Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion docs/macos-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linearctl",
"version": "0.8.11",
"version": "0.8.12",
"private": true,
"type": "module",
"bin": {
Expand Down
18 changes: 16 additions & 2 deletions scripts/sign-macos-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] }')"
Expand Down
25 changes: 23 additions & 2 deletions tests/release/macos-signing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ;;
Expand All @@ -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 ;;
Expand All @@ -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"),
Expand All @@ -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", () => {
Expand All @@ -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();
});

Expand Down
Loading