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"); +});