Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 53 additions & 1 deletion scripts/build-with-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/scripts/windowsSigningOptOut.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading