fix(ci): prevent duplicate unsigned exe files in GitHub Releases

Root cause: electron-builder auto-published unsigned files via implicit
tag-based publishing (despite release:false), and spaces in NSIS
artifact names caused naming mismatches — electron-builder sanitized
spaces to hyphens while softprops/action-gh-release converted them
to dots, resulting in three sets of exe files per architecture.

Changes:
- Hardcode NSIS artifactName with hyphens to eliminate spaces
- Add --publish never to explicitly prevent auto-publishing
- Update latest.yml with signed file hashes (preserving EB fields)
- Use lockfile-pinned app-builder-bin for blockmap regeneration
- Scope blockmap publishing to NSIS setup files only
- Fail hard if latest.yml references missing files
This commit is contained in:
Johannes Millan 2026-03-19 18:58:24 +01:00
parent 29441b7ae2
commit 757b7d4f52
3 changed files with 78 additions and 35 deletions

View file

@ -306,7 +306,8 @@ jobs:
with:
build_script_name: empty
github_token: ${{ secrets.github_token }}
release: false # Don't publish unsigned; we sign first, then publish
release: false
args: "--publish never" # Explicit: don't auto-publish unsigned exe files
# --- Code signing (SignPath failure is tolerated; post-signing verification failures will block the release) ---
@ -346,8 +347,8 @@ jobs:
exit 1
}
# Build a map of original filenames for matching
# SignPath may replace spaces with dots in filenames, so we normalize
# spaces, dots, and hyphens to find the corresponding original file
# SignPath may alter separators in filenames, so we normalize
# dots, hyphens, and spaces to find the corresponding original file
$originals = Get-ChildItem -Path .tmp/app-builds -Filter *.exe
$signed | ForEach-Object {
$signedName = $_.Name
@ -382,21 +383,26 @@ jobs:
}
}
- name: Regenerate blockmaps and update latest.yml for signed executables
- name: Regenerate blockmaps and generate latest.yml for signed executables
if: steps.signpath.outcome == 'success'
shell: pwsh
run: |
$buildDir = ".tmp/app-builds"
$exeFiles = Get-ChildItem -Path $buildDir -Filter "*.exe"
# Only regenerate blockmaps for NSIS setup files (portables don't use them)
$setupExes = Get-ChildItem -Path $buildDir -Filter "Super-Productivity-Setup*.exe"
foreach ($exe in $exeFiles) {
# Resolve the platform-specific app-builder binary from the lockfile-pinned package
$appBuilder = node -e "console.log(require('app-builder-bin').appBuilderPath)"
foreach ($exe in $setupExes) {
$blockmapPath = "$($exe.FullName).blockmap"
Write-Host "Regenerating blockmap for $($exe.Name)..."
$output = npx --yes app-builder-bin blockmap --input="$($exe.FullName)" --output="$blockmapPath" --compression=gzip
$output = & $appBuilder blockmap --input="$($exe.FullName)" --output="$blockmapPath" --compression=gzip
Write-Host " blockmap output: $output"
}
# Update latest.yml with new sha512 hashes and sizes from signed executables
# Update latest.yml with hashes from signed executables
# (electron-builder generates it during build, but with hashes of unsigned files)
node -e "
const fs = require('fs');
const path = require('path');
@ -404,36 +410,72 @@ jobs:
const crypto = require('crypto');
const buildDir = '.tmp/app-builds';
const ymlFiles = fs.readdirSync(buildDir).filter(f => f.startsWith('latest') && f.endsWith('.yml'));
const ymlPath = path.join(buildDir, 'latest.yml');
for (const ymlFile of ymlFiles) {
const ymlPath = path.join(buildDir, ymlFile);
function getFileInfo(filePath) {
const fileBuffer = fs.readFileSync(filePath);
const info = {
sha512: crypto.createHash('sha512').update(fileBuffer).digest('base64'),
size: fileBuffer.length,
};
const blockmapPath = filePath + '.blockmap';
if (fs.existsSync(blockmapPath)) {
info.blockMapSize = fs.statSync(blockmapPath).size;
}
return info;
}
if (fs.existsSync(ymlPath)) {
// Update existing latest.yml (preserves all electron-builder fields)
const ymlData = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
if (ymlData.files) {
for (const fileEntry of ymlData.files) {
const filePath = path.join(buildDir, fileEntry.url);
if (fs.existsSync(filePath)) {
const fileBuffer = fs.readFileSync(filePath);
const sha512 = crypto.createHash('sha512').update(fileBuffer).digest('base64');
const size = fileBuffer.length;
console.log(ymlFile + ': Updated ' + fileEntry.url + ' sha512=' + sha512.substring(0, 20) + '... size=' + size);
fileEntry.sha512 = sha512;
fileEntry.size = size;
}
}
if (!ymlData.files || ymlData.files.length === 0) {
console.error('ERROR: latest.yml has no file entries');
process.exit(1);
}
for (const fileEntry of ymlData.files) {
const filePath = path.join(buildDir, fileEntry.url);
if (!fs.existsSync(filePath)) {
console.error('ERROR: File referenced in latest.yml not found: ' + fileEntry.url);
process.exit(1);
}
const info = getFileInfo(filePath);
Object.assign(fileEntry, info);
console.log(' Updated ' + fileEntry.url + ': sha512=' + info.sha512.substring(0, 20) + '... size=' + info.size);
}
// Update top-level sha512 to match primary file
if (ymlData.path) {
const primary = ymlData.files && ymlData.files.find(f => f.url === ymlData.path);
if (primary) {
ymlData.sha512 = primary.sha512;
}
const primary = ymlData.files.find(f => f.url === ymlData.path);
if (primary) { ymlData.sha512 = primary.sha512; }
}
fs.writeFileSync(ymlPath, yaml.dump(ymlData, { lineWidth: -1 }));
console.log(ymlFile + ' updated successfully');
console.log('latest.yml updated with signed file hashes');
} else {
// Fallback: generate from scratch if latest.yml was not created
console.log('No existing latest.yml found, generating from scratch');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const setupExes = fs.readdirSync(buildDir)
.filter(f => f.startsWith('Super-Productivity-Setup') && f.endsWith('.exe'));
if (setupExes.length === 0) {
console.error('ERROR: No setup executables found in ' + buildDir);
process.exit(1);
}
const files = setupExes.map(filename => {
const filePath = path.join(buildDir, filename);
const info = getFileInfo(filePath);
console.log(' ' + filename + ': sha512=' + info.sha512.substring(0, 20) + '... size=' + info.size);
return { url: filename, ...info };
});
const primary = files.find(f => f.url === 'Super-Productivity-Setup.exe')
|| files.find(f => f.url === 'Super-Productivity-Setup-x64.exe')
|| files[0];
const latestYml = {
version: pkg.version,
files,
path: primary.url,
sha512: primary.sha512,
releaseDate: new Date().toISOString(),
};
fs.writeFileSync(ymlPath, yaml.dump(latestYml, { lineWidth: -1 }));
console.log('Generated latest.yml for ' + files.length + ' signed executables');
}
"
@ -445,7 +487,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
files: |
.tmp/app-builds/*.exe
.tmp/app-builds/*.blockmap
.tmp/app-builds/Super-Productivity-Setup*.blockmap
.tmp/app-builds/latest*.yml
draft: true
prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}

View file

@ -162,7 +162,7 @@ After a release build completes:
2. **Verify signature** on Windows using PowerShell:
```powershell
Get-AuthenticodeSignature ".\Super Productivity Setup-x64.exe" | Format-List
Get-AuthenticodeSignature ".\Super-Productivity-Setup-x64.exe" | Format-List
```
3. **Check output** shows:

View file

@ -30,7 +30,8 @@ win:
publish:
- github
nsis:
artifactName: ${productName} Setup-${arch}.${ext}
# Hardcoded to avoid spaces (SignPath converts spaces to dots). Also referenced in build.yml.
artifactName: Super-Productivity-Setup-${arch}.${ext}
# allow to install to custom location
oneClick: false
allowToChangeInstallationDirectory: true