From 532589663865ac8f281a7936e83f89e41d6ab75a Mon Sep 17 00:00:00 2001 From: Sean Donahoe Date: Tue, 18 Aug 2026 17:11:04 +0700 Subject: [PATCH] ci: let the verification gate skip Windows signing build-matrix.yml has never once completed a Windows build, and the reason was never time. It holds no Azure credentials by design, electron-builder declares win.azureSignOptions unconditionally, and the Azure EnvironmentCredential falls through to an INTERACTIVE flow when the three secrets are absent. Every Windows target therefore sat silent for 107 minutes at 'signing with Azure Trusted Signing' - AFTER its output was already written - until the job timeout killed it and reported CANCELLED, which reds out identically to a real failure. Raising the timeout 60 -> 120 did not help, because nothing was progressing. A verification build does not need a signature. This is the Windows counterpart of the CSC_IDENTITY_AUTO_DISCOVERY opt-out the same step already sets for macOS. The release path is untouched: it supplies real credentials and already fails closed when AZURE_CLIENT_SECRET is empty on a tag. The hazard is the opt-out LEAKING onto that path and quietly shipping an unsigned installer, so it is guarded twice. At runtime, resolveWindowsSigningOptOut throws on refs/tags/* and on refs/heads/dev - the two refs build-and-release.yml fires on. At rest, a test pins the flag present in the gate and absent from all three release workflows. The decision is a pure exported function so this is a behaviour test rather than a source-string match; the test loads the CommonJS module through createRequire, since its top-level return defeats vitest's ESM transform. Both guards are mutation-verified: leaking the flag into _build-reusable.yml fails the test, and deleting the tag/dev refusal fails it too. Full suite: 18,757 passed, 0 failed. --- .github/workflows/build-matrix.yml | 11 +++ scripts/build-with-builder.js | 54 ++++++++++++- .../unit/scripts/windowsSigningOptOut.test.ts | 80 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/unit/scripts/windowsSigningOptOut.test.ts diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index 96ae8c00c8..308dfe82cf 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -439,6 +439,17 @@ jobs: # skip signing when CSC_LINK is unset; this also disables Keychain # autodiscovery on hosted runners. CSC_IDENTITY_AUTO_DISCOVERY: 'false' + # The Windows counterpart. This workflow deliberately holds no Azure + # credentials, and electron-builder's Azure Trusted Signing falls + # through to an INTERACTIVE flow when they are absent - which hung + # every Windows target here for 107 minutes at "signing with Azure + # Trusted Signing", AFTER the build output was already written, until + # the job timeout killed it and reported CANCELLED. That is why this + # gate had never once completed a Windows build. Verification builds do + # not need signatures; the release path keeps its credentials and its + # refuse-to-ship-unsigned guard, and build-with-builder.js refuses this + # flag outright on a tag or on dev. + WAYLAND_SKIP_WINDOWS_SIGNING: '1' shell: bash run: | rm -rf "$PACKAGE_OUT_DIR" diff --git a/scripts/build-with-builder.js b/scripts/build-with-builder.js index d9ac47874a..4fb1404894 100644 --- a/scripts/build-with-builder.js +++ b/scripts/build-with-builder.js @@ -551,9 +551,40 @@ function cleanupWindowsPackOutput() { } } +/** + * Decide whether this build may skip Windows Authenticode signing. + * + * `electron-builder.yml` declares `win.azureSignOptions` unconditionally and + * authenticates through the Azure EnvironmentCredential. With no + * AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET the chain falls + * through to an INTERACTIVE flow and the build stops dead - 107 minutes of + * silence at "signing with Azure Trusted Signing" on a runner that had already + * written its output, killed by the job timeout and reported as CANCELLED. That + * is why `build-matrix.yml` had never completed a single Windows build. + * + * Verification builds do not need signatures, so they may opt out. A RELEASE + * build may not, ever: this throws on a tag and on `dev`, the two refs + * `build-and-release.yml` fires on, so the flag cannot silently unsign a shipped + * artifact even if it leaks into the release environment. + * + * @returns {boolean} true when the caller should disable Windows signing. + */ +function resolveWindowsSigningOptOut(env = process.env) { + if (env.WAYLAND_SKIP_WINDOWS_SIGNING !== '1') return false; + const ref = env.GITHUB_REF || ''; + if (ref.startsWith('refs/tags/') || ref === 'refs/heads/dev') { + throw new Error( + `WAYLAND_SKIP_WINDOWS_SIGNING=1 is set on a RELEASE build (${ref}). ` + + 'Refusing to produce an unsigned Windows artifact.' + ); + } + return true; +} + if (require.main !== module) { module.exports = { buildWithDmgRetry, + resolveWindowsSigningOptOut, cleanGeneratedResourceRoots, hasFreshTargetDmg, prepareOptionalHubResources, @@ -1093,7 +1124,28 @@ try { cleanupWindowsPackOutput(); } - const builderCommand = `bunx electron-builder ${BUILDER_CONFIG_ARG} ${builderArgs} ${archFlag} ${nsisInclude} ${publishArg}`; + // Windows signing opt-out for BUILD-VERIFICATION runs only. + // + // `electron-builder.yml` declares `win.azureSignOptions` unconditionally, and + // electron-builder authenticates through the Azure EnvironmentCredential. When + // AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET are absent the + // credential chain falls through to an INTERACTIVE flow and the build stops + // dead - observed as 107 minutes of silence at "signing with Azure Trusted + // Signing" on a runner that had already produced its output, killed by the job + // timeout and reported as CANCELLED. That is why `build-matrix.yml` had never + // completed a Windows build. + // + // This is the Windows counterpart of the macOS `CSC_IDENTITY_AUTO_DISCOVERY` + // opt-out those workflows already set. It is refused on a release build, so it + // cannot silently unsign a shipped artifact: the release path supplies real + // credentials and fails closed if the secret is empty on a tag. + const skipWindowsSigning = resolveWindowsSigningOptOut(); + if (skipWindowsSigning) { + console.log('⚠️ WAYLAND_SKIP_WINDOWS_SIGNING=1 - BUILD VERIFICATION ONLY, this artifact must never ship.'); + } + const winSignArg = skipWindowsSigning && isWindowsBuild ? ' --config.win.azureSignOptions=null' : ''; + + const builderCommand = `bunx electron-builder ${BUILDER_CONFIG_ARG} ${builderArgs} ${archFlag} ${nsisInclude} ${publishArg}${winSignArg}`; const previousPackages = snapshotPackagedTargets(BUILDER_OUTPUT_DIR); const previousDmgs = snapshotDmgArtifacts(BUILDER_OUTPUT_DIR); try { diff --git a/tests/unit/scripts/windowsSigningOptOut.test.ts b/tests/unit/scripts/windowsSigningOptOut.test.ts new file mode 100644 index 0000000000..24484dda79 --- /dev/null +++ b/tests/unit/scripts/windowsSigningOptOut.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Ferrox Labs + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `build-matrix.yml` had never once completed a Windows build. Not slowness: + * it holds no Azure credentials, and electron-builder's Azure Trusted Signing + * falls through to an INTERACTIVE credential flow when they are absent, so every + * Windows target sat silent for 107 minutes at "signing with Azure Trusted + * Signing" - AFTER writing its output - until the job timeout killed it and + * reported CANCELLED, which reds out identically to a real failure. + * + * Verification builds may therefore opt out of Windows signing. The danger is + * the opt-out leaking onto the release path and quietly shipping an unsigned + * installer, so it is guarded twice: refused at runtime on the two refs + * `build-and-release.yml` fires on, and pinned here as absent from every release + * workflow. + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// `build-with-builder.js` is CommonJS and uses a top-level `return` inside its +// `require.main !== module` guard, which vitest's ESM transform cannot parse - +// which is why every other test in the repo reads it as text. createRequire +// loads it the way node does, so this can assert BEHAVIOUR instead of matching +// source strings. +const requireCjs = createRequire(import.meta.url); +const { resolveWindowsSigningOptOut } = requireCjs( + path.resolve(__dirname, '../../../scripts/build-with-builder.js') +) as { resolveWindowsSigningOptOut: (env?: NodeJS.ProcessEnv) => boolean }; + +const WORKFLOWS = path.resolve(__dirname, '../../../.github/workflows'); +const FLAG = 'WAYLAND_SKIP_WINDOWS_SIGNING'; +const read = (file: string) => readFileSync(path.join(WORKFLOWS, file), 'utf-8'); + +describe('windows signing opt-out is a verification-only escape hatch', () => { + it('is off unless explicitly set to 1', () => { + expect(resolveWindowsSigningOptOut({})).toBe(false); + expect(resolveWindowsSigningOptOut({ [FLAG]: '0' })).toBe(false); + expect(resolveWindowsSigningOptOut({ [FLAG]: 'true' })).toBe(false); + // Known positive, or every assertion below would pass vacuously. + expect(resolveWindowsSigningOptOut({ [FLAG]: '1' })).toBe(true); + }); + + it('REFUSES a tag build, which is what publishes', () => { + expect(() => resolveWindowsSigningOptOut({ [FLAG]: '1', GITHUB_REF: 'refs/tags/v0.12.1' })).toThrow( + /Refusing to produce an unsigned Windows artifact/ + ); + }); + + it('REFUSES a dev build, the other ref build-and-release fires on', () => { + expect(() => resolveWindowsSigningOptOut({ [FLAG]: '1', GITHUB_REF: 'refs/heads/dev' })).toThrow( + /Refusing to produce an unsigned Windows artifact/ + ); + }); + + it('allows an ordinary branch or dispatch build', () => { + expect(resolveWindowsSigningOptOut({ [FLAG]: '1', GITHUB_REF: 'refs/heads/main' })).toBe(true); + expect(resolveWindowsSigningOptOut({ [FLAG]: '1', GITHUB_REF: '' })).toBe(true); + }); + + it('is set ONLY in the verification gate, never in a release workflow', () => { + // The runtime refusal above is the second line of defence. This is the first: + // the flag must not reach a release workflow's environment at all. + expect(read('build-matrix.yml')).toContain(FLAG); + for (const file of ['_build-reusable.yml', 'build-and-release.yml', 'publish-npm.yml']) { + expect(read(file)).not.toContain(FLAG); + } + }); + + it('leaves the release path asserting that Windows signing IS configured', () => { + // Guards the other direction: the release must still refuse to ship unsigned. + expect(read('_build-reusable.yml')).toContain('AZURE_CLIENT_SECRET'); + expect(read('_build-reusable.yml')).toContain('Windows signing not configured'); + }); +});