From 52b33fe5a3c5dcad3813ef66e7e0d8ed17d63a4f Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 4 Sep 2026 17:07:26 +0200 Subject: [PATCH] fix(release): pass the keychain password to set-key-partition-list on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Build on macos arm64` started failing on master with security set-key-partition-list -S apple-tool:,apple: -s -k *** .keychain SecKeychainUnlock: The user name or passphrase you entered is not correct. Every failure ran the `macos-26-arm64` runner image 20260831.0337; the same job on image 20260728.0273 kept passing, as did `macos-15-intel`. The image exposed an electron-builder bug: `app-builder-lib` hands the certificate's `.p12` import password to `set-key-partition-list -k`, which authenticates against the temporary keychain and therefore needs the keychain's own generated password. Older macOS builds accepted the wrong password once the keychain was unlocked; the new one verifies it. Upstream fixed this in electron-userland/electron-builder#10101 (master, v27 alpha) and backported it in #10172 to release/v26 on 2026-09-03, but no 26.x containing it is published (26.16.0 predates the backport, #10167). Apply the backport to the installed 26.15.7 as a pnpm patch, guarded by `tools/dependencies/app-builder-lib-keychain-password.test.mjs`: it checks that the patched version is the installed one, that the compiled source passes `keychainPassword`, and — with `security` recorded — that `createKeychain` unlocks the partition list with the password it gave `create-keychain`, not the import password. The test fails 2/3 on the unpatched code. CI runs it next to the Vite patch guard; CLAUDE.md and AGENTS.md document when to retire the patch. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 + AGENTS.md | 11 ++ CLAUDE.md | 11 ++ package.json | 4 +- patches/app-builder-lib@26.15.7.patch | 26 +++ pnpm-lock.yaml | 11 +- ...app-builder-lib-keychain-password.test.mjs | 179 ++++++++++++++++++ 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 patches/app-builder-lib@26.15.7.patch create mode 100644 tools/dependencies/app-builder-lib-keychain-password.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eeb77cf5..5b0434924 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,9 @@ jobs: - name: Validate Vite dev-server transform filter patch run: pnpm run deps:vite:test + - name: Validate electron-builder keychain password patch + run: pnpm run deps:electron-builder:test + - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate diff --git a/AGENTS.md b/AGENTS.md index f3ac937ef..ba683bc87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,17 @@ This file provides guidance to coding agents working in this repository. `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling resolves a Vite version containing the fix, and run `pnpm run deps:vite:test` after related dependency updates. +- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in + `patches/app-builder-lib@26.15.7.patch` with the upstream backport + electron-userland/electron-builder#10172: `security set-key-partition-list -k` + must receive the temporary keychain's own password, not the `.p12` import + password. macOS runner images since `macos-26-arm64` 20260831 verify that + password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user + name or passphrase you entered is not correct`. Keep the patch until + electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+), + and run `pnpm run deps:electron-builder:test` after related dependency + updates — the test fails when the patched version no longer matches the + installed one. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task diff --git a/CLAUDE.md b/CLAUDE.md index 786eecb75..136d2c649 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,17 @@ pnpm nx show projects `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling resolves a Vite version containing the fix, and run `pnpm run deps:vite:test` after related dependency updates. +- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in + `patches/app-builder-lib@26.15.7.patch` with the upstream backport + electron-userland/electron-builder#10172: `security set-key-partition-list -k` + must receive the temporary keychain's own password, not the `.p12` import + password. macOS runner images since `macos-26-arm64` 20260831 verify that + password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user + name or passphrase you entered is not correct`. Keep the patch until + electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+), + and run `pnpm run deps:electron-builder:test` after related dependency + updates — the test fails when the patched version no longer matches the + installed one. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task diff --git a/package.json b/package.json index 69a60cf2d..85a5e68ad 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "deps:nx:check": "node tools/dependencies/check-nx-version-sync.mjs", "deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check", "deps:vite:test": "node --test tools/dependencies/vite-transform-filter.test.mjs", + "deps:electron-builder:test": "node --test tools/dependencies/app-builder-lib-keychain-password.test.mjs", "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", @@ -277,7 +278,8 @@ }, "patchedDependencies": { "nx-electron@22.0.0": "patches/nx-electron@22.0.0.patch", - "vite@7.3.6": "patches/vite@7.3.6.patch" + "vite@7.3.6": "patches/vite@7.3.6.patch", + "app-builder-lib@26.15.7": "patches/app-builder-lib@26.15.7.patch" } } } diff --git a/patches/app-builder-lib@26.15.7.patch b/patches/app-builder-lib@26.15.7.patch new file mode 100644 index 000000000..d4603e0d8 --- /dev/null +++ b/patches/app-builder-lib@26.15.7.patch @@ -0,0 +1,26 @@ +diff --git a/out/codeSign/macCodeSign.js b/out/codeSign/macCodeSign.js +index 9a69042fd48f4759a1c697bf23fa5b44f2da2366..193f42a8c4cb95ded694ba8ec0c27a5dcc94ed4c 100644 +--- a/out/codeSign/macCodeSign.js ++++ b/out/codeSign/macCodeSign.js +@@ -156,16 +156,18 @@ async function createKeychain({ tmpDir, cscLink, cscKeyPassword, cscILink, cscIK + if (cscIKeyPassword != null) { + cscPasswords.push(cscIKeyPassword); + } +- return await importCerts(keychainFile, certPaths, cscPasswords); ++ return await importCerts(keychainFile, certPaths, cscPasswords, keychainPassword); + } +-async function importCerts(keychainFile, paths, keyPasswords) { ++async function importCerts(keychainFile, paths, keyPasswords, keychainPassword) { + var _a; + for (let i = 0; i < paths.length; i++) { + const password = (_a = keyPasswords[i]) !== null && _a !== void 0 ? _a : ""; + await (0, builder_util_1.exec)("/usr/bin/security", ["import", paths[i], "-k", keychainFile, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", password]); + // https://stackoverflow.com/questions/39868578/security-codesign-in-sierra-keychain-ignores-access-control-settings-and-ui-p + // https://github.com/electron-userland/electron-packager/issues/701#issuecomment-322315996 +- await (0, builder_util_1.exec)("/usr/bin/security", ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]); ++ // `-k` expects the keychain's own unlock password (as used by create-keychain/unlock-keychain above), ++ // not the imported item's password used by `security import -P`. ++ await (0, builder_util_1.exec)("/usr/bin/security", ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]); + } + return { + keychainFile, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b786e377c..661170a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ overrides: yaml@1.10.2: 1.10.3 patchedDependencies: + app-builder-lib@26.15.7: + hash: 6114f0c543079e090166568e1ff0c28e406abb22bded6030663b7a3470dce83b + path: patches/app-builder-lib@26.15.7.patch nx-electron@22.0.0: hash: f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002 path: patches/nx-electron@22.0.0.patch @@ -16709,7 +16712,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - app-builder-lib@26.15.7(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7): + app-builder-lib@26.15.7(patch_hash=6114f0c543079e090166568e1ff0c28e406abb22bded6030663b7a3470dce83b)(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -17859,7 +17862,7 @@ snapshots: dmg-builder@26.15.7(electron-builder-squirrel-windows@26.15.7): dependencies: - app-builder-lib: 26.15.7(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) + app-builder-lib: 26.15.7(patch_hash=6114f0c543079e090166568e1ff0c28e406abb22bded6030663b7a3470dce83b)(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) builder-util: 26.15.3 fs-extra: 10.1.0 js-yaml: 4.3.1 @@ -17943,7 +17946,7 @@ snapshots: electron-builder-squirrel-windows@26.15.7(dmg-builder@26.15.7): dependencies: - app-builder-lib: 26.15.7(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) + app-builder-lib: 26.15.7(patch_hash=6114f0c543079e090166568e1ff0c28e406abb22bded6030663b7a3470dce83b)(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) builder-util: 26.15.3 electron-winstaller: 5.4.0 transitivePeerDependencies: @@ -17952,7 +17955,7 @@ snapshots: electron-builder@26.15.7(electron-builder-squirrel-windows@26.15.7): dependencies: - app-builder-lib: 26.15.7(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) + app-builder-lib: 26.15.7(patch_hash=6114f0c543079e090166568e1ff0c28e406abb22bded6030663b7a3470dce83b)(dmg-builder@26.15.7)(electron-builder-squirrel-windows@26.15.7) builder-util: 26.15.3 builder-util-runtime: 9.7.0 chalk: 4.1.2 diff --git a/tools/dependencies/app-builder-lib-keychain-password.test.mjs b/tools/dependencies/app-builder-lib-keychain-password.test.mjs new file mode 100644 index 000000000..616798e6d --- /dev/null +++ b/tools/dependencies/app-builder-lib-keychain-password.test.mjs @@ -0,0 +1,179 @@ +/** + * Guards `patches/app-builder-lib@.patch`. + * + * electron-builder 26.15.x hands the certificate's `.p12` import password to + * `security set-key-partition-list -k`, which authenticates against the + * temporary keychain — so it needs the keychain's own generated password + * (upstream #10066, fixed on master in #10101 and backported to release/v26 in + * #10172, not yet in a published 26.x). macOS runner images since + * `macos-26-arm64` 20260831 verify that password, and `Build on macos arm64` + * failed with `SecKeychainUnlock: The user name or passphrase you entered is + * not correct`. The patch applies the backport to the compiled package. + * + * Two checks: the installed source carries the fix (a dependency bump that + * drops the patch must not silently reintroduce the bug), and the behavior + * holds when `createKeychain` runs against a recorded `security` — the + * partition-list call must use the password `create-keychain` was given, not + * the import password. + * + * Retire this test together with the patch once electron-builder resolves an + * `app-builder-lib` that contains #10172. + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +const require = createRequire(import.meta.url); + +// app-builder-lib is a transitive dependency of electron-builder; pnpm's +// strict layout keeps it out of the root node_modules, so resolve it the way +// electron-builder itself does. +const electronBuilderDir = path.dirname( + require.resolve('electron-builder/package.json') +); +const appBuilderLibPackage = require.resolve('app-builder-lib/package.json', { + paths: [electronBuilderDir], +}); +const appBuilderLibDir = path.dirname(appBuilderLibPackage); +const macCodeSignPath = path.join( + appBuilderLibDir, + 'out/codeSign/macCodeSign.js' +); + +const IMPORT_PASSWORD = 'certificate-import-password'; + +describe('app-builder-lib keychain password patch', () => { + it('targets the version the patch was written for', () => { + const { version } = JSON.parse( + readFileSync(appBuilderLibPackage, 'utf8') + ); + const rootPackage = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8') + ); + const patched = Object.keys( + rootPackage.pnpm?.patchedDependencies ?? {} + ); + + assert.ok( + patched.includes(`app-builder-lib@${version}`), + `installed app-builder-lib ${version} has no entry in pnpm.patchedDependencies (${patched.join(', ')}) — bump or retire the patch` + ); + }); + + it('passes the keychain password, not the import password, to set-key-partition-list', () => { + const source = readFileSync(macCodeSignPath, 'utf8'); + + assert.match( + source, + /"set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile/ + ); + assert.match( + source, + /importCerts\(keychainFile, certPaths, cscPasswords, keychainPassword\)/ + ); + assert.doesNotMatch( + source, + /"set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile/ + ); + }); + + describe('createKeychain against a recorded security binary', () => { + const calls = []; + let cacheDir; + let tmpRoot; + let builderUtil; + let originalExec; + let macCodeSign; + + before(() => { + cacheDir = mkdtempSync( + path.join(tmpdir(), 'app-builder-lib-cache-') + ); + tmpRoot = mkdtempSync(path.join(tmpdir(), 'app-builder-lib-tmp-')); + // Keep createKeychain's only real filesystem side effects (the bundled + // root-certs keychain copy and the temp keychain path) out of the + // user's cache and temp directories. + process.env.ELECTRON_BUILDER_CACHE = cacheDir; + process.env.APP_BUILDER_TMP_DIR = tmpRoot; + + // The compiled code reads `exec` off builder-util's util module at + // call time (`(0, builder_util_1.exec)(...)`), so replacing the + // export records every `/usr/bin/security` invocation without + // touching a real keychain. + builderUtil = require( + require.resolve('builder-util/out/util', { + paths: [appBuilderLibDir], + }) + ); + originalExec = builderUtil.exec; + builderUtil.exec = async (file, args) => { + calls.push({ file, args: [...(args ?? [])] }); + return ''; + }; + macCodeSign = require(macCodeSignPath); + }); + + after(() => { + builderUtil.exec = originalExec; + delete process.env.ELECTRON_BUILDER_CACHE; + delete process.env.APP_BUILDER_TMP_DIR; + rmSync(cacheDir, { recursive: true, force: true }); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('unlocks the partition list with the generated keychain password', async () => { + const { TmpDir } = require( + require.resolve('builder-util', { paths: [appBuilderLibDir] }) + ); + const tmpDir = new TmpDir('keychain-password-test'); + + try { + await macCodeSign.createKeychain({ + tmpDir, + // Base64 links are written to a temp file without inspection; + // only the path reaches the recorded `security import`. + cscLink: Buffer.from('not a real p12').toString('base64'), + cscKeyPassword: IMPORT_PASSWORD, + currentDir: tmpRoot, + }); + } finally { + await tmpDir.cleanup(); + } + + const security = calls.filter( + (call) => call.file === '/usr/bin/security' + ); + const argAfter = (args, flag) => args[args.indexOf(flag) + 1]; + const created = security.find( + (call) => call.args[0] === 'create-keychain' + ); + const imported = security.find((call) => call.args[0] === 'import'); + const partition = security.find( + (call) => call.args[0] === 'set-key-partition-list' + ); + + assert.ok(created, 'create-keychain was not invoked'); + assert.ok(imported, 'security import was not invoked'); + assert.ok(partition, 'set-key-partition-list was not invoked'); + + const keychainPassword = argAfter(created.args, '-p'); + assert.ok(keychainPassword, 'create-keychain carried no password'); + assert.notEqual(keychainPassword, IMPORT_PASSWORD); + assert.equal(argAfter(imported.args, '-P'), IMPORT_PASSWORD); + assert.equal( + argAfter(partition.args, '-k'), + keychainPassword, + 'set-key-partition-list must authenticate with the keychain password' + ); + assert.equal( + partition.args.at(-1), + created.args.at(-1), + 'same keychain file' + ); + }); + }); +});