Skip to content

Commit f5c4b08

Browse files
committed
ci: verify artifact signatures before upload
Add per-cell signature gates that run after the build and before upload, so a bad signature fails the cell instead of publishing an artifact we only assume is signed. Each gate is placed where a failure is still fixable — in CI, on the build that produced it — rather than surfacing downstream at a store upload or on a user's machine: - installers: signtool verify /pa (validity + trust chain + timestamp) on each Setup.exe and the MSI, plus a signer-identity check that it's our cert. - appx: assert the packages are NOT signed — the Microsoft Store re-signs during certification, so this catches the inverse regression. - dmg: mount the image and validate the .app inside (stapler validate + spctl --assess --type execute). electron-builder staples the .app, not the dmg container, so the app is what carries the ticket and what Gatekeeper checks. - mas: pkgutil confirms the package is signed with Apple distribution certs, catching a broken signing/match setup before App Store Connect upload. No spctl here — MAS distribution certs aren't Gatekeeper-valid for direct launch. - mas-dev: codesign validity plus an embedded development provisioning profile, the two things a sandboxed MAS build needs to launch on a registered device. These are automated regression gates, not a replacement for on-device checks (SmartScreen reputation, UAC publisher string, ARM64 native execution).
1 parent 3841958 commit f5c4b08

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

.github/workflows/release-candidate.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,100 @@ jobs:
171171
cd dist/mas-dev-universal
172172
ditto -v -c -k --sequesterRsrc --keepParent --zlibCompressionLevel 9 \
173173
Scratch*.app ../mas-dev-universal-${NPM_APP_VERSION}.zip
174+
# Signature checks run before upload and gate the job: a bad signature fails the cell
175+
# (and skips its uploads) rather than publishing an artifact we only think is signed.
176+
# These catch regressions automatically; they don't replace on-device checks (SmartScreen
177+
# reputation, the UAC publisher string, ARM64 native execution), which still need real hardware.
178+
- name: Verify Windows signatures
179+
if: matrix.target == 'installers'
180+
shell: pwsh
181+
run: |
182+
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' -ErrorAction SilentlyContinue |
183+
Sort-Object FullName -Descending | Select-Object -First 1
184+
if (-not $signtool) { Write-Host '::error::signtool.exe not found on runner'; exit 1 }
185+
$files = @(Get-ChildItem -Path dist -Filter 'Scratch *Setup.exe') +
186+
@(Get-ChildItem -Path dist -Filter 'Scratch *.msi')
187+
if (-not $files) { Write-Host '::error::No signed installers found to verify'; exit 1 }
188+
$failed = $false
189+
foreach ($f in $files) {
190+
# Authoritative Authenticode check: validity + trust chain + any RFC3161 timestamp.
191+
$out = & $signtool.FullName verify /pa /v $f.FullName 2>&1 | Out-String
192+
Write-Host $out
193+
if ($LASTEXITCODE -ne 0) { Write-Host "::error::signtool verify failed for $($f.Name)"; $failed = $true; continue }
194+
# Signer must be us, not a stray or test certificate.
195+
$sig = Get-AuthenticodeSignature -FilePath $f.FullName
196+
$signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '(none)' }
197+
if ($signer -notmatch 'Scratch Foundation') { Write-Host "::error::$($f.Name) signed by unexpected certificate: $signer"; $failed = $true; continue }
198+
# A timestamp lets the signature outlive the cert. RFC3161 timestamps don't always populate
199+
# TimeStamperCertificate, so warn (not fail) if neither it nor signtool's output shows one.
200+
if (-not $sig.TimeStamperCertificate -and $out -notmatch 'imestamp') { Write-Host "::warning::$($f.Name) may not be timestamped" }
201+
Write-Host "OK: $($f.Name) verified (signer=$signer)"
202+
}
203+
if ($failed) { exit 1 }
204+
- name: Verify AppX is unsigned
205+
if: matrix.target == 'appx'
206+
shell: pwsh
207+
run: |
208+
$files = Get-ChildItem -Path dist -Filter 'Scratch *.appx'
209+
if (-not $files) { Write-Host '::error::No AppX packages found to verify'; exit 1 }
210+
$failed = $false
211+
foreach ($f in $files) {
212+
# AppX must stay unsigned: the Microsoft Store re-signs during certification, and a
213+
# build-time signature here would mean signExts/our wrapper gating regressed.
214+
$sig = Get-AuthenticodeSignature -FilePath $f.FullName
215+
if ($sig.Status -ne 'NotSigned') { Write-Host "::error::$($f.Name) is unexpectedly signed (status=$($sig.Status))"; $failed = $true }
216+
else { Write-Host "OK: $($f.Name) is unsigned as expected" }
217+
}
218+
if ($failed) { exit 1 }
219+
- name: Verify macOS notarization
220+
if: matrix.target == 'dmg'
221+
run: |
222+
DMG="`ls dist/Scratch*.dmg | head -n1`"
223+
if [ -z "$DMG" ]; then echo "::error::No dmg found to verify"; exit 1; fi
224+
echo "Verifying $DMG"
225+
# electron-builder notarizes and staples the .app, then builds the DMG from it; the DMG
226+
# container itself is not stapled (and never has been in our releases). So verify the .app
227+
# inside — that's what Gatekeeper checks when a user launches, and what carries the ticket.
228+
MOUNT="`hdiutil attach "$DMG" -nobrowse -readonly | tail -n1 | sed 's|.*\(/Volumes/\)|\1|'`"
229+
if [ -z "$MOUNT" ]; then echo "::error::Failed to mount $DMG"; exit 1; fi
230+
trap 'hdiutil detach "$MOUNT" -quiet || true' EXIT
231+
APP="`ls -d "$MOUNT"/*.app | head -n1`"
232+
if [ -z "$APP" ]; then echo "::error::No .app found inside $DMG"; exit 1; fi
233+
echo "Verifying $APP"
234+
# Notarization ticket must be stapled so Gatekeeper passes without a network round-trip.
235+
xcrun stapler validate "$APP"
236+
# Gatekeeper assessment: would a user's Mac allow launching this app?
237+
spctl --assess --type execute --verbose=2 "$APP"
238+
- name: Verify MAS signature
239+
if: matrix.target == 'mas'
240+
run: |
241+
PKG="`ls dist/mas-universal/Scratch*.pkg 2>/dev/null | head -n1`"
242+
if [ -z "$PKG" ]; then echo "::error::No MAS pkg found to verify"; exit 1; fi
243+
echo "Verifying $PKG"
244+
# MAS builds ARE signed at build time, with Apple App Store distribution certs — unlike
245+
# AppX, which ships unsigned for the Microsoft Store. Confirm the pkg is signed here, where
246+
# a broken signing/match setup is fixable, rather than letting it surface only at App Store
247+
# Connect upload. spctl --assess is intentionally not used: MAS distribution certs are not
248+
# Gatekeeper-valid for direct execution, so a correct MAS build fails spctl by design.
249+
SIG="`pkgutil --check-signature "$PKG" 2>&1 || true`"
250+
echo "$SIG"
251+
echo "$SIG" | grep -q "Status: signed" || { echo "::error::$PKG is not validly signed"; exit 1; }
252+
- name: Verify mas-dev signature
253+
if: matrix.target == 'mas-dev'
254+
run: |
255+
APP="`ls -d dist/mas-dev-universal/Scratch*.app 2>/dev/null | head -n1`"
256+
if [ -z "$APP" ]; then echo "::error::No mas-dev .app found to verify"; exit 1; fi
257+
echo "Verifying $APP"
258+
# A Mac App Store build is sandboxed: it only launches with a valid signature AND an
259+
# embedded development provisioning profile authorizing the test device. If either is
260+
# missing the .app won't run locally — and macOS reports only an opaque "damaged" error,
261+
# so the cause is far easier to find here than after download. (No spctl/notarization
262+
# check: mas-dev is signed with a development cert, not notarized, so it never passes
263+
# Gatekeeper assessment — running it requires a registered device, not this gate.)
264+
codesign --verify --deep --strict --verbose=2 "$APP"
265+
codesign -dvv "$APP" 2>&1 | grep -i authority || true
266+
test -f "$APP/Contents/embedded.provisionprofile" \
267+
|| { echo "::error::$APP has no embedded provisioning profile; it will not launch on a test device"; exit 1; }
174268
# One artifact per build output, so each cell uploads only what it produced.
175269
# The 'installers' cell builds NSIS x3 + MSI in one pass and uploads them as four
176270
# separate artifacts; the 'appx' cell builds three AppX archs and uploads three.

0 commit comments

Comments
 (0)