From e3d47bb82f62684c01e2ccb362a5ad35d6d93eda Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 21:34:09 -0700 Subject: [PATCH] fix(release): select notarization credential keychains --- CHANGELOG.md | 1 + apps/desktop/scripts/after-sign.mjs | 8 +++- apps/desktop/scripts/macos-signing.test.mjs | 43 ++++++++++++++++++++- docs/releasing.md | 5 ++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5602a7d..329e74574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Personal sidebar channel order now follows your account across browsers and devices, with the existing local cache retained for offline use. Thanks @isaiahknight-va. - Signed and notarized macOS server archives with the Foundation Developer ID, alongside the desktop installers, and verify their notarization tickets on a clean runner before publication. +- Allow macOS release automation to select its credential keychain explicitly on headless hosts. - Updated SQLite, virtual scrolling, Wrangler, and Oxc tooling while preserving runtime minimums and the 48-hour dependency release-age gate. - Updated DOMPurify, Vite, Electron 43, desktop packaging, Wrangler, Node.js types, and pnpm while preserving runtime minimums and the 48-hour dependency release-age gate; Docker builds now include every workspace manifest for frozen-lockfile validation. - Corrected the source-install pnpm version and quickstart caller-authentication count to match the current toolchain and authentication guide. Thanks @KrasimirKralev. diff --git a/apps/desktop/scripts/after-sign.mjs b/apps/desktop/scripts/after-sign.mjs index d300db07a..6a7b6ef9f 100644 --- a/apps/desktop/scripts/after-sign.mjs +++ b/apps/desktop/scripts/after-sign.mjs @@ -63,6 +63,7 @@ async function verifyApp(script, appPath, requireNotarized = false) { export async function notarizeArchive(submission, options = {}) { const profile = options.profile || process.env.NOTARYTOOL_KEYCHAIN_PROFILE; + const keychainPath = options.keychainPath || process.env.NOTARYTOOL_KEYCHAIN_PATH; if (!profile) throw new Error("official macOS packaging requires NOTARYTOOL_KEYCHAIN_PROFILE"); const delay = options.delay || @@ -74,6 +75,7 @@ export async function notarizeArchive(submission, options = {}) { submission, "--keychain-profile", profile, + ...(keychainPath ? ["--keychain", keychainPath] : []), "--no-s3-acceleration", "--wait", "--output-format", @@ -102,7 +104,11 @@ export async function notarizeApp(appPath, options = {}) { await verifyApp(script, appPath); await run("ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", appPath, submission]); - await notarizeArchive(submission, { profile, delay: options.delay }); + await notarizeArchive(submission, { + profile, + delay: options.delay, + keychainPath: options.keychainPath, + }); await run("xcrun", ["stapler", "staple", appPath]); await verifyApp(script, appPath, true); diff --git a/apps/desktop/scripts/macos-signing.test.mjs b/apps/desktop/scripts/macos-signing.test.mjs index 7dad1cd37..4113bf23a 100644 --- a/apps/desktop/scripts/macos-signing.test.mjs +++ b/apps/desktop/scripts/macos-signing.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -63,6 +65,45 @@ test("notarization accepts only Apple's accepted response shape", () => { ); }); +test( + "notarization forwards a credential-keychain path as one argument", + { skip: process.platform === "win32" }, + async () => { + const scratch = await mkdtemp(path.join(os.tmpdir(), "clickclack-notary-test-")); + try { + const argsFile = path.join(scratch, "args.txt"); + await writeFile( + path.join(scratch, "xcrun"), + `#!/bin/sh\nprintf '%s\\n' "$@" > "$NOTARY_TEST_ARGS"\nprintf '%s\\n' '${accepted}'\n`, + { mode: 0o755 }, + ); + const keychainPath = path.join(scratch, "release credentials.keychain-db"); + const moduleURL = new URL("./after-sign.mjs", import.meta.url).href; + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import { notarizeArchive } from ${JSON.stringify(moduleURL)}; await notarizeArchive("synthetic.zip");`, + ], + { + env: { + PATH: scratch, + NOTARY_TEST_ARGS: argsFile, + NOTARYTOOL_KEYCHAIN_PROFILE: "synthetic-profile", + NOTARYTOOL_KEYCHAIN_PATH: keychainPath, + }, + }, + ); + const args = (await readFile(argsFile, "utf8")).trim().split("\n"); + assert.equal(args[args.indexOf("--keychain") + 1], keychainPath); + assert.equal(args[args.indexOf("--keychain-profile") + 1], "synthetic-profile"); + } finally { + await rm(scratch, { recursive: true, force: true }); + } + }, +); + test("macOS release policy is fail-closed and Foundation-scoped", async () => { const [config, packageScript, verifier, workflow] = await Promise.all([ readFile(path.join(root, "electron-builder.yml"), "utf8"), diff --git a/docs/releasing.md b/docs/releasing.md index c6e41c30d..1a653fccc 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -70,8 +70,9 @@ is published only after all of those jobs pass. ## Build the macOS release candidates Create and verify the signed tag, then check it out in a clean repository. The -notary profile must already be stored in the login keychain; its credentials do -not belong in the repository. +notary profile must already be stored in an accessible keychain; its credentials +do not belong in the repository. Set `NOTARYTOOL_KEYCHAIN_PATH` to select a managed +release keychain explicitly on a headless host with a locked login keychain. ```sh git tag -s v0.7.0 -m "Release v0.7.0"