Skip to content

feat(ci): sign and notarize the macOS binary, verify it on macOS - #134

Merged
tenequm merged 2 commits into
mainfrom
feat/macos-codesign-notarize
Jul 30, 2026
Merged

feat(ci): sign and notarize the macOS binary, verify it on macOS#134
tenequm merged 2 commits into
mainfrom
feat/macos-codesign-notarize

Conversation

@tenequm

@tenequm tenequm commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Why

The darwin binary is cross-compiled from Linux (cargo zigbuild) and was ad-hoc signed. It is never built or run on macOS before shipping, so two things could reach users unnoticed:

  • A binary that cannot launch. That is the class of bug ops/scripts/patch-macos-sdk.py exists for, and it does not degrade gracefully: the Homebrew formula calls generate_completions_from_executable at install time, so a dead binary breaks brew install itself.
  • No publisher identity. Ad-hoc carries no team, no chain to Apple. Blocks MDM-managed Macs and rules out ever shipping a cask, .app, or .pkg.

What

release-prep -> dist-build (pond-ci)      build + Developer ID sign
             -> macos-verify (macos-15)   launch -> signature -> notarize
             -> publish-release (pond-ci) crates.io, release, tap, flake

publish-release is split so the macOS checks gate the publish without re-running the four-target build; needs: does all the sequencing and publish rehydrates dist/ from an artifact.

Signing and notarization ship together deliberately. Gatekeeper has an explicit [Unnotarized Developer ID] P0 deny execute rule, so a Developer ID signature without notarization is strictly worse than ad-hoc.

Notes on two non-obvious choices

spctl is not used to verify. It rejects every bare CLI binary regardless of notarization — verified against a known-good notarized binary:

$ spctl --assess --type exec -vv /usr/local/bin/docker
/usr/local/bin/docker: rejected (the code is valid but does not seem to be an app)

So macos-verify asserts the two properties notarization actually requires (Developer ID authority, hardened runtime flag) instead.

The notarization gate is the JSON status, not the exit code. notarytool submit --wait exits 0 even when the notary returns Invalid.

Credentials

A CI-dedicated Developer ID Application certificate, separate from the interactive one, so it is independently revocable. Its private key is not in any local keychain. Notarization uses an App Store Connect API key (notarytool supports no other auth that can be exported).

Verified before this branch existed

The full chain was run locally against Apple with these exact commands and credentials:

step result
rcodesign sign via the moon.yml code path Developer ID + hardened runtime + secure timestamp
codesign assertions pass on signed, fail on ad-hoc
notarytool submit --wait {"status":"Accepted"}

Before merging

.github/workflows/dry-run-apple-signing.yml is temporary and must be deleted. It mirrors dist-build + macos-verify on pull_request (the production jobs only fire on a release push), giving this PR a real signed and notarized rehearsal.

It does not exercise publish-release, whose changes are mechanical: artifact download instead of in-job build, V from a job output.

The darwin binary is cross-compiled from Linux and was ad-hoc signed, never
built or run on macOS before shipping. Two consequences: a binary that cannot
launch reaches users (the class of bug patch-macos-sdk.py exists for, and that
breaks `brew install` outright since the formula runs `pond completions` at
install time), and the signature carries no publisher identity.

Sign with a CI-dedicated Developer ID Application certificate plus hardened
runtime in build-dist, then gate the publish on a macos-15 job that launches
the artifact, asserts the signature, and notarizes it.

- split publish-release into dist-build -> macos-verify -> publish-release;
  needs: does the sequencing, publish rehydrates dist/ from an artifact
- sign via rcodesign --p12-file when APPLE_P12_BASE64 is set, ad-hoc otherwise
  so local and fork builds still produce a loadable arm64 binary
- notarize with xcrun notarytool on the Mac itself; gate on the JSON status,
  since --wait exits 0 even when the notary returns Invalid
- assert Developer ID authority + runtime flag rather than spctl, which
  rejects every bare CLI binary regardless of notarization
- drop the formula's on_intel block: it served the aarch64 tarball behind a
  depends_on arch: :arm64 that already refuses Intel
- bump actions/checkout to v7.0.1, release-plz/action to v0.5.131
@tenequm tenequm self-assigned this Jul 30, 2026
It proved dist-build's signing path and macos-verify against Apple on #134.
Left in place it would rebuild and re-notarize on every future PR.
@tenequm
tenequm merged commit 9ab0165 into main Jul 30, 2026
6 checks passed
@tenequm
tenequm deleted the feat/macos-codesign-notarize branch July 30, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant