fix(release): pass the keychain password to set-key-partition-list on macOS

`Build on macos arm64` started failing on master with

    security set-key-partition-list -S apple-tool:,apple: -s -k *** <tmp>.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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-04 17:07:27 +02:00
1 parent 50b980af7a
commit 52b33fe5a3
7 files changed
+240 -5

No files matched your search

+3
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+3 -1
View File
@@ -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"
}
}
}
+26
View File
@@ -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,
+7 -4
View File
@@ -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
@@ -0,0 +1,179 @@
/**
* Guards `patches/app-builder-lib@<version>.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'
);
});
});
});