-
Notifications
You must be signed in to change notification settings - Fork 262
340 lines (336 loc) · 17.8 KB
/
Copy pathrelease-candidate.yml
File metadata and controls
340 lines (336 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
name: Release Candidate
# Builds a fully signed and notarized release candidate. Runs only when
# dispatched manually via Actions -> Run workflow; the dispatcher should
# verify that CI is green for the chosen ref before clicking. Gated by
# the 'release-candidate' GitHub Environment, which must be configured
# with required reviewers under Settings -> Environments.
run-name: "Release Candidate: ${{ github.ref_name }} @ ${{ github.sha }}"
on:
workflow_dispatch:
concurrency:
group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}'
permissions:
contents: read
env:
# Point electron's and electron-builder's download caches at predictable,
# workspace-relative paths so actions/cache can hit them consistently
# across runner OSes.
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder
jobs:
release-candidate:
environment: release-candidate
permissions:
contents: read
strategy:
# Don't cancel sibling matrix cells when one fails; let each one
# finish so we get artifacts and full failure signal from every cell.
fail-fast: false
matrix:
# One cell per build target. Splitting macOS targets across three
# runners parallelizes Apple notarization waits, which is the
# dominant wall-clock cost on this workflow. Windows is split
# into AppX (unsigned, Store-bound) and installers (NSIS + MSI,
# signed) — keeping AppX separate so a signing failure on the
# installers doesn't lose the Store artifact.
include:
- os: macos-latest
target: mas-dev
- os: macos-latest
target: mas
- os: macos-latest
target: dmg
- os: windows-latest
target: appx
- os: windows-latest
target: installers
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
env:
# Organization-specific identifiers. The electron-builder wrapper reads these and injects them
# as CLI overrides (--c.appx.*, --c.msi.upgradeCode); electron-builder.yaml's ${env.X}
# interpolation only works for filename fields, not these config fields. Kept in repo Variables
# so a fork supplies its own values — if one is unset the wrapper omits that override and
# electron-builder uses its default, so a fork never builds under our published identity.
APPX_IDENTITY_NAME: ${{ vars.APPX_IDENTITY_NAME }}
APPX_PUBLISHER: ${{ vars.APPX_PUBLISHER }}
APPX_PUBLISHER_DISPLAY_NAME: ${{ vars.APPX_PUBLISHER_DISPLAY_NAME }}
MSI_UPGRADE_CODE: ${{ vars.MSI_UPGRADE_CODE }}
# App Store Connect API key — fastlane and electron-builder read
# different env var names but use the same credentials. The .p8
# file path (APPLE_API_KEY / APP_STORE_CONNECT_API_KEY_KEY_FILEPATH)
# is written and exported by the "Decode App Store Connect API Key"
# step below.
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APPLE_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_KEY_ID }}
APPLE_API_ISSUER: ${{ vars.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
steps:
# Pin the checkout to the SHA that was current when this workflow
# was dispatched. Without this, actions/checkout defaults to the
# tip of the selected ref, which could move during the run.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: 'npm'
node-version-file: '.nvmrc'
- name: Cache electron prebuilt binary
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: .cache/electron
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-electron-
- name: Cache electron-builder tools
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: .cache/electron-builder
key: ${{ runner.os }}-electron-builder-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-electron-builder-
- name: Debug info
run: |
cat <<EOF
Node version: $(node --version)
NPM version: $(npm --version)
GitHub ref: ${{ github.ref }}
GitHub head ref: ${{ github.head_ref }}
Working directory: $(pwd)
EOF
- name: Install NPM dependencies
run: npm ci
- name: Cache media library assets
# Keyed on the installed scratch-gui's package.json (after npm ci has
# placed it under node_modules), not on the project's package-lock.json.
# The asset set is determined entirely by the scratch-gui version;
# unrelated dep bumps shouldn't bust this cache.
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: static/fetched
key: media-${{ hashFiles('node_modules/@scratch/scratch-gui/package.json') }}
restore-keys: |
media-
- name: Setup Keys for Fastlane
if: matrix.os == 'macos-latest'
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
with:
ssh-private-key: ${{ secrets.FASTLANE_ACCESS_KEY }}
- name: Decode App Store Connect API Key
if: matrix.os == 'macos-latest'
env:
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
run: |
KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
printf '%s' "$APP_STORE_CONNECT_API_KEY_KEY" | base64 -d > "$KEY_PATH"
chmod 600 "$KEY_PATH"
echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH=$KEY_PATH" >> "$GITHUB_ENV"
- name: Setup Ruby
if: matrix.os == 'macos-latest'
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
bundler-cache: true
- name: Fastlane
env:
GIT_URL: ${{ secrets.FL_GIT_URL }}
STORAGE_MODE: ${{ vars.FL_STORAGE_MODE }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
if: matrix.os == 'macos-latest'
run: bundle exec fastlane prepare_signing
- name: Build (signed)
timeout-minutes: 30 # macOS notarization can take a while
env:
NODE_OPTIONS: --max-old-space-size=4096
# Azure Artifact Signing config for electron-builder's win.azureSignOptions, scoped to the
# installers cell — the only one that signs with Azure. Gating per-cell (rather than at job
# level) keeps the credentials out of the env of cells that don't sign: the macOS targets
# and the AppX cell (which ships unsigned for the Store to re-sign).
AZURE_SIGNING_ENDPOINT: ${{ matrix.target == 'installers' && vars.AZURE_SIGNING_ENDPOINT || '' }}
AZURE_SIGNING_ACCOUNT_NAME: ${{ matrix.target == 'installers' && vars.AZURE_SIGNING_ACCOUNT_NAME || '' }}
AZURE_SIGNING_PROFILE_NAME: ${{ matrix.target == 'installers' && vars.AZURE_SIGNING_PROFILE_NAME || '' }}
AZURE_SIGNING_PUBLISHER_NAME: ${{ matrix.target == 'installers' && vars.AZURE_SIGNING_PUBLISHER_NAME || '' }}
# electron-builder validates a complete Azure.Identity EnvironmentCredential set before
# signing — it does not fall through to the az-CLI session, so OIDC alone isn't sufficient
# and a client secret is required.
AZURE_TENANT_ID: ${{ matrix.target == 'installers' && secrets.AZURE_TENANT_ID || '' }}
AZURE_CLIENT_ID: ${{ matrix.target == 'installers' && secrets.AZURE_CLIENT_ID || '' }}
AZURE_CLIENT_SECRET: ${{ matrix.target == 'installers' && secrets.AZURE_CLIENT_SECRET || '' }}
run: npm run dist -- --target=${{ matrix.target }}
- name: Zip MAS-Dev build
if: matrix.target == 'mas-dev'
run: |
NPM_APP_VERSION="`node -pe "require('./package.json').version"`"
cd dist/mas-dev-universal
ditto -v -c -k --sequesterRsrc --keepParent --zlibCompressionLevel 9 \
Scratch*.app ../mas-dev-universal-${NPM_APP_VERSION}.zip
# Signature checks run before upload and gate the job: a bad signature fails the cell
# (and skips its uploads) rather than publishing an artifact we only think is signed.
# These catch regressions automatically; they don't replace on-device checks (SmartScreen
# reputation, the UAC publisher string, ARM64 native execution), which still need real hardware.
- name: Verify Windows signatures
if: matrix.target == 'installers'
shell: pwsh
run: |
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $signtool) { Write-Host '::error::signtool.exe not found on runner'; exit 1 }
$files = @(Get-ChildItem -Path dist -Filter 'Scratch *Setup.exe') +
@(Get-ChildItem -Path dist -Filter 'Scratch *.msi')
if (-not $files) { Write-Host '::error::No signed installers found to verify'; exit 1 }
$failed = $false
foreach ($f in $files) {
# Authoritative Authenticode check: validity + trust chain + any RFC3161 timestamp.
$out = & $signtool.FullName verify /pa /v $f.FullName 2>&1 | Out-String
Write-Host $out
if ($LASTEXITCODE -ne 0) { Write-Host "::error::signtool verify failed for $($f.Name)"; $failed = $true; continue }
# Signer must be us, not a stray or test certificate.
$sig = Get-AuthenticodeSignature -FilePath $f.FullName
$signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '(none)' }
if ($signer -notmatch 'Scratch Foundation') { Write-Host "::error::$($f.Name) signed by unexpected certificate: $signer"; $failed = $true; continue }
# A timestamp lets the signature outlive the cert. RFC3161 timestamps don't always populate
# TimeStamperCertificate, so warn (not fail) if neither it nor signtool's output shows one.
if (-not $sig.TimeStamperCertificate -and $out -notmatch 'imestamp') { Write-Host "::warning::$($f.Name) may not be timestamped" }
Write-Host "OK: $($f.Name) verified (signer=$signer)"
}
if ($failed) { exit 1 }
- name: Verify AppX is unsigned
if: matrix.target == 'appx'
shell: pwsh
run: |
$files = Get-ChildItem -Path dist -Filter 'Scratch *.appx'
if (-not $files) { Write-Host '::error::No AppX packages found to verify'; exit 1 }
$failed = $false
foreach ($f in $files) {
# AppX must stay unsigned: the Microsoft Store re-signs during certification, and a
# build-time signature here would mean signExts/our wrapper gating regressed.
$sig = Get-AuthenticodeSignature -FilePath $f.FullName
if ($sig.Status -ne 'NotSigned') { Write-Host "::error::$($f.Name) is unexpectedly signed (status=$($sig.Status))"; $failed = $true }
else { Write-Host "OK: $($f.Name) is unsigned as expected" }
}
if ($failed) { exit 1 }
- name: Verify macOS notarization
if: matrix.target == 'dmg'
run: |
DMG="`ls dist/Scratch*.dmg | head -n1`"
if [ -z "$DMG" ]; then echo "::error::No dmg found to verify"; exit 1; fi
echo "Verifying $DMG"
# electron-builder notarizes and staples the .app, then builds the DMG from it; the DMG
# container itself is not stapled (and never has been in our releases). So verify the .app
# inside — that's what Gatekeeper checks when a user launches, and what carries the ticket.
MOUNT="`hdiutil attach "$DMG" -nobrowse -readonly | tail -n1 | sed 's|.*\(/Volumes/\)|\1|'`"
if [ -z "$MOUNT" ]; then echo "::error::Failed to mount $DMG"; exit 1; fi
trap 'hdiutil detach "$MOUNT" -quiet || true' EXIT
APP="`ls -d "$MOUNT"/*.app | head -n1`"
if [ -z "$APP" ]; then echo "::error::No .app found inside $DMG"; exit 1; fi
echo "Verifying $APP"
# Notarization ticket must be stapled so Gatekeeper passes without a network round-trip.
xcrun stapler validate "$APP"
# Gatekeeper assessment: would a user's Mac allow launching this app?
spctl --assess --type execute --verbose=2 "$APP"
- name: Verify MAS signature
if: matrix.target == 'mas'
run: |
PKG="`ls dist/mas-universal/Scratch*.pkg 2>/dev/null | head -n1`"
if [ -z "$PKG" ]; then echo "::error::No MAS pkg found to verify"; exit 1; fi
echo "Verifying $PKG"
# MAS builds ARE signed at build time, with Apple App Store distribution certs — unlike
# AppX, which ships unsigned for the Microsoft Store. Confirm the pkg is signed here, where
# a broken signing/match setup is fixable, rather than letting it surface only at App Store
# Connect upload. spctl --assess is intentionally not used: MAS distribution certs are not
# Gatekeeper-valid for direct execution, so a correct MAS build fails spctl by design.
SIG="`pkgutil --check-signature "$PKG" 2>&1 || true`"
echo "$SIG"
echo "$SIG" | grep -q "Status: signed" || { echo "::error::$PKG is not validly signed"; exit 1; }
- name: Verify mas-dev signature
if: matrix.target == 'mas-dev'
run: |
APP="`ls -d dist/mas-dev-universal/Scratch*.app 2>/dev/null | head -n1`"
if [ -z "$APP" ]; then echo "::error::No mas-dev .app found to verify"; exit 1; fi
echo "Verifying $APP"
# A Mac App Store build is sandboxed: it only launches with a valid signature AND an
# embedded development provisioning profile authorizing the test device. If either is
# missing the .app won't run locally — and macOS reports only an opaque "damaged" error,
# so the cause is far easier to find here than after download. (No spctl/notarization
# check: mas-dev is signed with a development cert, not notarized, so it never passes
# Gatekeeper assessment — running it requires a registered device, not this gate.)
codesign --verify --deep --strict --verbose=2 "$APP"
codesign -dvv "$APP" 2>&1 | grep -i authority || true
test -f "$APP/Contents/embedded.provisionprofile" \
|| { echo "::error::$APP has no embedded provisioning profile; it will not launch on a test device"; exit 1; }
# One artifact per build output, so each cell uploads only what it produced.
# The 'installers' cell builds NSIS x3 + MSI in one pass and uploads them as four
# separate artifacts; the 'appx' cell builds three AppX archs and uploads three.
- name: Upload macOS mas-dev
if: matrix.target == 'mas-dev'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macOS-mas-dev
path: dist/mas-dev-universal-*.zip
compression-level: 0
- name: Upload macOS mas
if: matrix.target == 'mas'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macOS-mas
path: dist/mas-universal/Scratch*.pkg
compression-level: 0
- name: Upload macOS dmg
if: matrix.target == 'dmg'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macOS-dmg
path: dist/Scratch*.dmg
compression-level: 0
- name: Upload Windows AppX ia32
if: matrix.target == 'appx'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-AppX-ia32
path: "dist/Scratch *ia32.appx"
compression-level: 0
- name: Upload Windows AppX x64
if: matrix.target == 'appx'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-AppX-x64
path: "dist/Scratch *x64.appx"
compression-level: 0
- name: Upload Windows AppX arm64
if: matrix.target == 'appx'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-AppX-arm64
path: "dist/Scratch *arm64.appx"
compression-level: 0
- name: Upload Windows NSIS ia32
if: matrix.target == 'installers'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-NSIS-ia32
path: "dist/Scratch *ia32 Setup.exe"
compression-level: 0
- name: Upload Windows NSIS x64
if: matrix.target == 'installers'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-NSIS-x64
path: "dist/Scratch *x64 Setup.exe"
compression-level: 0
- name: Upload Windows NSIS arm64
if: matrix.target == 'installers'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-NSIS-arm64
path: "dist/Scratch *arm64 Setup.exe"
compression-level: 0
- name: Upload Windows MSI x64
if: matrix.target == 'installers'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Windows-MSI-x64
path: "dist/Scratch *x64.msi"
compression-level: 0