Skip to content

fix: sign and notarize macos binary (CON-787) - #321

Open
justinwlin wants to merge 2 commits into
mainfrom
justinlin/con-787-sign-notarize-macos
Open

fix: sign and notarize macos binary (CON-787)#321
justinwlin wants to merge 2 commits into
mainfrom
justinlin/con-787-sign-notarize-macos

Conversation

@justinwlin

@justinwlin justinwlin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

macOS binaries ship with only the ad-hoc signature the Go linker emits — TeamIdentifier unset, Signature=adhoc, spctl verdict rejected. Gatekeeper refuses to run them; the brew cask path fails loudest, as "the application is damaged and can't be opened."

All four macOS install paths (brew formula, brew cask, install.sh, runpodctl update) fetch runpodctl-darwin-all.tar.gz, so signing that one artifact fixes all four.

Changes

  • .goreleaser.ymlquill sign-and-notarize post-hook on universal_binaries, after lipo merges the per-arch builds. output: true so quill's notary submission id and rejection reasons reach the job log; GoReleaser swallows hook stdout otherwise.
  • release.yml — install quill (pinned to v0.7.1), pass five QUILL_* secrets to GoReleaser, and bound the job at timeout-minutes: 45.

Snapshots ad-hoc sign and skip notary submission (--dry-run={{ .IsSnapshot }}), so local builds need no credentials. Job stays on ubuntu-latest — quill signs and notarizes Mach-O from Linux.

The timeout matters because quill waits for a conclusive notary verdict with no bound of its own: a stalled Apple submission would otherwise hold the release-${ref} concurrency lock for the default 6h and block any retry.

Verification

Snapshot build passes; hooks inherit job env; the real universal binary signs valid against the production cert

goreleaser release --snapshot --clean → exit 0, hook fires on the merged binary and its output is now visible:

• universal binaries
  • creating from 2 binaries    id=default binary=dist/default_darwin_all/runpodctl
  • running hook                hook=quill sign-and-notarize "dist/default_darwin_all/runpodctl" --dry-run=true --ad-hoc=true
    │ [0000]  WARN only ad-hoc signing ...
    │ [0000]  WARN DRY RUN: skipping notarization...

GoReleaser hooks inherit the job environment — verified with a sentinel variable in an isolated project, since that is how the five secrets reach quill:

• running hook   hook=sh -c 'echo "HOOK SEES SENTINEL=[$SENTINEL_VAR]"'
  │ HOOK SEES SENTINEL=[leaked-into-hook]

Same universal artifact signed with the real Developer ID .p12:

Authority=Developer ID Application: Runpod, Inc. (YM4PQ356FQ)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=YM4PQ356FQ

codesign --verify --deep --strict:
  valid on disk
  satisfies its Designated Requirement

Not yet exercised: the live Apple notary round-trip, which only runs on a real tag. Watch the first tagged release.

Notes

  • Signing is not retroactive — existing v2.3.0 artifacts stay unsigned. A new tag is required after merge.
  • Certificate is Developer ID Application, G2 Sub-CA, valid to 2031-07-31. Previous Sub-CA certs would have expired 2027-02-01.
  • Fails closed: notarization failure aborts during the build stage, before the GitHub release or tap PR exist. No half-published release.
  • upx stays scoped to linux_amd64 — it rewrites the Mach-O and would destroy the signature.
  • Bare binaries in .tar.gz can't be stapled (xcrun stapler is .app/.dmg/.pkg only), so Gatekeeper does an online ticket lookup. Expected.
  • The legacy per-arch darwin binaries remain unsigned. Only pre-change clients self-update via those; current update.go and install.sh both use darwin-all. Unchanged by this PR.

Does not fix: Homebrew tap trust (HOMEBREW_REQUIRE_TAP_TRUST) or self-update checksum verification (API-318). Both orthogonal.

CON-787

the darwin universal binary ships with only the ad-hoc signature the go
linker emits, so gatekeeper rejects it with "no usable signature". all
four macos install paths (brew formula, brew cask, install.sh, runpodctl
update) fetch runpodctl-darwin-all.tar.gz, so signing that one artifact
fixes all of them.

sign after lipo merges the per-arch builds — signing before the merge
produces an invalid signature. snapshots ad-hoc sign and skip notary
submission, so local builds stay credential-free.

quill signs and notarizes mach-o from linux, so the job stays on
ubuntu-latest.
three operability fixes on the signing pipeline:

- output: true on the quill hook. goreleaser swallows hook stdout, which
  is where the notary submission id and any rejection reason go —
  notarization is the most failure-prone step here and was silent.
- pin quill to v0.7.1. the installer piped into sudo sh took whatever was
  latest at release time, so an upstream regression could break signing
  with no change on our side, and releases were not reproducible.
- timeout-minutes: 45 on the release job. quill waits for a conclusive
  notary verdict with no bound of its own; a stalled apple submission
  would hold the release-${ref} concurrency lock for the default 6h and
  block any retry.
@justinwlin
justinwlin marked this pull request as ready for review July 30, 2026 23:48
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