From 4829238ae29c2bd3a7eb026b554273997f9a9c61 Mon Sep 17 00:00:00 2001 From: Jeff Dickey <216188+jdx@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:01:33 -0500 Subject: [PATCH] feat(release): notarize the macOS binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage was the last jdx.dev CLI signing its macOS binary without notarizing it. A signature settles who built the binary; notarization is what Gatekeeper demands once a download carries the quarantine bit, which is to say whenever someone fetches an archive in a browser rather than with curl. Without a ticket it sits behind the "cannot be verified" dialog that only a deliberate override gets past. `codesign_options: runtime` is the half the action can supply — Apple rejects a submission without the hardened runtime. The secure timestamp it also requires is codesign's own default for a Developer ID identity. Nothing is stapled: the ticket is keyed to the binary's cdhash and lives on Apple's side, so this rewrites neither the archive nor the uploaded asset. `stapler` writes only into bundles, disk images, and installer packages, and this is a bare Mach-O in an archive; Gatekeeper resolves the ticket online. The step matches hk's, including its behaviour when App Store Connect credentials are absent: warn that the release ships un-notarized and carry on, rather than failing the build. Co-Authored-By: Claude Opus 5 Entire-Checkpoint: 01M1QDXPJ6F41KCH69ZNHZBAMW --- .github/workflows/publish-cli.yml | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 23661358b..40e6c59e0 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -118,10 +118,92 @@ jobs: include: cli/assets/usage.1 codesign: "Developer ID Application: Jeffrey Dickey (4993Y37DX6)" codesign_prefix: dev.jdx. + # Apple will not notarize a binary without the hardened runtime. + codesign_options: runtime dry-run: ${{ github.event_name == 'workflow_dispatch' }} env: CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 1 CARGO_PROFILE_RELEASE_LTO: true + # Signing settles who built the binary; notarization is what Gatekeeper + # demands once an archive carries the quarantine bit, which is to say + # whenever someone downloads it in a browser rather than with curl. + # Without a ticket it is blocked behind a "cannot be verified" dialog that + # only a deliberate override gets past. + # + # The ticket lives on Apple's side and is keyed to the binary's cdhash, so + # nothing here rewrites what was already built or uploaded -- stapling + # would, but `stapler` only writes into bundles, disk images, and + # installer packages, and this is a bare Mach-O in an archive. Gatekeeper + # resolves the ticket online instead. + - name: Notarize macOS binary + if: matrix.os == 'macos-latest' + shell: bash + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + BINARIES: target/${{ matrix.target }}/release/usage + run: | + set -euo pipefail + + if [ -z "$APPLE_API_KEY_P8" ] || [ -z "$APPLE_API_KEY_ID" ] || [ -z "$APPLE_API_ISSUER_ID" ]; then + echo "::warning title=Not notarized::App Store Connect credentials are unset, so this release ships signed but un-notarized macOS binaries. Gatekeeper will block them for anyone who downloads an archive through a browser." + exit 0 + fi + + # Outside the workspace, so no later step can sweep the key into an + # artifact, and removed even when notarization fails. + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$work/key.p8" + + # notarytool takes a container, never a bare executable. Copies are + # fine: a Mach-O carries its signature inline, so the cdhash the + # ticket is keyed to is the one already shipped. + mkdir "$work/stage" + for binary in $BINARIES; do + cp "$binary" "$work/stage/" + done + # Apple rejects a binary without the hardened runtime or a secure + # timestamp. Naming a missing one here turns an opaque Invalid verdict + # into a diagnosis; Apple stays the authority on whether to accept. + for binary in $BINARIES; do + details="$(codesign --display --verbose=4 "$binary" 2>&1 || true)" + case "$details" in + *runtime*) ;; + *) echo "::warning title=No hardened runtime::$binary is signed without --options runtime, which Apple requires; expect an Invalid verdict below." ;; + esac + case "$details" in + *Timestamp=*) ;; + *) echo "::warning title=No secure timestamp::$binary carries no secure timestamp, which Apple requires; expect an Invalid verdict below." ;; + esac + done + + ditto -c -k --norsrc --noextattr "$work/stage" "$work/submission.zip" + + # `--wait` is not a gate on its own: it can return zero on an Invalid + # submission, so the reported status is what decides. plutil reads + # JSON and ships with macOS, which keeps this off jq. + result="$(xcrun notarytool submit "$work/submission.zip" \ + --key "$work/key.p8" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" \ + --output-format json \ + --timeout 30m \ + --wait)" + echo "$result" + + status="$(printf '%s' "$result" | plutil -extract status raw -o - -)" + id="$(printf '%s' "$result" | plutil -extract id raw -o - -)" + if [ "$status" != "Accepted" ]; then + echo "::error::notarization returned $status for submission $id" + xcrun notarytool log "$id" \ + --key "$work/key.p8" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" || true + exit 1 + fi + echo "notarized: submission $id" release: runs-on: ubuntu-latest timeout-minutes: 10