From 61928ee6e9d8cb569644415a283441a4c88f79e5 Mon Sep 17 00:00:00 2001 From: Q Date: Fri, 4 Sep 2026 19:45:51 -0500 Subject: [PATCH] fix(release): expose signing keychain to private-key lookup --- CHANGELOG.md | 6 ++++++ README.md | 2 +- docs/macos-signing.md | 4 +++- package.json | 2 +- scripts/sign-macos-release.sh | 18 ++++++++++++++++-- tests/release/macos-signing.test.ts | 25 +++++++++++++++++++++++-- 6 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ed7fd..27a4a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.12] - 2026-09-05 + +### Fixed + +- Make the temporary macOS signing keychain discoverable to codesign's private-key lookup, restoring the original keychain search list on every exit path. The v0.8.11 release was blocked before publication by this lookup failure. + ## [0.8.11] - 2026-09-05 ### Changed diff --git a/README.md b/README.md index 0637d99..8c18a67 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh Or install a specific version: ```bash -LINEAR_VERSION=v0.8.11 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh +LINEAR_VERSION=v0.8.12 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh ``` On Debian/Ubuntu, the installer automatically uses the `.deb` package. To skip deb and install the raw binary instead: diff --git a/docs/macos-signing.md b/docs/macos-signing.md index fd34661..351d21d 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -52,7 +52,9 @@ Keep the `.p12`, passwords, and private key out of the repository and logs. Only the macOS signing step receives secrets. `scripts/sign-macos-release.sh` imports the certificate into a temporary password-protected keychain, restricts key access to codesign, and resolves the exact valid identity to its fingerprint. It does not -change the default keychain or search list. An exit trap deletes the keychain and +change the default keychain. It temporarily adds the signing keychain to the user +search list so codesign can locate the private key (even with `--keychain`). An +exit trap restores the original search list and deletes the keychain and temporary files on success, failure, and catchable signals; GitHub-hosted ephemeral runners also bound credential lifetime if the process is forcibly terminated. diff --git a/package.json b/package.json index b7dbd22..b719745 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearctl", - "version": "0.8.11", + "version": "0.8.12", "private": true, "type": "module", "bin": { diff --git a/scripts/sign-macos-release.sh b/scripts/sign-macos-release.sh index 5cff496..ae4d31a 100644 --- a/scripts/sign-macos-release.sh +++ b/scripts/sign-macos-release.sh @@ -19,10 +19,23 @@ if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || fi [[ -f "$binary" ]] +# codesign's private-key lookup also uses the user search list, even when its +# certificate lookup is restricted with --keychain. Preserve paths with spaces. +keychain_list="$(security list-keychains -d user)" +original_keychains=() +while IFS= read -r path; do + [[ "$path" == *\"*\"* ]] || continue + path="${path#*\"}" + path="${path%\"*}" + original_keychains+=("$path") +done <<< "$keychain_list" + work_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/linearctl-sign.XXXXXX")" keychain="$work_dir/signing.keychain-db" cleanup() { local result=$? cleanup_result=0 + # The conditional array expansion is compatible with nounset in macOS Bash 3. + security list-keychains -d user -s ${original_keychains[@]+"${original_keychains[@]}"} || cleanup_result=$? if [[ -f "$keychain" ]]; then security delete-keychain "$keychain" || cleanup_result=$? fi @@ -43,13 +56,14 @@ printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$work_dir/certifica security create-keychain -p "$keychain_password" "$keychain" security set-keychain-settings -lut 21600 "$keychain" security unlock-keychain -p "$keychain_password" "$keychain" +security list-keychains -d user -s "$keychain" ${original_keychains[@]+"${original_keychains[@]}"} security import "$work_dir/certificate.p12" -P "$MACOS_CERTIFICATE_PASSWORD" \ -k "$keychain" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" rm "$work_dir/certificate.p12" -# Match the exact identity, then sign by fingerprint. Do not alter the user's -# default keychain or search list; codesign uses this temporary keychain only. +# Match the exact identity, then sign by fingerprint from this keychain only. +# The default keychain is unchanged; the search list is restored on exit. identities="$(security find-identity -v -p codesigning "$keychain")" fingerprint="$(printf '%s\n' "$identities" | awk -F '"' -v identity="$MACOS_SIGN_IDENTITY" \ '$2 == identity { split($1, fields, " "); print fields[2] }')" diff --git a/tests/release/macos-signing.test.ts b/tests/release/macos-signing.test.ts index bbca84e..3312562 100644 --- a/tests/release/macos-signing.test.ts +++ b/tests/release/macos-signing.test.ts @@ -32,6 +32,15 @@ printf '%s %s\\n' "$tool" "$*" >> "$MOCK_LOG" case "$tool" in security) case "$1" in + list-keychains) + if [[ "$#" -eq 3 ]]; then + if [[ "\${MOCK_EMPTY_SEARCH_LIST:-}" != true ]]; then + printf ' "%s"\\n' '/Users/runner/Library/Keychains/login.keychain-db' '/Users/runner/Library/Keychains/shared identity.keychain-db' + fi + else + : > "$MOCK_SEARCH_LIST" + for path in "\${@:5}"; do printf '%s\\n' "$path" >> "$MOCK_SEARCH_LIST"; done + fi ;; create-keychain) touch "\${!#}" ;; import) [[ "\${MOCK_FAIL:-}" != import ]] ;; find-identity) printf ' 1) %s "%s"\\n' '${fingerprint}' "$MOCK_IDENTITY" ;; @@ -40,7 +49,9 @@ case "$tool" in codesign) case "$*" in *--remove-signature*) [[ "\${MOCK_FAIL:-}" != remove ]] ;; - *--force*) [[ "\${MOCK_FAIL:-}" != sign ]] ;; + *--force*) + grep -q '/linearctl-sign\\.' "$MOCK_SEARCH_LIST" + [[ "\${MOCK_FAIL:-}" != sign ]] ;; *--check-notarization*) [[ "\${MOCK_FAIL:-}" != ticket ]] ;; *--verify*) [[ "\${MOCK_FAIL:-}" != verify ]] ;; esac ;; @@ -61,6 +72,7 @@ esac RUNNER_TEMP: dir, GITHUB_ACTIONS: "false", MOCK_LOG: logPath, + MOCK_SEARCH_LIST: join(dir, "search-list"), MOCK_IDENTITY: identity, MOCK_NOTARY_RESPONSE: JSON.stringify({ id: "submission-id", status: "Accepted" }), MACOS_CERTIFICATE_BASE64: Buffer.from("test certificate").toString("base64"), @@ -83,6 +95,8 @@ esac function expectCleanedUp() { expect(readdirSync(dir).filter((name) => name.startsWith("linearctl-sign."))).toEqual([]); expect(log()).toContain("security delete-keychain"); + expect(readFileSync(join(dir, "search-list"), "utf8")).toBe(env.MOCK_EMPTY_SEARCH_LIST === "true" ? "" : + "/Users/runner/Library/Keychains/login.keychain-db\n/Users/runner/Library/Keychains/shared identity.keychain-db\n"); } it("repairs, signs, verifies, notarizes and smoke-tests in order, then removes credentials", () => { @@ -104,7 +118,14 @@ esac expect(commands).toContain(`--sign ${fingerprint} --keychain`); expect(commands).toContain("--options runtime --timestamp --entitlements"); expect(commands).toContain('certificate leaf[subject.OU] = "ABCDEFGHIJ"'); - expect(commands).not.toContain("security list-keychains"); + expect(commands).toContain("security list-keychains -d user -s"); + expectCleanedUp(); + }); + + it("restores an originally empty keychain search list", () => { + env.MOCK_EMPTY_SEARCH_LIST = "true"; + const result = run(); + expect(result.status, result.stderr).toBe(0); expectCleanedUp(); });