bundle-apps: Developer ID signing for app binaries - #347
Merged
Merged
Conversation
Ad-hoc signing gave every published deck/board/chat/console build a new macOS TCC identity, so users were re-prompted for Documents access on every deploy. Mirror release.yml's Developer ID import and codesign each app binary with a fixed com.mattstack.<app> identifier, hardened runtime, and the same allow-jit entitlements build.sh already uses for these bun-compiled binaries, before the tarball and its sha256 (the value deps.lock pins) are produced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Warning Review limit reachedNext included review available in 4 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 90 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (1)
Comment |
Restructure the build job into build (runs the app repo's recipe, no keychain/cert steps, uploads the raw binary plus workflow-written facts) and a new sign-and-package job (macos-15, no app checkout, no app dependency install) that downloads that artifact, imports the Developer ID key, signs, verifies, tars, and hashes. The signing key is now never importable on the runner that executes app-controlled code, and the sign/skip decision is driven only by step outputs and inputs.dry_run, never by a GITHUB_ENV variable a later step could read after the recipe ran. Also switch the codesign identifier to com.mattstack.helper.<app>, matching rt-tray/build.sh's sign_helper_tree, so the raw-artifact distribution channel and the mattstack.app-embedded copy share one stable TCC identity instead of two. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
upload-artifact drops the executable bit, excludes dotfiles by default, and dereferences symlinks, so the raw binary handed to sign-and-package came back non-executable and failed its own post-sign smoke test. Tar the stage directory in build and extract it back out in sign-and-package instead, preserving modes and dotfiles end to end. sign-and-package's implicit success() on needs: [plan, build] meant one failed build leg skipped signing for every app (fail-fast is false) while release still ran and reported green having published nothing. if: !cancelled() restores per-leg independence: only the leg whose own built-<app> artifact is missing fails, at its own download-artifact step. Also: validate the tag read back from facts.json with git check-ref-format, for parity with the other fields re-validated there, and document two accepted (not fixed) residual risks on the sign job's steps: the post-sign smoke still runs the app's own binary beside the unlocked keychain, and release time does not re-verify signedness. Co-Authored-By: Claude Sonnet 5 <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.
Problem
.github/workflows/bundle-apps.ymlbuilds deck/board/chat/console/gitqfrom m4ttstack/apps and m4ttstack/gitq and packages them ad-hoc
signed. Ad-hoc signing has no stable identity: every rebuild produces
a different code identity, so macOS TCC (Documents access etc.)
treats each new published version of an app as a brand-new requester
and re-prompts the user on every
deck deploy. (The matrix covers allfive buildable rows in
rt-tray/deps.lock; gitq is a single-app repo,not a monorepo subdir, but it goes through the same recipe.)
Fix
Sign each app binary with the same Developer ID Application certificate
release.ymlalready imports (APPLE_CERT_P12_BASE64/APPLE_CERT_P12_PASSWORD), using a fixed identifier so the identitystays constant across versions. The
build/sign-and-packagejobsalready run on
macos-15(matrix-per-app), so no runner change wasneeded.
Two jobs, so the signing key never sees app code
The original single-job design ran the app repo's own build recipe
(
bash -c "$BUILD_CMD") and the Developer ID keychain import in thesame job. On review, that was judged unsafe: the key must never be
importable while app-repo code is running. This is now split:
build(macos-15): clones the app repo, runs its recipe, smoketests the binary, validates the skills dir, and stages the raw
unsigned binary plus a
facts.jsonwritten by trusted workflow code(name/version/tag/repo/sourceSha) from shell variables already
resolved before
BUILD_CMDran, never re-read fromGITHUB_ENVafter. It tars that staging directory (
handoff.tgz) and uploadsthe single tarball as artifact
built-<app>. No keychain or certstep exists in this job at all.
sign-and-package(macos-15, new): no app checkout, no appdependency install, no app-controlled code runs here at all. It
downloads
built-<app>, extracts the tarball, imports the DeveloperID cert, signs, re-verifies, tars the final artifact, and hashes.
The handoff is a tarball rather than a plain directory upload:
upload-artifactstrips the executable bit, drops dotfiles bydefault, and dereferences symlinks, so the binary
sign-and-packagereceived came back non-executable and failed its own post-sign smoke
test. Tarring in
buildand extracting insign-and-packagepreserves modes, dotfiles and symlinks end to end.
release/prnow depend onsign-and-packageinstead ofbuild;their own logic is unchanged (same artifact name
bundle-<app>, sameresult-<app>.jsonshape).Identifier:
com.mattstack.helper.<app>, notcom.mattstack.<app>rt-tray/build.sh'ssign_helper_treere-signs bundled helpers ascom.mattstack.helper.$(basename)when it embeds them intomattstack.app. The raw-artifact distribution channel (
~/.local/bin/deck,deck update) runs these CI-signed bytes directly, without everpassing through build.sh's re-sign. Using the same
com.mattstack.helper.<app>identifier here means both distributionchannels resolve to one stable TCC identity instead of two.
Flags mirror what
build.shalready applies to these samebun-compiled binaries:
--timestamp --options runtimeplus theallow-jit/allow-unsigned-executable-memoryentitlements inscripts/entitlements.plist. deck, board, chat, console and gitq allcarry
"entitlements": "jit"inrt-tray/deps.lockalready, and thecomment in
scripts/entitlements.plistdocuments why theunsigned-executable-memory entitlement is required for a long-running
bun binary under the hardened runtime. That combination is already
proven in production, so hardened runtime does not need to be dropped.
Sign-before-hash ordering
Signing happens in
sign-and-package, in theSign with Developer IDstep, before the
Packagestep'star czf/shasum -a 256thatproduces the value
update-lock.tspins intodeps.lock. So thepublished sha256 always describes the signed bytes, never the
pre-sign artifact.
Gate integrity
steps.signing.outputs.available(a step output) and
inputs.dry_run(a workflow input expression) atthe
if:level of each step, never by testing aGITHUB_ENV-derivedshell variable to branch. With the two-job split, no app code can
reach
sign-and-package's environment at all, but the discipline iskept anyway.
Check signing secretserrors (exit 1) on a real (non-dry-run) publishwith no Developer ID configured.
Sign with Developer IDrequires a non-emptySIGNING_IDENTITYorfails, rather than silently falling through.
Skip signing (dry run only)step is reachable only whensteps.signing.outputs.available != 'true'; it re-assertsinputs.dry_run == trueitself and exits 1 otherwise, rather thantrusting the earlier gate alone.
KEYCHAIN_PATHis written toGITHUB_ENVimmediately aftersecurity create-keychain, soCleanup keychain(if: always())still finds and deletes it if the rest of the import fails partway.
sign-and-packagecarriesif: ${{ !cancelled() }}. Without it, itsimplicit
success()onneeds: [plan, build]meant one failedbuildleg (fail-fast is false) skipped signing for every app whilereleasestill ran and reported green having published nothing.With
!cancelled(), each leg's owndownload-artifact: built-<app>step is what fails, only for the app whose build died,restoring the per-leg independence the
prjob's comment alreadydocuments.
facts.json'stagis now validated withgit check-ref-format "refs/tags/$TAG"inLoad build facts, for parity with theversionandsourceShafields re-validated there.Accepted, not fixed (documented on the sign job's steps)
--versionsmoke still runs the app-produced binaryon the same runner as the unlocked keychain. That is a large
reduction from the pre-split design (the app's own
BUILD_CMDnolonger executes anywhere near the key), and it's the same tier
release.ymlalready accepts for its own sha-pinned helper smokes.token could still poison the tarball between
sign-and-packageandgh release create. That class of attack is pre-existing and isbounded the same way it already was: the release job takes repo,
tag and URL only from the plan job's trusted matrix, and recomputes
the sha256 from the asset it actually uploads.
Verification
dry_runwas already a workflow input that skips therelease/prjobs and their side effects (tag creation, GitHub release, deps.lock
PR); build, sign, and artifact upload run either way. It verifies
signing: after signing, the step re-runs
<artifact> --version(proving the hardened runtime does not break execution) and asserts
codesign -dvvshows both the expected identifier(
com.mattstack.helper.<app>) and aDeveloper ID Applicationauthority, failing the step otherwise.
To run it:
gh workflow run bundle-apps.yml -f apps=deck -f dry_run=true(orapps=all). This builds, signs, verifies, anduploads the
bundle-<app>artifact without dispatching a real releaseor deps.lock PR. Not run from this session since dispatching it mints
tags/releases when
dry_runis left off, and I was told not todispatch the real workflow.
Notes
actionlint .github/workflows/bundle-apps.ymlpasses (it is one ofthe workflows linted in
checks.yml, unlikerelease.ymlwhich isgrandfathered).
scripts/repo-purity.shpasses.bun test scripts/bundle-ci/passes (26/26); no new script logicwas added outside workflow YAML/inline shell, so no new test file
per the repo's TDD convention for scripts.
🤖 Generated with Claude Code