diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e515445..fbec6f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -99,6 +99,19 @@ jobs:
- name: Build binary
run: bun run build:binary --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }}
+ - name: Sign, notarize, and verify macOS binary
+ if: runner.os == 'macOS'
+ shell: bash
+ env:
+ BINARY_NAME: ${{ matrix.binary }}
+ MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: bash scripts/sign-macos-release.sh "./dist/$BINARY_NAME"
+
- name: Smoke test binary
run: bun run smoke:binary dist/${{ matrix.binary }}
@@ -137,8 +150,35 @@ jobs:
path: dist/${{ matrix.artifact }}
if-no-files-found: error
+ verify-macos:
+ # Fresh machines: no signing keychain or notarization cache from the build.
+ needs: [resolve, build]
+ strategy:
+ matrix:
+ include:
+ - os: macos-15-intel
+ artifact: linearctl-darwin-x64
+ - os: macos-latest
+ artifact: linearctl-darwin-arm64
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.resolve.outputs.commit }}
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ matrix.artifact }}
+ path: dist
+
+ - name: Verify quarantined macOS release on a clean runner
+ shell: bash
+ env:
+ BINARY_NAME: ${{ matrix.artifact }}
+ run: bash scripts/verify-macos-release.sh "./dist/$BINARY_NAME"
+
release:
- needs: [resolve, validate, build]
+ needs: [resolve, validate, build, verify-macos]
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9c6367..d5ed7fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.8.11] - 2026-09-05
+
+### Changed
+
+- Require Developer ID signing and Apple notarization for both macOS release binaries, with fresh native-runner verification before publication. First launch requires online ticket retrieval; standalone binaries cannot carry stapled tickets.
+- Share the pinned, verified build pipeline between CI and releases, and gate publication on validation of the exact tagged commit.
+
### Fixed
- Preserve completed upload/project resources, structured errors, and meaningful exit codes when composite workflows fail; report skipped steps and provide recovery guidance in human and both JSON modes.
+- Verify staged Unix installer downloads before atomically replacing an existing installation, and preserve macOS quarantine instead of bypassing Gatekeeper.
+- Stream file transfers with cancellation and atomic downloads.
+- Preserve partial pagination results and resume context when transport requests fail.
+- Honor JSON envelopes in remaining top-level CLI error paths.
## [0.8.10] - 2026-09-02
diff --git a/INSTALL.md b/INSTALL.md
index 3c20192..d0539d6 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -12,6 +12,12 @@ curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh
This detects your OS and architecture and installs to `~/.local/bin/linearctl`. On Debian/Ubuntu it uses a `.deb` package automatically.
+macOS releases are Developer ID signed and notarized. First launch needs Internet
+access so Gatekeeper can retrieve Apple's ticket; standalone binaries cannot be
+stapled for offline first launch. The installer does not remove quarantine. See
+[macOS signing](docs/macos-signing.md) for details and the separate repair path
+for locally built binaries.
+
If `~/.local/bin` is not in your PATH, add it:
```bash
diff --git a/README.md b/README.md
index 8197aa7..0637d99 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.10 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh
+LINEAR_VERSION=v0.8.11 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:
@@ -24,6 +24,11 @@ On Debian/Ubuntu, the installer automatically uses the `.deb` package. To skip d
LINEAR_NO_DEB=1 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh
```
+macOS releases use Developer ID signing and notarization. First launch requires
+Internet access for Gatekeeper's ticket lookup; the standalone binaries cannot
+carry stapled tickets. See [macOS signing](docs/macos-signing.md) for the release
+policy and required maintainer secrets.
+
### Windows
From PowerShell:
@@ -47,6 +52,9 @@ cp dist/linearctl ~/.local/bin/linearctl
On Windows, `bun run build:binary` produces `dist\linearctl.exe`; copy it to a directory on your user `PATH`.
The compiled binary has no runtime dependencies. Building from source requires the [Bun](https://bun.sh) version pinned in `.bun-version`. See [build and release validation](docs/releasing.md) for toolchain updates, generated-artifact checks, and release gates.
+Local macOS builds are not Developer ID signed or notarized. If Bun leaves an
+invalid signature (verification fails or execution exits 137), follow the
+[local signature verification and repair steps](docs/macos-signing.md#local-builds-and-signature-repair).
## Quick start
diff --git a/docs/macos-signing.md b/docs/macos-signing.md
new file mode 100644
index 0000000..fd34661
--- /dev/null
+++ b/docs/macos-signing.md
@@ -0,0 +1,122 @@
+# macOS signing and notarization
+
+## Supported release strategy
+
+Both `linearctl-darwin-x64` and `linearctl-darwin-arm64` are standalone Mach-O
+executables signed with a **Developer ID Application** certificate, hardened
+runtime, and a secure timestamp. Each signed executable is submitted to Apple's
+notary service in a temporary ZIP; only an explicit `Accepted` result permits the
+build to proceed. The release publishes the exact signed binary submitted in that
+ZIP, retaining the existing asset names and installer format.
+
+**This is an online-notarization strategy, not a stapled distribution.** Neither
+raw Mach-O executables nor ZIP archives support stapling a notarization ticket.
+Gatekeeper must retrieve the ticket from Apple, so **the first launch requires
+Internet access to Apple's services**. Offline first launch is not supported. A
+future offline distribution would need a stapled container such as a signed DMG
+or installer package; running `stapler` against these raw assets is not valid.
+See Apple's [Testing a Notarised Product](https://developer.apple.com/forums/thread/130560).
+
+There is no unsigned or ad-hoc fallback for public macOS releases. Missing secrets,
+invalid identities, signing errors, rejected/pending/timed-out notarization,
+verification failures, and execution failures all block publication of the entire
+release. This policy applies to releases built with this workflow, not older assets.
+
+The installer verifies release checksums and does **not** remove quarantine. Do
+not re-sign downloaded releases or clear quarantine to work around a failed
+Gatekeeper check: that would bypass the supported trust path. Check network
+access, use a current release, and report a persistent failure instead.
+
+## GitHub Actions secrets
+
+Configure these repository (or accessible organization) Actions secrets before
+triggering a release:
+
+| Secret | Required value |
+| --- | --- |
+| `MACOS_CERTIFICATE_BASE64` | Base64-encoded password-protected `.p12` export containing the Developer ID Application certificate **and private key** |
+| `MACOS_CERTIFICATE_PASSWORD` | Non-empty password for that `.p12` export |
+| `MACOS_SIGN_IDENTITY` | Exact `Developer ID Application: Your Name (TEAMID)` identity name; required, not auto-selected |
+| `APPLE_ID` | Apple ID email with access to the developer team |
+| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for notarization, not the Apple ID login password |
+| `APPLE_TEAM_ID` | Ten-character Apple Developer Team ID matching the certificate |
+
+Export the identity from Keychain Access on a trusted Mac, then copy its encoding:
+
+```bash
+base64 -i DeveloperIDApplication.p12 | pbcopy
+security find-identity -v -p codesigning
+```
+
+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
+temporary files on success, failure, and catchable signals; GitHub-hosted ephemeral
+runners also bound credential lifetime if the process is forcibly terminated.
+
+The signing script removes Bun's existing signature before signing. Hardened
+runtime entitlements in `scripts/macos-entitlements.plist` allow JIT and unsigned
+executable memory for Bun's JavaScriptCore engine. No debugger, DYLD environment,
+or library-validation exemptions are enabled. See the
+[Bun signing guide](https://bun.com/guides/runtime/codesign-macos-executable) for
+background; this CLI does not need its broader native-library permissions.
+
+## Release verification
+
+For **each** architecture, CI:
+
+1. Builds on a native macOS runner (Intel on `macos-15-intel`, arm64 on
+ `macos-latest`).
+2. Repairs/signs, runs `codesign --verify --strict --verbose=4` with an Apple
+ Developer ID and expected-team requirement, and submits to notarization with
+ a bounded wait. The JSON submission ID/status is printed for diagnostics;
+ maintainers can retrieve Apple's failure report with `xcrun notarytool log`
+ using that submission ID and their notarization credentials.
+3. Requires `Accepted`, verifies the online notarization ticket, and runs
+ `--version` and `--help` before uploading the build artifact.
+4. Downloads the artifact on a **fresh native macOS runner**, without signing
+ credentials or the build machine's ticket cache. Restores executable mode,
+ sets `com.apple.quarantine`, and requires Gatekeeper to be enabled.
+5. Runs strict signature verification and `codesign --check-notarization` with a
+ `notarized` requirement. This is Apple's prescribed assessment for non-app
+ code; `spctl --assess --type execute` and `syspolicy_check distribution` target
+ app bundles, not standalone command-line tools.
+6. Runs the quarantined binary's `--version` and `--help` with an empty environment
+ except a fresh HOME/TMPDIR and system-only PATH, outside the checkout. It does
+ not remove quarantine, disable Gatekeeper, or re-sign the downloaded artifact.
+
+Only after both clean-runner jobs succeed can the release job calculate checksums
+and publish assets. These automated checks exercise online ticket retrieval and
+native execution; they are not an offline or interactive Finder-install test.
+The shell regression tests mock Apple tools and test failure handling; actual
+Apple signing and Gatekeeper integration requires the macOS release jobs.
+
+## Local builds and signature repair
+
+`bun run build:binary` does not import a Developer ID identity or notarize. Bun may
+supply an ad-hoc signature, but some Bun/macOS combinations leave it invalid,
+causing an immediate kill (often exit 137). After building **your own trusted
+source** on macOS, check it before copying it into your PATH:
+
+```bash
+bun run build:binary
+codesign --verify --strict --verbose=4 dist/linearctl
+./dist/linearctl --version
+```
+
+If verification fails or the locally compiled binary is killed, repair its
+signature on that Mac:
+
+```bash
+codesign --remove-signature dist/linearctl
+codesign --force --sign - dist/linearctl
+codesign --verify --strict --verbose=4 dist/linearctl
+./dist/linearctl --version
+./dist/linearctl --help
+```
+
+Repeat after rebuilding if needed. This ad-hoc repair enables local development;
+it does **not** establish a Developer ID or notarization trust chain and is not a
+supported fix for quarantined downloads or a distribution signing strategy.
diff --git a/docs/releasing.md b/docs/releasing.md
index 5209925..c1f1ad8 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -71,7 +71,8 @@ is safe because release always runs its own blocking validation of the tag's SHA
manual dispatch has no bypass. To retry, dispatch `release.yml` with the existing
version tag; validation runs again before assets can be uploaded or overwritten.
-macOS signing/notarization is tracked separately in #177. Signing belongs after
-compilation and before final smoke tests/upload; checksums must cover the final
-signed assets. Keep the shared build entry point and validation dependencies when
-adding that step.
+macOS binaries are Developer ID signed and notarized after compilation and before
+final smoke tests/upload. Both architectures then pass quarantined execution and
+online notarization checks on fresh native runners before publication. Checksums
+cover the final signed assets. See [macOS signing](macos-signing.md) for required
+secrets, the online-first-launch policy, and local signature repair.
diff --git a/install.sh b/install.sh
index b222a8d..358a54a 100755
--- a/install.sh
+++ b/install.sh
@@ -80,11 +80,6 @@ main() {
# umask (omitting "who" in symbolic chmod preserves masked permissions).
chmod +rx "$STAGED_BINARY"
- # Remove macOS quarantine attribute so Gatekeeper doesn't block unsigned binary
- if [ "$os" = "darwin" ] && command -v xattr > /dev/null 2>&1; then
- xattr -d com.apple.quarantine "$STAGED_BINARY" 2>/dev/null || true
- fi
-
mv -f "$STAGED_BINARY" "${INSTALL_DIR}/${BINARY_NAME}"
echo "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}"
diff --git a/package.json b/package.json
index b5295e2..b7dbd22 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "linearctl",
- "version": "0.8.10",
+ "version": "0.8.11",
"private": true,
"type": "module",
"bin": {
diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist
new file mode 100644
index 0000000..f00fbb5
--- /dev/null
+++ b/scripts/macos-entitlements.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+
diff --git a/scripts/sign-macos-release.sh b/scripts/sign-macos-release.sh
new file mode 100644
index 0000000..5cff496
--- /dev/null
+++ b/scripts/sign-macos-release.sh
@@ -0,0 +1,96 @@
+#!/bin/bash
+# Release-only: no unsigned/ad-hoc fallback. See docs/macos-signing.md.
+set -euo pipefail
+umask 077
+
+binary="${1:?Usage: bash scripts/sign-macos-release.sh }"
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+
+for name in MACOS_CERTIFICATE_BASE64 MACOS_CERTIFICATE_PASSWORD MACOS_SIGN_IDENTITY APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
+ if [[ -z "${!name:-}" ]]; then
+ echo "Error: required release secret $name is missing" >&2
+ exit 1
+ fi
+done
+if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] ||
+ [[ "$MACOS_SIGN_IDENTITY" != "Developer ID Application: "*" ($APPLE_TEAM_ID)" ]]; then
+ echo "Error: MACOS_SIGN_IDENTITY must be a Developer ID Application identity for APPLE_TEAM_ID" >&2
+ exit 1
+fi
+[[ -f "$binary" ]]
+
+work_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/linearctl-sign.XXXXXX")"
+keychain="$work_dir/signing.keychain-db"
+cleanup() {
+ local result=$? cleanup_result=0
+ if [[ -f "$keychain" ]]; then
+ security delete-keychain "$keychain" || cleanup_result=$?
+ fi
+ rm -rf "$work_dir" || cleanup_result=$?
+ if [[ "$result" -ne 0 ]]; then
+ exit "$result"
+ fi
+ exit "$cleanup_result"
+}
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+
+keychain_password="$(openssl rand -base64 32)"
+if [[ "${GITHUB_ACTIONS:-}" == true ]]; then
+ echo "::add-mask::$keychain_password"
+fi
+printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$work_dir/certificate.p12"
+security create-keychain -p "$keychain_password" "$keychain"
+security set-keychain-settings -lut 21600 "$keychain"
+security unlock-keychain -p "$keychain_password" "$keychain"
+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.
+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] }')"
+if [[ ! "$fingerprint" =~ ^[[:xdigit:]]{40}$ ]]; then
+ echo "Error: expected exactly one valid matching Developer ID Application identity" >&2
+ exit 1
+fi
+
+# Bun can leave a malformed signature; remove it before applying a fresh one.
+codesign --remove-signature "$binary"
+codesign --force --sign "$fingerprint" --keychain "$keychain" \
+ --identifier com.github.qwrobins.linearctl --options runtime --timestamp \
+ --entitlements "$script_dir/macos-entitlements.plist" "$binary"
+codesign --verify --strict --verbose=4 \
+ -R="anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$APPLE_TEAM_ID\"" \
+ "$binary"
+
+# Apple accepts ZIP submissions, not naked Mach-O files. Notarization records
+# the binary's code hash; publish those exact signed bytes, not this ZIP.
+ditto -c -k --keepParent "$binary" "$work_dir/notarization.zip"
+notary_result=0
+xcrun notarytool submit "$work_dir/notarization.zip" \
+ --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" \
+ --wait --timeout 20m --output-format json > "$work_dir/notarization.json" || notary_result=$?
+# Print the submission ID/status for diagnostics and require Accepted explicitly:
+# notarytool's process exit code alone is not a notarization verdict.
+python3 - "$work_dir/notarization.json" <<'PY'
+import json
+import sys
+
+with open(sys.argv[1]) as result_file:
+ result = json.load(result_file)
+print(json.dumps(result, indent=2))
+if result.get("status") != "Accepted":
+ sys.exit("Error: notarization was not Accepted; inspect the submission with notarytool log")
+PY
+if [[ "$notary_result" -ne 0 ]]; then
+ exit "$notary_result"
+fi
+
+# Raw executables cannot be stapled. Require an online ticket lookup instead.
+codesign --verify --strict --verbose=4 -R=notarized --check-notarization "$binary"
+"$binary" --version
+"$binary" --help
diff --git a/scripts/verify-macos-release.sh b/scripts/verify-macos-release.sh
new file mode 100644
index 0000000..820568a
--- /dev/null
+++ b/scripts/verify-macos-release.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Run on a fresh, native-architecture macOS runner without signing credentials.
+set -euo pipefail
+binary="${1:?Usage: bash scripts/verify-macos-release.sh }"
+
+# Artifact transport does not preserve executable permissions or quarantine.
+chmod +x "$binary"
+xattr -w com.apple.quarantine "0083;$(printf '%x' "$(date +%s)");linearctl-release-test;" "$binary"
+spctl --status | grep -qx 'assessments enabled'
+codesign --verify --strict --verbose=4 "$binary"
+# Apple's prescribed check for non-app code (spctl --type execute is for apps).
+codesign --verify --strict --verbose=4 --check-notarization \
+ -R='anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and notarized' \
+ "$binary"
+
+# Keep quarantine intact. Use a clean HOME and system-only PATH to ensure the
+# compiled binary does not depend on Bun, repository files, or user config.
+work_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/linearctl-verify.XXXXXX")"
+trap 'rm -rf "$work_dir"' EXIT
+trap 'exit 1' HUP INT TERM
+binary="$(cd "$(dirname "$binary")" && pwd)/$(basename "$binary")"
+cd "$work_dir"
+env -i HOME="$work_dir" TMPDIR="$work_dir" PATH=/usr/bin:/bin:/usr/sbin:/sbin "$binary" --version
+env -i HOME="$work_dir" TMPDIR="$work_dir" PATH=/usr/bin:/bin:/usr/sbin:/sbin "$binary" --help
diff --git a/tests/install/unix-installer.test.sh b/tests/install/unix-installer.test.sh
index 0672988..e2aa76b 100644
--- a/tests/install/unix-installer.test.sh
+++ b/tests/install/unix-installer.test.sh
@@ -80,20 +80,9 @@ MOCK
cat > "$TEST_ROOT/mock-bin/xattr" <<'MOCK'
#!/bin/sh
set -eu
-[ "$TEST_OS" = darwin ] || exit 96
-[ "$#" -eq 3 ] && [ "$1" = -d ] && [ "$2" = com.apple.quarantine ] || exit 97
-[ "$(dirname "$3")" = "$LINEAR_INSTALL_DIR" ] || exit 98
-[ "$3" != "$LINEAR_INSTALL_DIR/linearctl" ] || exit 99
-cmp -s "$3" "$TEST_ROOT/fixtures/replacement" || exit 100
-[ -x "$3" ] || exit 101
-if [ "$INSTALL_STATE" = upgrade ]; then
- cmp -s "$LINEAR_INSTALL_DIR/linearctl" "$TEST_ROOT/fixtures/existing" || exit 102
-else
- [ ! -e "$LINEAR_INSTALL_DIR/linearctl" ] || exit 103
-fi
-# Real xattr may fail when quarantine is absent; the installer must tolerate it.
-# Record successful assertions first so its best-effort call cannot hide errors.
-touch "$CASE_ROOT/quarantine-checked"
+# Signed releases must retain quarantine. Record even best-effort attempts so
+# an installer using `xattr ... || true` cannot hide a trust-policy regression.
+touch "$CASE_ROOT/quarantine-modified"
exit 1
MOCK
chmod +x "$TEST_ROOT/mock-bin/"*
@@ -141,9 +130,7 @@ run_case() {
077) expected_mode='-rwx------' ;;
esac
[ "$mode" = "$expected_mode" ] || fail "unexpected replacement permissions: $mode"
- if [ "$TEST_OS" = darwin ]; then
- [ -f "$CASE_ROOT/quarantine-checked" ] || fail 'quarantine removal did not operate on the prepared staging file'
- fi
+ [ ! -f "$CASE_ROOT/quarantine-modified" ] || fail 'installer attempted to modify quarantine'
[ "$("$LINEAR_INSTALL_DIR/linearctl")" = VERIFIED_REPLACEMENT ] || fail 'replacement cannot execute'
grep -q 'Checksum verified' "$CASE_ROOT/output" || fail 'checksum was not verified'
else
diff --git a/tests/release/macos-signing.test.ts b/tests/release/macos-signing.test.ts
new file mode 100644
index 0000000..bbca84e
--- /dev/null
+++ b/tests/release/macos-signing.test.ts
@@ -0,0 +1,223 @@
+import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { spawnSync } from "node:child_process";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+const root = resolve(import.meta.dirname, "../..");
+const identity = "Developer ID Application: Test Developer (ABCDEFGHIJ)";
+const fingerprint = "A".repeat(40);
+
+// Exercise shell control flow without Apple credentials or a macOS host. Real
+// codesign/notarization/Gatekeeper integration runs in the release matrix.
+describe.skipIf(process.platform === "win32")("macOS release scripts", () => {
+ let dir: string;
+ let binary: string;
+ let logPath: string;
+ let env: NodeJS.ProcessEnv;
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "linearctl-sign-test-"));
+ const bin = join(dir, "mock-bin");
+ mkdirSync(bin);
+ logPath = join(dir, "commands.log");
+ writeFileSync(logPath, "");
+ binary = join(dir, "linearctl with spaces");
+ writeFileSync(binary, `#!/bin/bash\nprintf 'binary %s\\n' "$*" >> '${logPath}'\n`, { mode: 0o755 });
+
+ const mock = `#!/bin/bash
+set -eu
+tool="$(basename "$0")"
+printf '%s %s\\n' "$tool" "$*" >> "$MOCK_LOG"
+case "$tool" in
+ security)
+ case "$1" in
+ create-keychain) touch "\${!#}" ;;
+ import) [[ "\${MOCK_FAIL:-}" != import ]] ;;
+ find-identity) printf ' 1) %s "%s"\\n' '${fingerprint}' "$MOCK_IDENTITY" ;;
+ delete-keychain) rm -f "$2" ;;
+ esac ;;
+ codesign)
+ case "$*" in
+ *--remove-signature*) [[ "\${MOCK_FAIL:-}" != remove ]] ;;
+ *--force*) [[ "\${MOCK_FAIL:-}" != sign ]] ;;
+ *--check-notarization*) [[ "\${MOCK_FAIL:-}" != ticket ]] ;;
+ *--verify*) [[ "\${MOCK_FAIL:-}" != verify ]] ;;
+ esac ;;
+ ditto) touch "\${!#}" ;;
+ xcrun)
+ printf '%s' "\${MOCK_NOTARY_RESPONSE}"
+ [[ "\${MOCK_FAIL:-}" != notary ]] ;;
+ spctl) printf '%s\\n' "\${MOCK_GATEKEEPER:-assessments enabled}" ;;
+ xattr) [[ "\${MOCK_FAIL:-}" != quarantine ]] ;;
+esac
+`;
+ for (const name of ["security", "codesign", "ditto", "xcrun", "spctl", "xattr"]) {
+ writeFileSync(join(bin, name), mock, { mode: 0o755 });
+ }
+ env = {
+ ...process.env,
+ PATH: `${bin}:${process.env.PATH}`,
+ RUNNER_TEMP: dir,
+ GITHUB_ACTIONS: "false",
+ MOCK_LOG: logPath,
+ MOCK_IDENTITY: identity,
+ MOCK_NOTARY_RESPONSE: JSON.stringify({ id: "submission-id", status: "Accepted" }),
+ MACOS_CERTIFICATE_BASE64: Buffer.from("test certificate").toString("base64"),
+ MACOS_CERTIFICATE_PASSWORD: "test-only",
+ MACOS_SIGN_IDENTITY: identity,
+ APPLE_ID: "test@example.com",
+ APPLE_APP_SPECIFIC_PASSWORD: "test-only",
+ APPLE_TEAM_ID: "ABCDEFGHIJ",
+ };
+ });
+
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ function run(script = "sign-macos-release.sh") {
+ return spawnSync("bash", [join(root, "scripts", script), binary], { env, encoding: "utf8" });
+ }
+ function log() {
+ return readFileSync(logPath, "utf8");
+ }
+ function expectCleanedUp() {
+ expect(readdirSync(dir).filter((name) => name.startsWith("linearctl-sign."))).toEqual([]);
+ expect(log()).toContain("security delete-keychain");
+ }
+
+ it("repairs, signs, verifies, notarizes and smoke-tests in order, then removes credentials", () => {
+ const result = run();
+ expect(result.status, result.stderr).toBe(0);
+ const commands = log();
+ const stages = [
+ "security import", "codesign --remove-signature", "codesign --force",
+ "codesign --verify --strict --verbose=4", "ditto -c -k --keepParent",
+ "xcrun notarytool submit", "-R=notarized --check-notarization",
+ "binary --version", "binary --help", "security delete-keychain",
+ ];
+ let previous = -1;
+ for (const stage of stages) {
+ const index = commands.indexOf(stage);
+ expect(index, stage).toBeGreaterThan(previous);
+ previous = index;
+ }
+ 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");
+ expectCleanedUp();
+ });
+
+ it.each([
+ "MACOS_CERTIFICATE_BASE64", "MACOS_CERTIFICATE_PASSWORD", "MACOS_SIGN_IDENTITY",
+ "APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID",
+ ])("fails before importing when %s is absent", (name) => {
+ env[name] = "";
+ const result = run();
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toContain(name);
+ expect(log()).toBe("");
+ });
+
+ it.each(["-", "Apple Development: Test Developer (ABCDEFGHIJ)", "Developer ID Application: Other (0123456789)"])(
+ "rejects an invalid release identity: %s", (invalidIdentity) => {
+ env.MACOS_SIGN_IDENTITY = invalidIdentity;
+ expect(run().status).not.toBe(0);
+ expect(log()).toBe("");
+ },
+ );
+
+ it("rejects a missing identity in the imported keychain", () => {
+ env.MOCK_IDENTITY = "Developer ID Application: Someone Else (ABCDEFGHIJ)";
+ expect(run().status).not.toBe(0);
+ expect(log()).not.toContain("\ncodesign ");
+ expectCleanedUp();
+ });
+
+ it.each(["import", "remove", "sign", "verify", "notary", "ticket"])(
+ "fails closed and cleans up after a %s failure", (stage) => {
+ env.MOCK_FAIL = stage;
+ expect(run().status).not.toBe(0);
+ expect(log()).not.toContain("binary --");
+ if (["import", "remove", "sign", "verify"].includes(stage)) {
+ expect(log()).not.toContain("xcrun notarytool");
+ }
+ expectCleanedUp();
+ },
+ );
+
+ it.each(["Invalid", "In Progress", "Rejected", undefined])("rejects notarization status %s even with exit zero", (status) => {
+ env.MOCK_NOTARY_RESPONSE = JSON.stringify({ id: "submission-id", status });
+ expect(run().status).not.toBe(0);
+ expect(log()).not.toContain("--check-notarization");
+ expect(log()).not.toContain("binary --");
+ expectCleanedUp();
+ });
+
+ it("fails closed for malformed notarization output", () => {
+ env.MOCK_NOTARY_RESPONSE = "not json";
+ expect(run().status).not.toBe(0);
+ expect(log()).not.toContain("binary --");
+ expectCleanedUp();
+ });
+
+ it("checks quarantine, strict signature and online notarization before clean execution", () => {
+ const result = run("verify-macos-release.sh");
+ expect(result.status, result.stderr).toBe(0);
+ const commands = log();
+ expect(commands).toContain("xattr -w com.apple.quarantine 0083;");
+ expect(commands).toContain("codesign --verify --strict --verbose=4");
+ expect(commands).toContain("--check-notarization");
+ expect(commands).toContain("exists and notarized");
+ expect(commands.indexOf("--check-notarization")).toBeLessThan(commands.indexOf("binary --version"));
+ expect(commands).toContain("binary --help");
+ expect(commands).not.toContain("security ");
+ expect(commands).not.toContain("xattr -d");
+ expect(readdirSync(dir).filter((name) => name.startsWith("linearctl-verify."))).toEqual([]);
+ });
+
+ it("executes without build-machine environment or configuration", () => {
+ writeFileSync(binary, `#!/bin/bash
+set -eu
+[[ "$PATH" == /usr/bin:/bin:/usr/sbin:/sbin ]]
+[[ "$HOME" == "$TMPDIR" && "$PWD" == "$HOME" ]]
+[[ -z "\${APPLE_ID:-}" && -z "\${MACOS_CERTIFICATE_BASE64:-}" ]]
+`);
+ const result = run("verify-macos-release.sh");
+ expect(result.status, result.stderr).toBe(0);
+ });
+
+ it.each(["sign-macos-release.sh", "verify-macos-release.sh"])("propagates execution failure in %s", (script) => {
+ writeFileSync(binary, "#!/bin/bash\nexit 137\n");
+ expect(run(script).status).toBe(137);
+ if (script === "sign-macos-release.sh") expectCleanedUp();
+ });
+
+ it.each(["quarantine", "verify", "ticket"])("does not execute after clean-runner %s failure", (stage) => {
+ env.MOCK_FAIL = stage;
+ expect(run("verify-macos-release.sh").status).not.toBe(0);
+ expect(log()).not.toContain("binary --");
+ });
+
+ it("rejects a runner with Gatekeeper disabled", () => {
+ env.MOCK_GATEKEEPER = "assessments disabled";
+ expect(run("verify-macos-release.sh").status).not.toBe(0);
+ expect(log()).not.toContain("binary --");
+ });
+});
+
+it("gates publication on clean macOS verification and preserves quarantine in the installer", () => {
+ const workflow = readFileSync(join(root, ".github/workflows/release.yml"), "utf8");
+ expect(workflow).toContain("needs: [resolve, validate, build, verify-macos]");
+ expect(workflow.indexOf("bash scripts/sign-macos-release.sh")).toBeLessThan(workflow.indexOf("actions/upload-artifact"));
+ const verification = workflow.split(" verify-macos:")[1]?.split(" release:")[0] ?? "";
+ expect(verification).toContain("needs: [resolve, build]");
+ expect(verification).toContain("ref: ${{ needs.resolve.outputs.commit }}");
+ expect(verification).toContain("macos-15-intel");
+ expect(verification).toContain("linearctl-darwin-x64");
+ expect(verification).toContain("linearctl-darwin-arm64");
+ expect(verification).toContain("actions/download-artifact");
+ expect(verification).toContain("bash scripts/verify-macos-release.sh");
+ expect(verification).not.toContain("secrets.");
+ expect(readFileSync(join(root, "install.sh"), "utf8")).not.toContain("xattr -d");
+});