From 9bd83c316290910b79fdb9871c43146c285c9abd Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 11:34:38 -0400 Subject: [PATCH] fix(desktop): correct the Windows and macOS release verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.1 tag failed on both platforms, each for its own reason. Windows passed `--config.win.publisherName`, which Electron Builder 26 removed in favour of `signtoolOptions.publisherName`. `WindowsConfiguration` sets `additionalProperties: false`, so the unknown key invalidated the whole `win` object and the build died during schema validation, before packaging. The Azure path already carries the publisher in `azureSignOptions`, so it just drops the duplicate; the certificate path moves to the new location. This path had never run in CI before — the Azure secrets were only set today — and the unit tests asserted the argument array rather than the schema, so they agreed with the bug. They now validate every emitted option against the installed Electron Builder schema, which fails on the exact argument that broke the release. macOS verified the wrong artifact: Electron Builder notarizes and staples the .app and then packs the stapled bundle into the disk image, so the image itself never carries a ticket and `stapler validate` on it always fails. Notarization had in fact succeeded — spctl reported "Notarized Developer ID" for the mounted bundle in the same run. Validate the staple on the app inside the image. --- .github/workflows/desktop-release.yml | 5 +- apps/desktop/scripts/package-win.ts | 12 ++--- apps/desktop/tests/package-win.spec.ts | 71 ++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 900cd0ea..23d0da60 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -246,7 +246,10 @@ jobs: exit 1 fi verify_app "$dmg_app" - xcrun stapler validate "$dmg_path" + # electron-builder notarizes and staples the .app, then packs the + # already-stapled bundle into the disk image; the image itself never + # receives a ticket. Validate the staple where it actually lives. + xcrun stapler validate "$dmg_app" cleanup_mount trap - EXIT diff --git a/apps/desktop/scripts/package-win.ts b/apps/desktop/scripts/package-win.ts index e8d661d1..8593c361 100644 --- a/apps/desktop/scripts/package-win.ts +++ b/apps/desktop/scripts/package-win.ts @@ -73,12 +73,12 @@ export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] { ) } - const publisherName = hasAzureValue - ? trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])! - : trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])! - if (!hasAzureValue) args.length = 0 - args.push('--config.win.publisherName', publisherName) - return args + // Electron Builder 26 removed `win.publisherName`, and `win` rejects unknown + // keys, so passing it fails schema validation before any build work. The + // Azure path already carries the publisher in `azureSignOptions`; the + // certificate path now sets it where signtool reads it. + if (hasAzureValue) return args + return ['--config.win.signtoolOptions.publisherName', trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!] } /** Require one complete signing method for a tagged Windows release. */ diff --git a/apps/desktop/tests/package-win.spec.ts b/apps/desktop/tests/package-win.spec.ts index a8ef69ad..207bd08a 100644 --- a/apps/desktop/tests/package-win.spec.ts +++ b/apps/desktop/tests/package-win.spec.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module' import { describe, expect, it } from 'vitest' import { requireWindowsReleaseSigning, @@ -5,6 +6,51 @@ import { windowsSigningArgs, } from '../scripts/package-win' +interface SchemaNode { + readonly $ref?: string + readonly anyOf?: readonly SchemaNode[] + readonly properties?: Readonly> +} + +const requireFromTests = createRequire(import.meta.url) +const schema = requireFromTests( + requireFromTests.resolve('app-builder-lib/scheme.json', { + paths: [requireFromTests.resolve('electron-builder')], + }), +) as { readonly definitions: Readonly> } + +function referencedDefinition(node: SchemaNode): SchemaNode | undefined { + const reference = node.$ref ?? node.anyOf?.find(branch => branch.$ref !== undefined)?.$ref + return reference === undefined ? undefined : schema.definitions[reference.replace('#/definitions/', '')] +} + +/** + * Assert that a `--config.` option exists in the installed Electron Builder schema. + * + * `WindowsConfiguration` sets `additionalProperties: false`, so an option that + * the schema does not declare fails validation before any build work and takes + * the whole `win` object down with it. Comparing against the real schema keeps + * these arguments honest across Electron Builder upgrades, which is what an + * expected-array assertion cannot do. + * @param path - Dotted option path with the `--config.` prefix removed. + */ +function assertSchemaOption(path: string): void { + const [root, ...rest] = path.split('.') + expect(root).toBe('win') + let definition = schema.definitions['WindowsConfiguration']! + rest.forEach((segment, index) => { + const property = definition.properties?.[segment] + if (property === undefined) { + throw new Error(`Electron Builder has no option '${rest.slice(0, index + 1).join('.')}' under win`) + } + const next = referencedDefinition(property) + if (next !== undefined) definition = next + else if (index !== rest.length - 1) { + throw new Error(`Electron Builder option 'win.${rest.slice(0, index + 1).join('.')}' has no nested options`) + } + }) +} + const signingEnvironment: NodeJS.ProcessEnv = { AZURE_TENANT_ID: 'tenant-id', AZURE_CLIENT_ID: 'client-id', @@ -26,7 +72,6 @@ describe('Windows Azure signing configuration', () => { '--config.win.azureSignOptions.codeSigningAccountName', 'signing-account', '--config.win.azureSignOptions.certificateProfileName', 'certificate-profile', '--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US', - '--config.win.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US', ]) }) @@ -56,6 +101,27 @@ describe('Windows Azure signing configuration', () => { }) }) +describe('Electron Builder option names', () => { + const certificateEnvironment: NodeJS.ProcessEnv = { + WIN_CSC_LINK: 'certificate.p12', + WIN_CSC_KEY_PASSWORD: 'password', + WINDOWS_SIGNING_PUBLISHER_NAME: 'CN=Example Publisher, O=Example Publisher', + } + + it('emits only options the installed Electron Builder schema declares', () => { + for (const environment of [signingEnvironment, certificateEnvironment]) { + const options = windowsSigningArgs(environment).filter(argument => argument.startsWith('--config.')) + expect(options.length).toBeGreaterThan(0) + for (const option of options) assertSchemaOption(option.slice('--config.'.length)) + } + }) + + it('rejects an option the schema does not declare', () => { + expect(() => { assertSchemaOption('win.publisherName') }) + .toThrow("Electron Builder has no option 'publisherName' under win") + }) +}) + describe('Windows tagged-release signing', () => { it('rejects an unsigned tagged release', () => { expect(() => requireWindowsReleaseSigning({})).toThrow('Windows release signing is not configured') @@ -70,7 +136,7 @@ describe('Windows tagged-release signing', () => { expect(requireWindowsReleaseSigning(environment)).toBe('CN=Example Publisher, O=Example Publisher') expect(windowsSigningArgs(environment)).toEqual([ - '--config.win.publisherName', 'CN=Example Publisher, O=Example Publisher', + '--config.win.signtoolOptions.publisherName', 'CN=Example Publisher, O=Example Publisher', ]) }) @@ -111,7 +177,6 @@ describe('Windows package invocation', () => { '--config.win.azureSignOptions.codeSigningAccountName', 'signing-account', '--config.win.azureSignOptions.certificateProfileName', 'certificate-profile', '--config.win.azureSignOptions.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US', - '--config.win.publisherName', 'CN=Example Publisher, O=Example Publisher, L=Redmond, S=WA, C=US', ]) })