From fa88e57f0ed733821db3f82b811e9a007c24ff6e Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:04:51 -0700 Subject: [PATCH 1/9] build: externalize AppX identifiers to env vars scratch-desktop is open source, but several identifiers in electron-builder.yaml are organization-specific: a fork that builds without changing them would publish artifacts that collide with ours (same Store identityName, same publisher CN). Move the AppX identifiers to environment variables sourced from GitHub repository Variables. Both workflows export them at job level so electron-builder's \${env.X} interpolation finds them during build. Forks that don't set the variables get a build failure rather than a silent collision with our identifiers. Variables introduced: - APPX_IDENTITY_NAME - APPX_PUBLISHER - APPX_PUBLISHER_DISPLAY_NAME --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/release-candidate.yml | 6 ++++++ electron-builder.yaml | 12 +++++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ccf3d49..1abcdf3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: defaults: run: shell: bash + env: + # Organization-specific identifiers used by electron-builder.yaml's ${env.X} + # interpolation. Kept in repo Variables so a fork builds with its own values + # (or fails loudly if unset) rather than colliding with our published artifacts. + APPX_IDENTITY_NAME: ${{ vars.APPX_IDENTITY_NAME }} + APPX_PUBLISHER: ${{ vars.APPX_PUBLISHER }} + APPX_PUBLISHER_DISPLAY_NAME: ${{ vars.APPX_PUBLISHER_DISPLAY_NAME }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index b12da7f8..a72fba62 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -68,6 +68,12 @@ jobs: # through the build (so we get an installer artifact) but skips # the signing step. SCRATCH_SHOULD_SIGN: ${{ matrix.os != 'windows-latest' }} + # Organization-specific identifiers used by electron-builder.yaml's ${env.X} + # interpolation. Kept in repo Variables so a fork builds with its own values + # (or fails loudly if unset) rather than colliding with our published artifacts. + APPX_IDENTITY_NAME: ${{ vars.APPX_IDENTITY_NAME }} + APPX_PUBLISHER: ${{ vars.APPX_PUBLISHER }} + APPX_PUBLISHER_DISPLAY_NAME: ${{ vars.APPX_PUBLISHER_DISPLAY_NAME }} # 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) diff --git a/electron-builder.yaml b/electron-builder.yaml index 21dd187f..ea2d7f74 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -19,9 +19,9 @@ mac: extendInfo: ITSAppUsesNonExemptEncryption: false NSCameraUsageDescription: >- - This app requires camera access when using the video sensing blocks. + This app requires camera access when using the video sensing blocks. NSMicrophoneUsageDescription: >- - This app requires microphone access when recording sounds or detecting loudness. + This app requires microphone access when recording sounds or detecting loudness. gatekeeperAssess: true hardenedRuntime: true icon: buildResources/ScratchDesktop.icns @@ -47,9 +47,11 @@ win: - appx - nsis appx: - identityName: ScratchFoundation.ScratchDesktop - publisherDisplayName: "Scratch Foundation" - publisher: "CN=2EC43DF1-469A-4119-9AB9-568A0A1FF65F" + # Organization-specific identifiers come from environment variables so a fork can build + # without colliding with our published artifacts. See README for required env vars. + identityName: "${env.APPX_IDENTITY_NAME}" + publisherDisplayName: "${env.APPX_PUBLISHER_DISPLAY_NAME}" + publisher: "${env.APPX_PUBLISHER}" artifactName: "Scratch ${version} ${arch}.${ext}" nsis: oneClick: false # allow user to choose per-user or per-machine From d52225b70c8e326934534023763d23e50e3c7e77 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:07:11 -0700 Subject: [PATCH 2/9] build: expand Windows distribution matrix to ARM64 + add MSI Windows now ships across three formats and three architectures: - NSIS direct download: x64, arm64, and existing ia32 - MSI for managed deployment: x64 only - Microsoft Store AppX: new arm64 alongside existing ia32 and x64 The wrapper organizes the Windows targets into two electron-builder passes: AppX in one (intentionally unsigned, since the Microsoft Store re-signs at certification), and NSIS+MSI together in a single signed pass via the new windowsInstallers entry. The release- candidate matrix mirrors this with two Windows runners. NSIS artifactName now includes \${arch} so the three multi-arch builds don't collide on the same filename. The ia32 NSIS filename changes from "Scratch X.Y.Z Setup.exe" to "Scratch X.Y.Z ia32 Setup.exe"; the scratch-www download-page link will need updating. MSI upgradeCode sources from a repository Variable (MSI_UPGRADE_CODE) so a fork generates and uses its own GUID rather than inheriting ours. Future versions upgrade in place when the GUID stays stable. Initial arch scope is x64 only; ARM64 institutional deployments use the Store AppX via Intune, avoiding the WiX 4 ARM64 limitation electron-builder currently carries. --- .github/workflows/ci.yml | 1 + .github/workflows/release-candidate.yml | 16 ++++++++++------ electron-builder.yaml | 11 ++++++++++- scripts/electron-builder-wrapper.js | 24 +++++++++++++++++++----- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1abcdf3b..6a382bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: 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 }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index a72fba62..bcfceb51 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -32,9 +32,10 @@ jobs: 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 targets are - # split so an appx-only failure doesn't lose the nsis artifact - # (and vice versa). + # 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 @@ -53,9 +54,11 @@ jobs: artifact-name: Windows-appx artifact-path: dist/Scratch*.appx - os: windows-latest - target: nsis - artifact-name: Windows-nsis - artifact-path: dist/Scratch*.exe + target: installers + artifact-name: Windows-installers + artifact-path: | + dist/Scratch*.exe + dist/Scratch*.msi runs-on: ${{ matrix.os }} defaults: run: @@ -74,6 +77,7 @@ jobs: 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) diff --git a/electron-builder.yaml b/electron-builder.yaml index ea2d7f74..f37164d7 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -55,7 +55,16 @@ appx: artifactName: "Scratch ${version} ${arch}.${ext}" nsis: oneClick: false # allow user to choose per-user or per-machine - artifactName: "Scratch ${version} Setup.${ext}" + artifactName: "Scratch ${version} ${arch} Setup.${ext}" +msi: + # perMachine + HKLM registration is required for Group Policy / SCCM / Intune deployment. + perMachine: true + oneClick: false + # upgradeCode is a stable GUID that ties future MSIs to past installations: future versions + # upgrade in place rather than installing alongside earlier ones. Sourced from a repository + # Variable so a fork generates and uses its own GUID rather than inheriting ours. + upgradeCode: "${env.MSI_UPGRADE_CODE}" + artifactName: "Scratch ${version} ${arch}.${ext}" linux: target: AppImage executableName: scratch-desktop diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index e5cbcf16..7669ece3 100644 --- a/scripts/electron-builder-wrapper.js +++ b/scripts/electron-builder-wrapper.js @@ -117,11 +117,22 @@ const calculateTargets = function (wrapperConfig) { platform: 'darwin' }, microsoftStore: { - name: 'appx:ia32 appx:x64', + name: 'appx:ia32 appx:x64 appx:arm64', platform: 'win32' }, windowsDirectDownload: { - name: 'nsis:ia32', + name: 'nsis:ia32 nsis:x64 nsis:arm64', + platform: 'win32' + }, + windowsManagedDeployment: { + name: 'msi:x64', + platform: 'win32' + }, + windowsInstallers: { + // Combined pass: every Windows target that should be code-signed. + // AppX intentionally lives in its own pass because it must not be signed at build time + // (the Microsoft Store re-signs during certification). + name: 'nsis:ia32 nsis:x64 nsis:arm64 msi:x64', platform: 'win32' }, linuxAppImage: { @@ -141,7 +152,9 @@ const calculateTargets = function (wrapperConfig) { 'mas-dev': availableTargets.macAppStoreDev, 'dmg': availableTargets.macDirectDownload, 'appx': availableTargets.microsoftStore, - 'nsis': availableTargets.windowsDirectDownload + 'nsis': availableTargets.windowsDirectDownload, + 'msi': availableTargets.windowsManagedDeployment, + 'installers': availableTargets.windowsInstallers }; const selected = targetsByShortName[wrapperConfig.target]; if (!selected) { @@ -155,9 +168,10 @@ const calculateTargets = function (wrapperConfig) { const targets = []; switch (process.platform) { case 'win32': - // Run in two passes so we can skip signing the AppX for distribution through the MS Store. + // Two passes: AppX intentionally unsigned (the Microsoft Store re-signs during cert), + // everything else signed in a single pass. targets.push(availableTargets.microsoftStore); - targets.push(availableTargets.windowsDirectDownload); + targets.push(availableTargets.windowsInstallers); break; case 'darwin': // Running 'dmg' and 'mas' in the same pass causes electron-builder to skip signing the non-MAS app copy. From bbc5f9ddfc9e8ac993adbf08a51a6369368ec8c2 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:07:22 -0700 Subject: [PATCH 3/9] docs: add Windows build matrix doc Captures the 7-artifact shipping matrix (NSIS x ia32+x64+arm64, MSI x64, AppX x ia32+x64+arm64), the rationale for the empty cells (no ARM64 or ia32 MSI), how the wrapper organizes Windows into two electron-builder passes, and a short guide for identifying which build a user has when triaging reports. Linked from README. --- README.md | 4 +++ docs/windows-build-matrix.md | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/windows-build-matrix.md diff --git a/README.md b/README.md index 6d694154..d872216e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Scratch 3.0 as a standalone desktop application +## Documentation + +- [Windows build matrix](docs/windows-build-matrix.md) — which {target × arch} combinations we ship for Windows and why + ## Developer Instructions ### Releasing a new version diff --git a/docs/windows-build-matrix.md b/docs/windows-build-matrix.md new file mode 100644 index 00000000..2e4ad98e --- /dev/null +++ b/docs/windows-build-matrix.md @@ -0,0 +1,63 @@ +# Windows build matrix + +scratch-desktop ships Windows in three formats across up to three architectures. This document captures which cells we ship, which we deliberately don't, and why. + +## Matrix + +| Target | x64 | ARM64 | x86 (ia32) | +|---|---|---|---| +| **NSIS** (direct download from scratch.mit.edu) | ✅ | ✅ | ✅ | +| **MSI** (managed deployment via Intune/SCCM/GPO) | ✅ | — | — | +| **AppX** (Microsoft Store) | ✅ | ✅ | ✅ | + +Seven shipping artifacts per release. + +## Format-by-format + +### NSIS + +The interactive setup.exe a home user downloads from the website. Allows the user to pick per-user or per-machine install. Runs as the foreground process during install — UAC prompts as you'd expect for a Windows installer. + +Ships on **all three architectures**. Surface Pro X, Snapdragon-X laptops, and other Windows-on-ARM devices get a native ARM64 binary; older 32-bit devices keep their ia32 path. + +### MSI + +The managed-deployment installer for IT administrators using Intune, SCCM, or Group Policy. Per-machine install with HKLM registration. Silent install supported via `msiexec /i "Scratch x64.msi" /quiet` (the filename has spaces, so quote it). Uninstall via `msiexec /x` or Add/Remove Programs. + +Ships **x64 only**. + +- **No ARM64 MSI**: ARM64 managed-deployment environments use the Store AppX deployed through Intune's "Microsoft Store app (new)" type, which is Microsoft's recommended ARM64 enterprise path. The MSI route on ARM64 would currently produce an x64-stamped artifact installing an ARM64 payload (an electron-builder + WiX 4 limitation), which some enterprise validation tooling rejects. +- **No ia32 MSI**: 32-bit Windows fleets large enough to centralize deployment are uncommon enough that we haven't built ia32 MSI testing into the matrix. Adding it later is a one-line change if a specific deployment use case justifies it. + +### AppX + +The Microsoft Store distribution. Auto-updates through Store. Sandboxed by default. Reaches the broadest passive-update audience. + +Ships on **all three architectures**. Modern Intune-managed environments — including those with ARM64 devices — deploy this through Intune's Store integration without needing the MSI or NSIS paths. + +## How multi-arch builds are produced + +`scripts/electron-builder-wrapper.js` is authoritative for which architectures each target builds. Each entry in `availableTargets` uses electron-builder's `:` CLI syntax, with multiple archs joined by spaces: + +```js +microsoftStore: { name: 'appx:ia32 appx:x64 appx:arm64', platform: 'win32' }, +windowsDirectDownload: { name: 'nsis:ia32 nsis:x64 nsis:arm64', platform: 'win32' }, +windowsManagedDeployment: { name: 'msi:x64', platform: 'win32' }, +windowsInstallers: { name: 'nsis:ia32 nsis:x64 nsis:arm64 msi:x64', platform: 'win32' }, +``` + +This form overrides any per-target `arch:` list in `electron-builder.yaml`. To add or remove an arch from a target, edit the wrapper string. + +Local `npm run dist` and the release-candidate workflow both build Windows in two passes: AppX in one (intentionally unsigned — the Microsoft Store re-signs during certification), and NSIS + MSI together in `windowsInstallers` (signed in a single electron-builder invocation). The per-format short names (`nsis`, `msi`) remain available for local granular builds. + +The release-candidate workflow uses `--target=` to fan targets out across runners; the mapping from shortname to wrapper entry is in the same file. + +## Identifying which build a user has + +Useful for diagnosing reports from users on unusual configurations. + +- **NSIS**: filename includes the arch (`Scratch ia32 Setup.exe`, etc.). +- **MSI**: filename includes the arch. +- **AppX**: visible in Settings → Apps → Scratch → Advanced options → "Architecture" field, or in the Store listing. + +To confirm an installed app is running natively vs. under emulation on Windows-on-ARM: Task Manager → Details → Scratch.exe → the **Architecture** column should read `ARM64` for native ARM64 builds; `x86` or `x64` indicates emulation. From ab40399a619a274267ac757b83ac6eb589d2e2e9 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:11:36 -0700 Subject: [PATCH 4/9] ci: split release-candidate uploads per artifact and drop compression The previous release-candidate uploads bundled multiple installers into single artifacts via a generic upload step driven by matrix variables. Splitting per-artifact lets consumers grab just the cell they care about instead of pulling a multi-installer zip. Also sets compression-level: 0 on every upload. The artifact files (.appx, .exe, .msi, .dmg, .pkg, .zip) are all already-compressed containers; re-gzipping costs CPU for ~0% size reduction. --- .github/workflows/release-candidate.yml | 86 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index bcfceb51..a7755f86 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -39,26 +39,14 @@ jobs: include: - os: macos-latest target: mas-dev - artifact-name: macOS-mas-dev - artifact-path: dist/mas-dev-universal-*.zip - os: macos-latest target: mas - artifact-name: macOS-mas - artifact-path: dist/mas-universal/Scratch*.pkg - os: macos-latest target: dmg - artifact-name: macOS-dmg - artifact-path: dist/Scratch*.dmg - os: windows-latest target: appx - artifact-name: Windows-appx - artifact-path: dist/Scratch*.appx - os: windows-latest target: installers - artifact-name: Windows-installers - artifact-path: | - dist/Scratch*.exe - dist/Scratch*.msi runs-on: ${{ matrix.os }} defaults: run: @@ -180,8 +168,76 @@ jobs: cd dist/mas-dev-universal ditto -v -c -k --sequesterRsrc --keepParent --zlibCompressionLevel 9 \ Scratch*.app ../mas-dev-universal-${NPM_APP_VERSION}.zip - - name: Upload artifact + # 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: ${{ matrix.artifact-name }} - path: ${{ matrix.artifact-path }} + name: Windows-MSI-x64 + path: "dist/Scratch *x64.msi" + compression-level: 0 From 9005202f0844cfa33ad33d404dee1613b5b63d20 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:14:35 -0700 Subject: [PATCH 5/9] ci: limit PR-time CI to one installer per platform The PR-time CI workflow previously built the full Windows installer matrix (NSIS x3 + MSI + AppX x3) on every push. That's a lot of billed minutes for installer packaging that rarely changes. Now each platform builds one representative installer that non- developer teammates can grab and try from any PR: NSIS x64 on Windows (via a new 'nsis-x64' wrapper shortname) and DMG on macOS. Both platforms specify their target explicitly so the wrapper's default behavior isn't load-bearing. The full multi-arch matrix still runs on release-candidate dispatch where the artifacts actually ship. Estimated savings: Windows CI job drops from ~11 min to ~4 min, with proportional reductions in billed compute minutes. --- .github/workflows/ci.yml | 24 +++++++++++++++++------- scripts/electron-builder-wrapper.js | 9 ++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a382bc0..e3e6d90b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,23 +84,33 @@ jobs: media- - name: Test run: npm run test - - name: Build + # Each platform builds one representative installer on PR-time CI so non-developer + # teammates can grab and try the latest from any PR. The full multi-arch matrix + # only runs on release-candidate dispatch where the artifacts actually ship. + - name: Build macOS DMG + if: matrix.os == 'macos-latest' timeout-minutes: 30 env: # TODO: fix whatever is causing excessive memory usage during build NODE_OPTIONS: --max-old-space-size=4096 - run: npm run distDev + run: npm run distDev -- --target=dmg + - name: Build Windows NSIS x64 + if: matrix.os == 'windows-latest' + timeout-minutes: 30 + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: npm run distDev -- --target=nsis-x64 - name: Upload macOS artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: matrix.os == 'macos-latest' with: name: macOS-unsigned path: dist/Scratch*.dmg - - name: Upload Windows artifacts + compression-level: 0 + - name: Upload Windows NSIS x64 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: matrix.os == 'windows-latest' with: - name: Windows-unsigned - path: | - dist/Scratch*.appx - dist/Scratch*.exe + name: Windows-NSIS-x64 + path: "dist/Scratch *x64 Setup.exe" + compression-level: 0 diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index 7669ece3..5d368371 100644 --- a/scripts/electron-builder-wrapper.js +++ b/scripts/electron-builder-wrapper.js @@ -135,6 +135,12 @@ const calculateTargets = function (wrapperConfig) { name: 'nsis:ia32 nsis:x64 nsis:arm64 msi:x64', platform: 'win32' }, + windowsNsisX64: { + // Single representative installer for PR-time CI — the smallest build that still + // produces something a non-developer teammate can install and try on Windows x64. + name: 'nsis:x64', + platform: 'win32' + }, linuxAppImage: { name: 'appimage', platform: 'linux' @@ -154,7 +160,8 @@ const calculateTargets = function (wrapperConfig) { 'appx': availableTargets.microsoftStore, 'nsis': availableTargets.windowsDirectDownload, 'msi': availableTargets.windowsManagedDeployment, - 'installers': availableTargets.windowsInstallers + 'installers': availableTargets.windowsInstallers, + 'nsis-x64': availableTargets.windowsNsisX64 }; const selected = targetsByShortName[wrapperConfig.target]; if (!selected) { From 264d9df34160d7697a7a67810ff669cb4aafe657 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:38:35 -0700 Subject: [PATCH 6/9] ci: include branch and SHA in release-candidate run name workflow_dispatch runs don't get a commit message in the GHA expression context, so by default the run list shows only the workflow name. Setting run-name to branch + SHA gives each manual dispatch a distinct, scannable title without requiring the dispatcher to fill in an input. --- .github/workflows/release-candidate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index a7755f86..52dce80a 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -4,6 +4,7 @@ name: Release Candidate # 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: From 0ea25ba6998fca0299b00e73fc22d3bcff9dbade Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:26:35 -0700 Subject: [PATCH 7/9] build: sign Windows installers with Azure Artifact Signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign EXE and MSI via electron-builder's win.azureSignOptions, authenticated with an Azure App Registration client secret (AZURE_CLIENT_SECRET alongside the tenant and client IDs). electron-builder validates a complete Azure.Identity EnvironmentCredential before signing and does not fall back to an ambient CLI session, so a client secret is required rather than a login session; the credential is scoped to the release-candidate environment, which layers its human-gated approval with the Azure auth. The Azure config and credentials are set on the installers cell's build step only (gated on matrix.target), so they never enter the env of cells that don't sign with Azure — the macOS targets and the unsigned AppX cell. AppX is excluded via signExts: the Microsoft Store re-signs during certification with a Store-issued publisher cert, and matching its CN at build time isn't feasible. The wrapper skips signing in --mode=dev, so PR-time CI and local dev builds build without needing Azure auth. Removes the legacy WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD flow. --- .github/workflows/release-candidate.yml | 30 ++++++------ README.md | 28 ++++------- electron-builder.yaml | 9 ++++ scripts/electron-builder-wrapper.js | 65 ++++++++++++++++++++----- 4 files changed, 87 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 52dce80a..54f56da3 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -53,13 +53,6 @@ jobs: run: shell: bash env: - # Temporary workaround: Windows signing is currently broken due to - # a security-policy change. See - # https://github.com/electron/windows-installer/issues/473 - # While this is true, the release-candidate job still runs Windows - # through the build (so we get an installer artifact) but skips - # the signing step. - SCRATCH_SHOULD_SIGN: ${{ matrix.os != 'windows-latest' }} # Organization-specific identifiers used by electron-builder.yaml's ${env.X} # interpolation. Kept in repo Variables so a fork builds with its own values # (or fails loudly if unset) rather than colliding with our published artifacts. @@ -154,14 +147,21 @@ jobs: timeout-minutes: 30 # macOS notarization can take a while env: NODE_OPTIONS: --max-old-space-size=4096 - # Only expose Windows code-signing secrets to runs that will use them. - # SCRATCH_SHOULD_SIGN is currently false on Windows (temporary workaround above); - # both expressions evaluate to '' until Windows signing is re-enabled. - WIN_CSC_LINK: ${{ matrix.os == 'windows-latest' && env.SCRATCH_SHOULD_SIGN == 'true' && secrets.WIN_CSC_LINK || - '' }} - WIN_CSC_KEY_PASSWORD: ${{ matrix.os == 'windows-latest' && env.SCRATCH_SHOULD_SIGN == 'true' && secrets.WIN_CSC_KEY_PASSWORD - || '' }} - run: npm run ${{ env.SCRATCH_SHOULD_SIGN == 'true' && 'dist' || 'distDev' }} -- --target=${{ matrix.target }} + # 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: | diff --git a/README.md b/README.md index d872216e..c399ec5a 100644 --- a/README.md +++ b/README.md @@ -60,27 +60,19 @@ other changes which might affect the media libraries. `npm run dist` -Node that on macOS this will require installing various certificates. +Note that on macOS this will require installing various certificates. -#### Signing the NSIS installer (Windows, non-store) +#### Code signing (Windows) -*This section is relevant only to members of the Scratch Team.* +For an unsigned local build, use `npm run distDev` (it needs no signing configuration). `npm run dist` is the +signed release build; on Windows it requires Azure Artifact Signing credentials, so in practice it runs only in CI. -By default all Windows installers are unsigned. An APPX package for the Microsoft Store shouldn't be signed: it will -be signed automatically as part of the store submission process. On the other hand, the non-Store NSIS installer -should be signed. - -To generate a signed NSIS installer: - -1. Acquire our latest digital signing certificate and save it on your computer as a `p12` file. -2. Set `WIN_CSC_LINK` to the path to your certificate file. For maximum compatibility I use forward slashes. - - CMD: `set WIN_CSC_LINK=C:/Users/You/Somewhere/Certificate.p12` - - PowerShell: `$env:WIN_CSC_LINK = "C:/Users/You/Somewhere/Certificate.p12"` -3. Set `WIN_CSC_KEY_PASSWORD` to the password string associated with your P12 file. - - CMD: `set WIN_CSC_KEY_PASSWORD=superSecret` - - PowerShell: `$env:WIN_CSC_KEY_PASSWORD = "superSecret"` -4. Build the NSIS installer only: building the APPX installer will fail if these environment variables are set. - - `npm run dist -- -w nsis` +Signed Windows builds are produced only in CI. The +[`release-candidate`](.github/workflows/release-candidate.yml) workflow signs the NSIS and MSI installers with +Azure Artifact Signing, and leaves the Store AppX unsigned because the Microsoft Store re-signs it during +certification. (The earlier `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` certificate flow has been removed.) See the +[Windows build matrix](docs/windows-build-matrix.md) for the full set of shipping artifacts and how each is +produced. #### Workaround for code signing issue in macOS diff --git a/electron-builder.yaml b/electron-builder.yaml index f37164d7..403c65a2 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -46,6 +46,15 @@ win: target: - appx - nsis + # AppX is excluded from build-time signing: the Microsoft Store re-signs during + # certification with a Store-issued publisher cert, and our build-time cert's + # subject would not match the manifest's Publisher attribute. + signExts: ["exe", "msi", "!appx", "!appxbundle", "!appxupload"] + # azureSignOptions is supplied by scripts/electron-builder-wrapper.js only when + # signing is enabled (--mode=dist). Declaring it here would also activate it for + # PR CI and local --mode=dev builds, where Azure auth isn't available — and some + # signing paths (notably the NSIS uninstaller) ignore signAndEditExecutable, so + # gating per-target isn't reliable. Keeping the config off entirely is cleaner. appx: # Organization-specific identifiers come from environment variables so a fork can build # without colliding with our published artifacts. See README for required env vars. diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index 5d368371..a84edf42 100644 --- a/scripts/electron-builder-wrapper.js +++ b/scripts/electron-builder-wrapper.js @@ -3,9 +3,15 @@ * Running this script with no command line parameters should build all targets for the current platform. * Pass `--target=` to build exactly one target instead of the platform default set; the matrix in * `release-candidate.yml` uses this to fan a multi-target serial pass out into one runner per target. Valid short - * names: `mas`, `mas-dev`, `dmg`, `appx`, `nsis`. - * On Windows, make sure to set CSC_* or WIN_CSC_* environment variables or the NSIS build will fail. - * On Mac, the CSC_* variables are optional but will be respected if present. + * names: `mas`, `mas-dev`, `dmg`, `appx`, `nsis`, `msi`, `installers`, `nsis-x64`. + * Windows signing uses Azure Artifact Signing. This script injects `win.azureSignOptions` as + * electron-builder CLI overrides from the `AZURE_SIGNING_*` environment variables (not via + * `electron-builder.yaml`, which would sign every build, including PR CI and local dev). Auth uses an + * Azure client secret read by Azure.Identity's EnvironmentCredential (`AZURE_TENANT_ID` / + * `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`, set in `release-candidate.yml`). Signing happens only + * in --mode=dist; --mode=dev (the default) and --mode=dir build unsigned. + * On Mac, the CSC_* variables are optional; they're respected only for signed (--mode=dist) builds + * and stripped otherwise. * See also: https://www.electron.build/code-signing */ @@ -46,18 +52,15 @@ const getPlatformFlag = function () { * Run `electron-builder` once to build one or more target(s). * @param {object} wrapperConfig - overall configuration object for the wrapper script. * @param {object} target - the target to build in this call. - * If the `target.name` is `'nsis'` then the environment must contain code-signing config (CSC_* or WIN_CSC_*). - * If the `target.name` is `'appx'` then code-signing config will be stripped from the environment if present. */ const runBuilder = function (wrapperConfig, target) { - // the AppX build fails if CSC_* or WIN_CSC_* variables are set - const shouldStripCSC = (target.name.indexOf('appx') === 0) || (!wrapperConfig.doSign); + // A pass can bundle several space-separated targets (e.g. the installers pass), so parse them once. + const targetNames = target.name.trim().split(/\s+/); + // Strip CSC_* env vars when any AppX target is in the pass (CSC would conflict with the Store-managed + // cert) and for unsigned builds. Windows signing itself now goes through Azure (not CSC), so these + // vars are typically absent anyway. + const shouldStripCSC = targetNames.some(name => name.startsWith('appx')) || (!wrapperConfig.doSign); const childEnvironment = shouldStripCSC ? stripCSC(process.env) : process.env; - if (wrapperConfig.doSign && - (target.name.indexOf('nsis') === 0) && - !(childEnvironment.CSC_LINK || childEnvironment.WIN_CSC_LINK)) { - throw new Error(`Signing NSIS build requires CSC_LINK or WIN_CSC_LINK`); - } const platformFlag = getPlatformFlag(); let allArgs = [platformFlag, target.name]; if (target.platform === 'darwin') { @@ -75,6 +78,44 @@ const runBuilder = function (wrapperConfig, target) { // APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER are present in // the environment. } + // Appx-only means every target name in the pass starts with 'appx'. A mixed pass that bundles + // appx with nsis or msi keeps signing on for the non-appx parts; only the pure-appx case opts + // out so the Store can re-sign its container with unsigned inner binaries (today's behavior). + const isAppxOnly = targetNames.every(name => name.startsWith('appx')); + if (target.platform === 'win32' && wrapperConfig.doSign && !isAppxOnly) { + // Supply Azure Artifact Signing config via CLI rather than electron-builder.yaml. Putting it + // in YAML would activate signing for every build, including PR CI and local dev where Azure + // auth isn't available — and some signing paths (notably the NSIS uninstaller) ignore the + // win.signAndEditExecutable flag, so per-target gating isn't reliable. AppX-only passes skip + // signing entirely: the Microsoft Store re-signs the outer .appx during certification, and + // we want the inner binaries unsigned (electron-builder would otherwise sign them via signExts). + // Preflight everything signing needs: the azureSignOptions config (AZURE_SIGNING_*) and the + // Azure.Identity credentials electron-builder reads from the environment. Checking the + // credentials here turns the opaque "Unable to find valid azure env configuration" failure — + // e.g. an expired, rotated, or mis-pasted client secret — into an immediate, named error. + const required = [ + 'AZURE_SIGNING_ENDPOINT', + 'AZURE_SIGNING_ACCOUNT_NAME', + 'AZURE_SIGNING_PROFILE_NAME', + 'AZURE_SIGNING_PUBLISHER_NAME', + 'AZURE_TENANT_ID', + 'AZURE_CLIENT_ID', + 'AZURE_CLIENT_SECRET' + ]; + const missing = required.filter(name => !childEnvironment[name]); + if (missing.length > 0) { + throw new Error(`Azure signing requires env vars: ${missing.join(', ')}`); + } + // Wrap values in double quotes so spaces (e.g. a multi-word publisherName) survive shell + // re-tokenization. spawnSync(shell: true) joins argv with spaces and runs through cmd.exe / + // /bin/sh, so any internal whitespace would otherwise become an arg separator. + allArgs.push( + `--c.win.azureSignOptions.endpoint="${childEnvironment.AZURE_SIGNING_ENDPOINT}"`, + `--c.win.azureSignOptions.codeSigningAccountName="${childEnvironment.AZURE_SIGNING_ACCOUNT_NAME}"`, + `--c.win.azureSignOptions.certificateProfileName="${childEnvironment.AZURE_SIGNING_PROFILE_NAME}"`, + `--c.win.azureSignOptions.publisherName="${childEnvironment.AZURE_SIGNING_PUBLISHER_NAME}"` + ); + } if (!wrapperConfig.doPackage) { allArgs.push('--dir', '--c.compression=store'); } From 126c997373f87e0ede2d4a5c57a35f214e8b5f04 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:28:59 -0700 Subject: [PATCH 8/9] build: inject org-specific Windows IDs via CLI instead of YAML macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder only expands ${env.X} in filename fields like artifactName, not in config fields, so appx.identityName / appx.publisher / appx.publisherDisplayName / msi.upgradeCode were being passed to WiX and the AppX manifest as literal "${env.*}" strings — failing GUID and identity validation. This was never caught because PR CI only builds nsis-x64 and earlier release-candidate runs failed at signing before reaching MSI/AppX compilation. Inject the four identifiers as electron-builder CLI overrides from the wrapper, the same mechanism already used for azureSignOptions, with values double-quoted so a CN= publisher string's spaces survive shell tokenization. Update the ci.yml and release-candidate.yml env comments, which still described the old ${env.X}-interpolation mechanism, to point at the wrapper. --- .github/workflows/ci.yml | 8 +++++--- .github/workflows/release-candidate.yml | 8 +++++--- electron-builder.yaml | 18 ++++++++++-------- scripts/electron-builder-wrapper.js | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3e6d90b..a985ab72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,11 @@ jobs: run: shell: bash env: - # Organization-specific identifiers used by electron-builder.yaml's ${env.X} - # interpolation. Kept in repo Variables so a fork builds with its own values - # (or fails loudly if unset) rather than colliding with our published artifacts. + # 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 }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 54f56da3..0bb338b2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -53,9 +53,11 @@ jobs: run: shell: bash env: - # Organization-specific identifiers used by electron-builder.yaml's ${env.X} - # interpolation. Kept in repo Variables so a fork builds with its own values - # (or fails loudly if unset) rather than colliding with our published artifacts. + # 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 }} diff --git a/electron-builder.yaml b/electron-builder.yaml index 403c65a2..575ab2a4 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -56,11 +56,12 @@ win: # signing paths (notably the NSIS uninstaller) ignore signAndEditExecutable, so # gating per-target isn't reliable. Keeping the config off entirely is cleaner. appx: - # Organization-specific identifiers come from environment variables so a fork can build - # without colliding with our published artifacts. See README for required env vars. - identityName: "${env.APPX_IDENTITY_NAME}" - publisherDisplayName: "${env.APPX_PUBLISHER_DISPLAY_NAME}" - publisher: "${env.APPX_PUBLISHER}" + # identityName, publisher, and publisherDisplayName are organization-specific, so a fork can + # build without colliding with our published artifacts. They're injected at build time by + # scripts/electron-builder-wrapper.js from the APPX_IDENTITY_NAME / APPX_PUBLISHER / + # APPX_PUBLISHER_DISPLAY_NAME environment variables. They can't live here directly: + # electron-builder only expands ${env.X} in filename fields like artifactName, not in + # config fields like these. artifactName: "Scratch ${version} ${arch}.${ext}" nsis: oneClick: false # allow user to choose per-user or per-machine @@ -70,9 +71,10 @@ msi: perMachine: true oneClick: false # upgradeCode is a stable GUID that ties future MSIs to past installations: future versions - # upgrade in place rather than installing alongside earlier ones. Sourced from a repository - # Variable so a fork generates and uses its own GUID rather than inheriting ours. - upgradeCode: "${env.MSI_UPGRADE_CODE}" + # upgrade in place rather than installing alongside earlier ones. It's injected at build time + # by scripts/electron-builder-wrapper.js from the MSI_UPGRADE_CODE environment variable, so a + # fork uses its own GUID rather than inheriting ours (see the appx note re: ${env.X} only + # working in filename fields, not config fields like this one). artifactName: "Scratch ${version} ${arch}.${ext}" linux: target: AppImage diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index a84edf42..e940dca0 100644 --- a/scripts/electron-builder-wrapper.js +++ b/scripts/electron-builder-wrapper.js @@ -78,6 +78,24 @@ const runBuilder = function (wrapperConfig, target) { // APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER are present in // the environment. } + if (target.platform === 'win32') { + // Organization-specific identifiers. electron-builder only expands ${env.X} macros in + // filename fields like artifactName, not in config fields like these, so we inject them as + // CLI overrides from the environment. Each is applied whenever its variable is set (a fork + // that doesn't set them builds with electron-builder's defaults). Values are double-quoted + // so spaces (e.g. a CN= publisher string) survive spawnSync(shell: true) re-tokenization. + const idOverrides = { + 'appx.identityName': childEnvironment.APPX_IDENTITY_NAME, + 'appx.publisher': childEnvironment.APPX_PUBLISHER, + 'appx.publisherDisplayName': childEnvironment.APPX_PUBLISHER_DISPLAY_NAME, + 'msi.upgradeCode': childEnvironment.MSI_UPGRADE_CODE + }; + for (const [key, value] of Object.entries(idOverrides)) { + if (value) { + allArgs.push(`--c.${key}="${value}"`); + } + } + } // Appx-only means every target name in the pass starts with 'appx'. A mixed pass that bundles // appx with nsis or msi keeps signing on for the non-appx parts; only the pure-appx case opts // out so the Store can re-sign its container with unsigned inner binaries (today's behavior). From 50669c3ed1e617757c7dc35cd8bc5d4d45fb0039 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:27:40 -0700 Subject: [PATCH 9/9] ci: verify artifact signatures before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/release-candidate.yml | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0bb338b2..4f0f2a23 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -171,6 +171,100 @@ jobs: 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.