build: sign and notarize releases with a Developer ID, from a local script - #24
Merged
Merged
Conversation
Releases were ad-hoc signed, so Gatekeeper blocked both the .pkg and the app and the install guide had to walk users through overriding it — every update. Wire real signing through the build scripts and CI. scripts/build.sh, scripts/package.sh - Sign with ONGEUL_SIGN_ID (Developer ID Application) plus hardened runtime and the entitlements file when set, ad-hoc otherwise, so the dev loop keeps working with no credentials. - Sign the .pkg with ONGEUL_INSTALLER_ID (Developer ID Installer) — a separate certificate; signing only the app still gets blocked at install time. - Notarize the .pkg and staple the ticket. notarytool --wait does not always exit non-zero on rejection, so read status out of the JSON and dump the log before failing. .github/workflows/release.yml - Import both certificates into a keychain created inside the job and deleted afterwards; never echo the base64. Identities are read back out of the keychain by SHA-1 rather than kept as secrets. - Notarize with an App Store Connect API key so no Apple ID password lives in CI. - Put the job behind a `release` environment, so the signing key is not visible to any other workflow, and pin third-party actions to commit SHAs since this job holds a private key. Docs: replace the Gatekeeper override procedure with a note that 0.4.0 is signed and notarized, and record the one-time TCC reset that comes with the signature change (Accessibility grants are keyed to the signature; they survive updates from 0.4.0 onward). Rewrite the packaging guide around the new environment variables and CI secrets. Info.plist: 0.2.0 → 0.4.0. package.sh overwrites this from the tag, but build.sh does not, so local builds were reporting a two-releases-stale version in the preferences panel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signing in GitHub Actions would put the Developer ID private key on a machine we do not control. Actions secrets are not zero-knowledge — GitHub holds the decryption key — and even without storing the .p12 there, codesign running on a hosted runner unwraps the key in that VM's memory. A leak that got the certificate revoked would block already installed copies of Ongeul on users' machines, so the key stays here. - release.yml drops the build job entirely and now only deploys the versioned docs for the tag. It holds no secrets, and the environment and certificate import added in the previous commit are gone. - scripts/release.sh builds, signs, notarizes and publishes locally. Preflight fails fast on missing tools, missing signing environment, a dirty tree, a tag that is unpushed or not at HEAD, or a release that already exists — signing and notarization take minutes, so none of that should be discovered late. spctl gates publication, so a skipped notarization cannot ship. Tags containing '-' publish as pre-releases, which the old workflow did not do. - Use a string rather than an array for the --prerelease flag: macOS ships bash 3.2, where expanding an empty array under set -u aborts, which would have broken every non-rc release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
swiftc was invoked with no optimization flag, so the shipped Swift layer was built at -Onone while the Rust engine was built with --release. The CGEventTap callback runs on every key event, so this is not free. - -O -wmo. A full build still takes under 10 seconds, so there is no reason to split debug and release configurations — what you run locally is what ships. - -file-prefix-map rewrites the build machine's absolute paths in #file literals and debug info. A Swift runtime trap used to print /Volumes/Workspace/rust/ongeul/... verbatim; now that releases are built locally rather than on a CI runner, that path is the maintainer's home directory layout. Verified: no /Users/ or /Volumes/ strings remain in the binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release.sh builds from the working tree, not from the tag's tree, and relied on "clean tree + HEAD == tag" to make those equivalent. But diff-index only sees tracked files, while build.sh collects sources with `find OngeulApp/Sources -name '*.swift'` — so a stray untracked .swift would be compiled into a signed, notarized release containing code that is not in the tag. Check `git ls-files --others` over the build inputs only: design/, CLAUDE.md and dic/ are permanently untracked in this repo, so a blanket check would never pass. OngeulApp/Generated is gitignored and therefore excluded automatically, which is required — the bindings are generated during the build rather than committed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Releases were ad-hoc signed, so Gatekeeper blocked both the
.pkgand the app, and the install guide had to walk every user through overriding it — on every update. Now that a Developer ID is available, wire real signing and notarization into the build, and publish releases from a local script.Signing stays off CI — on purpose
The Developer ID private key does not go to GitHub, and this is written into
CLAUDE.mdas a rule rather than left implicit.Actions secrets are not zero-knowledge:
gh secret setencrypts client-side, but GitHub holds the matching private key because it has to inject the value into runners. And storing it is not even the crux — runningcodesignon a GitHub-hosted runner unwraps the key into a VM we do not control, whether or not the.p12was ever a secret. If that key leaked and Apple revoked the certificate, already-installed copies of Ongeul would be blocked by Gatekeeper on users' machines — the blast radius reaches people who never updated.An earlier revision of this branch did wire certificates into Actions (temporary keychain,
releaseenvironment, SHA-pinned actions). That is all removed. Nothing was ever uploaded — no secrets are configured on the repo.release.ymlnow only deploys the versioned docs for the tag and holds no secrets.Releasing
scripts/release.shfront-loads everything that can fail — tools, signing environment, clean tree, tag pushed and pointing at HEAD, no existing release — because signing and notarization take minutes and none of that should surface late.spctlgates publication, so a build that skipped notarization cannot ship. Tags containing-publish as pre-releases, which the old workflow did not do, so an rc no longer takes over "Latest".scripts/package.shandscripts/build.shsign withONGEUL_SIGN_IDplus hardened runtime (a notarization prerequisite) and fall back to ad-hoc when unset, so the dev loop needs no credentials. The.pkgis signed separately withONGEUL_INSTALLER_ID— a different certificate; signing only the app still gets blocked at install time.notarytool --waitdoes not reliably exit non-zero on rejection, so the status is read out of the JSON and the log dumped before failing.Also in here
-O -wmo. swiftc had no optimization flag, so the shipped Swift layer was built at-Ononewhile the Rust engine used--release. The CGEventTap callback runs on every key event. A full build still takes under 10 seconds, so debug and release are not split — what you run locally is what ships.-file-prefix-map. A Swift runtime trap printed/Volumes/Workspace/rust/ongeul/...verbatim. That was tolerable while releases came off a CI runner; now that they are built locally it is the maintainer's home directory layout. Verified: no/Users/or/Volumes/strings remain in the binary.package.shoverwrites this from the tag butbuild.shdoes not, so local builds were reporting a two-releases-stale version in the preferences panel.Verified
End-to-end on this machine, against a real
0.4.0-rc1package:App signature carries
flags=0x10000(runtime)and a trusted timestamp;codesign --verify --strict --deeppasses; the binary isx86_64 arm64.xcodebuild test(114 tests) and the ad-hoc fallback path are both green.release.shpreflight failures were exercised directly.Not yet verified: the
-Obinary in an actual input session — that package was built before the flag landed. That is what rc1 is for.🤖 Generated with Claude Code