ci: Sign and notarize the macOS standalone binaries - #609
Merged
Conversation
Build the darwin binaries on a macOS runner so they can be code signed with the Developer ID Application certificate under the hardened runtime, then notarized in a single notarytool submission covering both architectures. The compiled binaries carry their JavaScript bundle in a __BUN segment with no data trailing the Mach-O, so a signature lays out cleanly, and the job proves it by verifying the signature and running the signed binary. Signing is skipped, with a warning, when the Apple secrets are unset: the binaries then only get an ad-hoc signature, which keeps pull request builds working while exercising the same code path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
Replace the hand-rolled keychain setup with apple-actions/import-codesign-certs, which runs the same security commands and deletes the temporary keychain after the job, leaving only the codesign and notarytool calls as scripts. Drop the Apple ID and app-specific password notarization path: the notary service is authenticated with an App Store Connect API key alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
Move the manual trigger out of _build.yml and into a caller, following the _publish.yml and publish.yml pair. A reusable workflow only ever populates inputs from workflow_call, so serving a workflow_dispatch from the same file meant defaulting every input again at each use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
Gatekeeper rejects an ad-hoc signature on any machine but the one that made it, so falling back to one only produced binaries that look signed. Take a sign input instead: when it is set, every Apple credential is required and the job fails without them, and when it is not, the binaries are built but left alone and reported as unreleasable. Anything that releases signs. A check opts out, since a pull request from a fork cannot read the credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
razor-x
commented
Aug 5, 2026
| build: | ||
| name: Build | ||
| uses: ./.github/workflows/_build.yml | ||
| # Passes the Apple credentials used to sign and notarize the macOS binaries. |
Member
Author
There was a problem hiding this comment.
Suggested change
| # Passes the Apple credentials used to sign and notarize the macOS binaries. |
Comment on lines
+60
to
+61
| # A pull request from a fork cannot read the signing credentials, | ||
| # and a check never releases what it builds. |
Member
Author
There was a problem hiding this comment.
Suggested change
| # A pull request from a fork cannot read the signing credentials, | |
| # and a check never releases what it builds. |
| build: | ||
| name: Build | ||
| uses: ./.github/workflows/_build.yml | ||
| # Checks the macOS signing credentials without cutting a release. |
Member
Author
There was a problem hiding this comment.
Suggested change
| # Checks the macOS signing credentials without cutting a release. |
| name: build-${{ github.sha }}-macos | ||
| path: release | ||
| - name: Generate checksums | ||
| # Signing changes the macOS binaries, so checksum everything after it. |
Member
Author
There was a problem hiding this comment.
Suggested change
| # Signing changes the macOS binaries, so checksum everything after it. |
| if-no-files-found: error | ||
| path: release/* | ||
|
|
||
| bundle: |
Member
Author
There was a problem hiding this comment.
this is just linux (yes it also builds windows but we may need to sign those eventually to anyway)
linux builds the Linux and Windows binaries alongside the npm package, macos builds and signs the macOS ones, and build assembles the artifact that both feed. The job display names, and so the check names, stay as they were. Drop the comments that only restate the line below them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
The build resolves the one Developer ID Application identity in the imported certificate, so the secret only matters when there is more than one to choose between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
GitHub only offers a workflow_dispatch for a workflow on the default branch, so the credentials cannot be checked this way until the workflow is merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The released macOS binaries were cross-compiled on Linux and shipped unsigned, so Gatekeeper blocks them on any machine that downloads them. They are now built on a macOS runner, code signed with the Developer ID Application certificate under the hardened runtime, and notarized before release.
Build workflow
_build.ymlsplits into three jobs. Itsartifact_nameoutput and the finalbuild-<sha>artifact are unchanged, socheck.ymlandpublish.ymlconsume it exactly as before.linuxmacosbuildchecksums.txtChecksums move to the last job because signing rewrites the macOS binaries.
Signing
apple-actions/import-codesign-certsimports the certificate into a temporary keychain and deletes it after the job.codesignruns with--options runtime, a secure timestamp, and the three entitlements JavaScriptCore needs to start under the hardened runtime:allow-jit,allow-unsigned-executable-memory, anddisable-library-validation.notarytool submit --waitcovers both architectures, since the notary service notarizes every eligible binary in the archive. A ticket cannot be stapled to a bare executable, so Gatekeeper looks it up online by cdhash.Signing is required, never faked
A
signinput controls it, rather than the presence of credentials:sign: false. A pull request from a fork cannot read the credentials, and nothing a check builds is released. Those binaries are reported as unreleasable.There is no ad-hoc signature fallback: Gatekeeper rejects one anywhere but the machine that made it, so it would only produce binaries that look signed.
build.ymladds a manual entrypoint for running a full signing pass without cutting a release.Two things worth knowing
Compiled binaries carry their JavaScript bundle in a
__BUNsegment with nothing trailing the Mach-O, so a signature lays out cleanly. Verified by parsing the segments of the binaries this repo produces.Two details of the build would otherwise be silent bugs, and both are asserted in the job:
LC_CODE_SIGNATUREon its darwin-x64 output whosedatasizeoverruns the end of the file by 21,760 bytes, fixed in Bun 1.3. The signing step drops Bun's ad-hoc signature first socodesignlays out its own from scratch.prepackinjects the version intosrc/lib/version.ts, which the old single job got for free fromnpm pack. Without it the macOS binaries would report0.0.0, so themacosjob runs it and the verify step asserts the reported version.Before this can release
APPLE_CERTIFICATE,APPLE_CERTIFICATE_PASSWORD,APPLE_API_KEY,APPLE_API_KEY_ID, andAPPLE_API_ISSUER_ID. LeaveAPPLE_SIGNING_IDENTITYunset unless the bundle holds more than one Developer ID Application certificate.workflow_dispatchfor a workflow already on the default branch, soBuildcannot run until then.Buildby hand to confirm the credentials work.codesignandnotarytoolhave not run yet, and nothing on this pull request exercises them: checks build withsign: false. That manual run is what proves the signing path end to end, before a tag depends on it.🤖 Generated with Claude Code
https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w