Skip to content

build: sign and notarize releases with a Developer ID, from a local script - #24

Merged
hiking90 merged 4 commits into
mainfrom
chore/developer-id-signing
Sep 5, 2026
Merged

hiking90 merged 4 commits into
mainfrom
chore/developer-id-signing

Conversation

@hiking90

@hiking90 hiking90 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Releases were ad-hoc signed, so Gatekeeper blocked both the .pkg and 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.md as a rule rather than left implicit.

Actions secrets are not zero-knowledge: gh secret set encrypts 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 — running codesign on a GitHub-hosted runner unwraps the key into a VM we do not control, whether or not the .p12 was 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, release environment, SHA-pinned actions). That is all removed. Nothing was ever uploaded — no secrets are configured on the repo.

release.yml now only deploys the versioned docs for the tag and holds no secrets.

Releasing

git tag v0.4.0-rc1 && git push origin v0.4.0-rc1   # docs deploy only
./scripts/release.sh v0.4.0-rc1                    # build → sign → notarize → publish

scripts/release.sh front-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. spctl gates 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.sh and scripts/build.sh sign with ONGEUL_SIGN_ID plus hardened runtime (a notarization prerequisite) and fall back to ad-hoc when unset, so the dev loop needs no credentials. The .pkg is signed separately with ONGEUL_INSTALLER_ID — a different certificate; signing only the app still gets blocked at install time. notarytool --wait does 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 -Onone while 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.
  • 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.
  • Docs. The Gatekeeper override procedure is gone from the install guide (along with its two now-unreferenced screenshots), replaced by a note that 0.4.0 is signed and notarized. Troubleshooting gains an entry for the one-time TCC reset: Accessibility and Input Monitoring grants are keyed to the code signature, so moving from ad-hoc to Developer ID invalidates them once — and they survive updates from 0.4.0 onward, which is an improvement over the status quo the old docs described.

Verified

End-to-end on this machine, against a real 0.4.0-rc1 package:

=== [7/7] Notarization ===
    Status: Accepted

$ spctl -a -vvv -t install build/Ongeul-0.4.0-rc1.pkg
build/Ongeul-0.4.0-rc1.pkg: accepted
source=Notarized Developer ID
origin=Developer ID Installer: JeongHoe Kim (5KH893KQQW)

App signature carries flags=0x10000(runtime) and a trusted timestamp; codesign --verify --strict --deep passes; the binary is x86_64 arm64. xcodebuild test (114 tests) and the ad-hoc fallback path are both green. release.sh preflight failures were exercised directly.

Not yet verified: the -O binary in an actual input session — that package was built before the flag landed. That is what rc1 is for.

🤖 Generated with Claude Code

hiking90 and others added 4 commits September 4, 2026 23:58
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>
@hiking90
hiking90 merged commit 8954739 into main Sep 5, 2026
2 checks passed
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