From 6cc7a305ecadf70340893aedb0c9f4da9a66fa3d Mon Sep 17 00:00:00 2001 From: Q Date: Fri, 4 Sep 2026 19:34:31 -0500 Subject: [PATCH 1/2] fix(release): sign and notarize macOS binaries before publishing --- .github/workflows/release.yml | 47 +++++- INSTALL.md | 6 + README.md | 8 + docs/macos-signing.md | 122 +++++++++++++++ install.sh | 5 - scripts/macos-entitlements.plist | 10 ++ scripts/sign-macos-release.sh | 96 ++++++++++++ scripts/verify-macos-release.sh | 24 +++ tests/release/macos-signing.test.ts | 222 ++++++++++++++++++++++++++++ 9 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 docs/macos-signing.md create mode 100644 scripts/macos-entitlements.plist create mode 100644 scripts/sign-macos-release.sh create mode 100644 scripts/verify-macos-release.sh create mode 100644 tests/release/macos-signing.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ba6f27..1cb7aa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: target: bun-linux-arm64 binary: linearctl-linux-arm64 artifact: linearctl-linux-arm64 - - os: macos-latest + - os: macos-15-intel target: bun-darwin-x64 binary: linearctl-darwin-x64 artifact: linearctl-darwin-x64 @@ -55,8 +55,21 @@ jobs: - name: Build binary run: bun build src/cli/main.ts --compile --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 - if: matrix.target == 'bun-linux-x64' || matrix.target == 'bun-darwin-arm64' + if: matrix.target == 'bun-linux-x64' env: BINARY_NAME: ${{ matrix.binary }} run: ./dist/"$BINARY_NAME" --help @@ -98,9 +111,37 @@ jobs: with: name: ${{ matrix.artifact }} path: dist/${{ matrix.artifact }} + if-no-files-found: error - release: + verify-macos: + # Fresh machines: no signing keychain or notarization cache from the build. needs: 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: ${{ github.event.inputs.tag || github.ref }} + + - 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: [build, verify-macos] runs-on: ubuntu-latest steps: 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 5eb166d..5bb8c92 100644 --- a/README.md +++ b/README.md @@ -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 [Bun](https://bun.sh). +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/install.sh b/install.sh index 4184b4f..74b6872 100755 --- a/install.sh +++ b/install.sh @@ -77,11 +77,6 @@ main() { chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - # 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 "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true - fi - echo "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then 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/release/macos-signing.test.ts b/tests/release/macos-signing.test.ts new file mode 100644 index 0000000..8759598 --- /dev/null +++ b/tests/release/macos-signing.test.ts @@ -0,0 +1,222 @@ +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: [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: build"); + 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"); +}); From ce67c40d3a0d9ba38222d7325960d85d2cd08dfc Mon Sep 17 00:00:00 2001 From: Q Date: Fri, 4 Sep 2026 19:36:09 -0500 Subject: [PATCH 2/2] chore: prepare v0.8.11 release --- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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/README.md b/README.md index c7c8a5c..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: 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": {