ci: sign and notarize releases in a final job on GitHub's runner

Merged
NO
7h ago
+1142-138

Problem.

  • Every compile phase received the Developer ID certificate and the notarization password, and the release job, which holds the Sparkle private key, ran on the compile runner. Moving the compile to Depot (D4) would have moved the keys.
  • The DMG was signed but never notarized or stapled.
  • Release builds contain rac's dev switches, and nothing checks for them (gate G6).
  • generate_sparkle_deltas.py assumes every earlier release has both architectures, so the release after an arm64-only 0.1.0 would have crashed.

Change.

  • Compile phases only compile. They receive the Sparkle public key and a plain true/false for branded entitlements; the true/false is computed in a small settings job. They receive no certificate, notarization password or provisioning profile. The finishing phase uploads the unsigned app as tar+zstd with a sha256 file.
  • New per-architecture sign job on GitHub-hosted macos-latest:
    • It downloads and checks the app with retries, then imports the certificate into a temporary keychain (set-key-partition-list, so no dialog appears). The keychain is deleted in an always() step.
    • It runs the G6 gate, then signs, notarizes and staples the app.
    • It signs the DMG with a timestamp, then notarizes and staples it, retrying on Apple's transient staple errors (exit codes 65 and 68) and on upload failures.
    • It mounts the DMG and verifies it: codesign --verify --deep --strict; spctl with assessments enabled and source=Notarized Developer ID for both the app and the DMG (the DMG with -t open --context context:primary-signature); stapler validate; the signing team; and no profile-only entitlements without a profile.
    • It makes the deltas and uploads the same DMG artifact the release job already expects.
  • Release job. It is pinned to macos-latest because it holds the Sparkle key. It re-verifies each notarized DMG before signing it into the appcast.
  • G6 gate. github_check_release_binary.sh runs strings over the app's executables and frameworks and looks for rac-agent-debug, rac-agent-mcp-echo, rac-window-screenshot and rac-launch-in-background. The new Developer switches (G6) input defaults to warn. Switch it to enforce once Core's BUILDFLAG(RAC_DEV_SWITCHES) change merges.
  • Deltas. Architectures an earlier release lacks are skipped, and the script prints "made N deltas".
  • Secrets stay out of logs. Tracing is off wherever a secret is used, and the sign log is no longer written to disk.
  • Docs. docs/operations/releases.md and upstream-sync.md are updated.

Preflight findings.

  • The empty MAC_TEAM_ID (patches/rac/branding/identity.patch) doesn't affect signing or notarization, which use the certificate name and PROD_MACOS_NOTARIZATION_TEAM_ID. It only matters if branded entitlements are turned on: the app would then claim .me.kainoa.rac and fail to launch. github_verify_dmg.sh now fails the build in that case.
  • Today's entitlements (hardened-runtime keys, and allow-jit in the helpers) are allowed for a Developer ID app without a profile. The restricted set (application-identifier, keychain-access-groups, web-browser public-key credential) is off.

Verified.

  • 14 unit tests for the G6 gate on fake Mach-O binaries, including a locale bug that a real universal binary exposed.
  • shellcheck is clean on the changed scripts. actionlint reports only the existing description error in prepare-environment.
  • A local ad hoc dry run with a fake app covered packing, the download check, unpacking (framework symlinks survive), the gate, sign_and_package_app.sh, the DMG, the hashes and the outputs.
  • The verify script passed a good ad hoc image and correctly failed on a restricted entitlement without a profile, a mismatched team, an ad hoc image that was expected to be notarized, and an unmountable image.
  • The retry logic was tested with a stub xcrun.
  • An Opus reviewer that hadn't seen the design tried to break it and found no secret reaching a compile job. Its six findings are fixed in the second commit.
  • Not proven: real certificate import, notarization, stapling and Gatekeeper on a runner. The first proof is build 1 (0.1.0), which fires when the sync merges.

Patch surface (D17): no change, because this PR touches no patches.

Part of plan 06 M0 task 5 and M1 (the ci stream).

🤖 Generated with Claude Code (Claude Opus 5.5 and Claude Sonnet 5.5)


[!NOTE]
High Risk
Restructures where Apple signing/notarization and Sparkle release secrets run, and changes what ships if verification or the new sign job fails—core release security infrastructure.

Overview
Splits compile from signing so build phases never receive the Developer ID certificate, notarization credentials, or provisioning profile. Finished phases upload an unsigned rac.app (tar+zstd + checksum) instead of a DMG; a new per-arch sign job on macos-latest runs G6, signs/notarizes/staples the app and DMG, verifies the mounted image, and publishes the same release artifacts as before.

Secret exposure is reduced: a settings job exposes only a boolean for branded entitlements to compile jobs; the release job is pinned to GitHub's runner alongside sign. sign_and_package_app.sh now notarizes and staples the DMG (with retries), and generate_sparkle_deltas.py skips architectures missing from older releases.

G6 gate: new github_check_release_binary.sh scans Mach-O binaries for developer switch strings, controlled by a Developer switches (G6) workflow input (warn / enforce). The release job re-runs github_verify_dmg.sh on downloaded DMGs before appcast signing. Unit tests cover the gate and verify script behavior; release docs and upstream-sync notes describe the new flow.

Reviewed by Cursor Bugbot for commit 9f9e5a672251359b3c45f566ffc70ca2780917a9. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Moves code signing, notarization, and release packaging out of the build phases and into a final sign job that always runs on GitHub's own macos-latest runner, so signing keys never reach build runners. Notarizes and staples the DMG, which was previously signed but never notarized.

  • Build phases now only compile and receive just the Sparkle public key plus a true/false flag for branded entitlements computed by a small settings job; the finished phase uploads the unsigned app as a tar.zst with a sha256.
  • The sign job imports the certificate into a temporary keychain (deleted in an always() step), runs a Gatekeeper check on the mounted DMG, signs, notarizes, and staples both app and DMG with retries on Apple's transient errors, and makes the update deltas.
  • Tracing is disabled wherever a secret is read or decoded, so signing and notarization credentials never appear in logs.
  • The release job, which holds the Sparkle private key, is pinned to macos-latest and re-verifies each notarized DMG before writing it into the appcast.
  • The team ID and certificate name are stripped of stray whitespace before comparison, and github_verify_dmg.sh fails if codesign can't read the entitlements instead of treating it as none.