Skip to content

build(release): macOS Developer ID signing + notarization (inert until secrets exist) - #2856

Draft
devarismeroxa wants to merge 1 commit into
mainfrom
build/macos-notarization
Draft

build(release): macOS Developer ID signing + notarization (inert until secrets exist)#2856
devarismeroxa wants to merge 1 commit into
mainfrom
build/macos-notarization

Conversation

@devarismeroxa

Copy link
Copy Markdown
Contributor

Draft on purpose. The signing path cannot be executed until an Apple Developer Program membership exists, so I will not claim it verified. Everything that can be verified without credentials has been, and is evidenced below. See "What I need from you".

Defect

Through v0.19.0 the darwin release binaries are ad-hoc signed only. A browser download carries com.apple.quarantine and Gatekeeper kills the binary with no output at all:

$ ./conduit version          # quarantined, as a browser leaves it
(no output)
$ echo $?
137                          # SIGKILL

$ codesign -dv ./conduit
Signature=adhoc
$ spctl -a -vv ./conduit
./conduit: rejected

This is the README's first documented install route ("Download a pre-built binary ... and simply run it!"). Exit 137 and an empty terminal — no dialog, no reason, nothing to search for.

It stayed invisible because neither CI nor curl sets the quarantine attribute; only a human with a browser hits it. Homebrew and install.sh are unaffected for the same reason. Found by an end-to-end DX audit.

Approach

scripts/sign-darwin.sh runs as a GoReleaser post-build hook and signs + notarizes with quill, which works from the Linux release runner — no macOS runner needed, no change to the job topology.

It is inert by construction. The hook exits 0 without doing anything unless all of:

  • the target is darwin,
  • the tag is not a nightly,
  • MACOS_SIGN_P12 is set.

So this is safe to merge before the credentials exist: the release behaves exactly as it does today and logs why it skipped. Nightlies never notarize — it costs an Apple round-trip of several minutes, the train runs daily, and nobody browser-downloads a nightly.

quill is installed from a pinned release with its checksum verified rather than anchore's curl | sh installer. This is the one job that produces the artifacts users execute; an unverified download here is a supply-chain hole. Checksum e58c6f86...3fbf was taken from quill's published checksums.txt and the download+verify was executed locally.

Bonus fix: a latent release-pipeline break

.goreleaser.yml used two properties GoReleaser has deprecated (archives.builds, archives.format_overrides.format) while release.yml pins goreleaser version: latest. When those are removed upstream, releases break at tag time — the worst possible moment to discover it. Pre-existing on main; confirmed by A/B, not introduced here.

Migrated to archives.ids and formats. goreleaser check now reports no deprecations (previously two).

Verification — what I actually ran

Check Result
goreleaser check clean; no deprecations, config valid
A/B snapshot build, main vs this branch 13 artifacts, byte-identical names (diff empty)
Post-build hook execution ran on all 7 targets; silent no-op on the 5 non-darwin, explanatory message on both darwin
Built darwin binary still works dist/conduit_darwin_arm64_v8.0/conduit versionv0.20.0-nightly.20260905 darwin/arm64
quill pinned download + checksum executed: quill_0.7.1_linux_amd64.tar.gz: OK, extracts to a 27MB binary
bash -n + shellcheck on the hook clean
Hook no-op paths all three exercised by hand (non-darwin / nightly / no-p12), all exit 0
markdownlint not run — local node is broken (syntax error: unexpected end of file). Checked MD013 (tables exempt per .markdownlint.yml), MD040, tabs and emphasis style by hand. CI is authoritative.

Note on the goreleaser target string: it is darwin_arm64_v8.0, not darwin_arm64. The hook matches darwin_*, verified against real hook output.

What I could NOT verify

The signing and notarization path itself. It requires an Apple Developer ID certificate that does not exist. No claim is made that signing works. The first real signed release is the test, and docs/releases.md documents exactly how to verify it (download through a browser, then codesign -dv / spctl -a / ./conduit version).

What I need from you

An Apple Developer Program membership (99 USD/year). There is no way to notarize without one — it is not a technical choice. Then five repo secrets, documented in docs/releases.md:

MACOS_SIGN_P12, MACOS_SIGN_P12_PASSWORD, MACOS_NOTARY_KEY, MACOS_NOTARY_KEY_ID, MACOS_NOTARY_ISSUER

The certificate type must be Developer ID Application specifically. "Apple Development" and "Apple Distribution" certs cannot notarize software distributed outside the App Store.

Interim mitigation

The README fix — stop telling macOS users to do the thing that silently dies, and document xattr -d com.apple.quarantine — is landing separately in the stale-references PR, which is already editing that install section.

Adversarial self-review

  • Invented an action. My first draft used anchore/quill-action@v0. I checked and it 404s — it does not exist. Replaced with the pinned checksum-verified install. Flagging because it is exactly the kind of thing that looks plausible and would have failed only at tag time.
  • Nightly guard tested against a real nightly tag string (v0.20.0-nightly.20260905), not a guess at the format.
  • Stapling — a bare Mach-O cannot be stapled; that needs a container (.app/.dmg/.pkg). Apple publishes the ticket and Gatekeeper fetches it on first run, so a notarized CLI in a tarball is accepted with network access. Standard shape for a notarized CLI, documented in docs/releases.md, not a gap.
  • Blast radius of the deprecation migration — proven by artifact-name diff rather than by reading the config, because the archive-name template is the thing users' scripts depend on.
  • skip=sign in my snapshot runs skips goreleaser's own signs block (unused here), not build hooks — confirmed by seeing the hook actually run.

Risk tier

Tier 3 — release tooling. No engine, data-path or config-schema change. The one real risk is breaking the release job, which is why the artifact-name A/B and the inert-by-default design are the core of the verification.

🤖 Generated with Claude Code

https://claude.ai/code/session_016xE861dwb3MLgqEdWRECLY

…rop deprecated goreleaser keys

Through v0.19.0 the darwin release binaries were ad-hoc signed only —
`codesign -dv` reports `Signature=adhoc`, `spctl -a -vv` reports
`rejected`. A browser download carries com.apple.quarantine, so
Gatekeeper kills the binary with no output at all: exit 137, empty
terminal, no dialog, nothing to search for. That is the README's first
documented install route.

It stayed invisible because neither CI nor curl sets the quarantine
attribute; only a human using a browser hits it. Homebrew and
install.sh are unaffected for the same reason.

scripts/sign-darwin.sh runs as a GoReleaser post-build hook and signs +
notarizes via quill, which works from the Linux release runner so no
macOS runner is needed. quill is installed from a pinned release and
checksum-verified rather than through anchore's curl|sh installer: this
is the one job that produces the artifacts users execute.

The hook is a no-op unless the target is darwin, the tag is not a
nightly, and MACOS_SIGN_P12 is set. So this merges safely BEFORE the
Apple credentials exist — the release behaves exactly as it does today
and logs why it skipped. Nightlies never notarize: it costs an Apple
round-trip of several minutes, the train runs daily, and nobody
browser-downloads a nightly.

Signing stays OFF until an Apple Developer Program membership provides
a Developer ID Application certificate and an App Store Connect API
key. docs/releases.md documents the five secrets and how to verify a
signed release.

Separately, .goreleaser.yml used two properties GoReleaser has
deprecated (archives.builds, archives.format_overrides.format) while
release.yml pins `version: latest`. When they are removed the release
pipeline breaks at tag time — the worst possible moment. Migrated to
archives.ids and formats. Verified by A/B snapshot build: 13 artifacts,
byte-identical names before and after, and `goreleaser check` now
reports no deprecations.

Tier 3 (release tooling; no engine or data-path change).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xE861dwb3MLgqEdWRECLY
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