diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ccf3d49..a985ab72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,16 @@ jobs: 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 }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -76,23 +86,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/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index b12da7f8..4f0f2a23 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: @@ -32,42 +33,35 @@ 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 - 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: nsis - artifact-name: Windows-nsis - artifact-path: dist/Scratch*.exe + target: installers runs-on: ${{ matrix.os }} defaults: 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. 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) @@ -155,14 +149,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: | @@ -170,8 +171,170 @@ 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 + # 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: ${{ matrix.artifact-name }} - path: ${{ matrix.artifact-path }} + name: Windows-MSI-x64 + path: "dist/Scratch *x64.msi" + compression-level: 0 diff --git a/README.md b/README.md index 6d694154..c399ec5a 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 @@ -56,27 +60,19 @@ other changes which might affect the media libraries. `npm run dist` -Node that on macOS this will require installing various certificates. - -#### Signing the NSIS installer (Windows, non-store) - -*This section is relevant only to members of the Scratch Team.* +Note that on macOS this will require installing various certificates. -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. +#### Code signing (Windows) -To generate a signed NSIS installer: +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. -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/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. diff --git a/electron-builder.yaml b/electron-builder.yaml index 21dd187f..575ab2a4 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 @@ -46,14 +46,36 @@ 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: - identityName: ScratchFoundation.ScratchDesktop - publisherDisplayName: "Scratch Foundation" - publisher: "CN=2EC43DF1-469A-4119-9AB9-568A0A1FF65F" + # 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 - 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. 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 executableName: scratch-desktop diff --git a/scripts/electron-builder-wrapper.js b/scripts/electron-builder-wrapper.js index e5cbcf16..e940dca0 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,62 @@ 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). + 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'); } @@ -117,11 +176,28 @@ 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' + }, + 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: { @@ -141,7 +217,10 @@ 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, + 'nsis-x64': availableTargets.windowsNsisX64 }; const selected = targetsByShortName[wrapperConfig.target]; if (!selected) { @@ -155,9 +234,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.