Skip to content

bundle-apps: Developer ID signing for app binaries - #347

Merged
m4ttheweric merged 3 commits into
mainfrom
bundle-apps-devid-signing
Sep 19, 2026
Merged

m4ttheweric merged 3 commits into
mainfrom
bundle-apps-devid-signing

Conversation

@m4ttheweric

@m4ttheweric m4ttheweric commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

.github/workflows/bundle-apps.yml builds deck/board/chat/console/gitq
from 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 all
five 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.yml already imports (APPLE_CERT_P12_BASE64 /
APPLE_CERT_P12_PASSWORD), using a fixed identifier so the identity
stays constant across versions. The build/sign-and-package jobs
already run on macos-15 (matrix-per-app), so no runner change was
needed.

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 the
same 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, smoke
    tests the binary, validates the skills dir, and stages the raw
    unsigned binary plus a facts.json written by trusted workflow code
    (name/version/tag/repo/sourceSha) from shell variables already
    resolved before BUILD_CMD ran, never re-read from GITHUB_ENV
    after. It tars that staging directory (handoff.tgz) and uploads
    the single tarball as artifact built-<app>. No keychain or cert
    step exists in this job at all.
  • sign-and-package (macos-15, new): no app checkout, no app
    dependency install, no app-controlled code runs here at all. It
    downloads built-<app>, extracts the tarball, imports the Developer
    ID cert, signs, re-verifies, tars the final artifact, and hashes.

The handoff is a tarball rather than a plain directory upload:
upload-artifact strips the executable bit, drops dotfiles by
default, and dereferences symlinks, so the binary sign-and-package
received came back non-executable and failed its own post-sign smoke
test. Tarring in build and extracting in sign-and-package
preserves modes, dotfiles and symlinks end to end.

release/pr now depend on sign-and-package instead of build;
their own logic is unchanged (same artifact name bundle-<app>, same
result-<app>.json shape).

Identifier: com.mattstack.helper.<app>, not com.mattstack.<app>

rt-tray/build.sh's sign_helper_tree re-signs bundled helpers as
com.mattstack.helper.$(basename) when it embeds them into
mattstack.app. The raw-artifact distribution channel (~/.local/bin/deck,
deck update) runs these CI-signed bytes directly, without ever
passing through build.sh's re-sign. Using the same
com.mattstack.helper.<app> identifier here means both distribution
channels resolve to one stable TCC identity instead of two.

Flags mirror what build.sh already applies to these same
bun-compiled binaries: --timestamp --options runtime plus the
allow-jit / allow-unsigned-executable-memory entitlements in
scripts/entitlements.plist. deck, board, chat, console and gitq all
carry "entitlements": "jit" in rt-tray/deps.lock already, and the
comment in scripts/entitlements.plist documents why the
unsigned-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 the Sign with Developer ID
step, before the Package step's tar czf / shasum -a 256 that
produces the value update-lock.ts pins into deps.lock. So the
published sha256 always describes the signed bytes, never the
pre-sign artifact.

Gate integrity

  • The sign/skip decision is driven only by steps.signing.outputs.available
    (a step output) and inputs.dry_run (a workflow input expression) at
    the if: level of each step, never by testing a GITHUB_ENV-derived
    shell variable to branch. With the two-job split, no app code can
    reach sign-and-package's environment at all, but the discipline is
    kept anyway.
  • Check signing secrets errors (exit 1) on a real (non-dry-run) publish
    with no Developer ID configured.
  • Sign with Developer ID requires a non-empty SIGNING_IDENTITY or
    fails, rather than silently falling through.
  • The Skip signing (dry run only) step is reachable only when
    steps.signing.outputs.available != 'true'; it re-asserts
    inputs.dry_run == true itself and exits 1 otherwise, rather than
    trusting the earlier gate alone.
  • KEYCHAIN_PATH is written to GITHUB_ENV immediately after
    security create-keychain, so Cleanup keychain (if: always())
    still finds and deletes it if the rest of the import fails partway.
  • sign-and-package carries if: ${{ !cancelled() }}. Without it, its
    implicit success() on needs: [plan, build] meant one failed
    build leg (fail-fast is false) skipped signing for every app while
    release still ran and reported green having published nothing.
    With !cancelled(), each leg's own download-artifact: built-<app> step is what fails, only for the app whose build died,
    restoring the per-leg independence the pr job's comment already
    documents.
  • facts.json's tag is now validated with git check-ref-format "refs/tags/$TAG" in Load build facts, for parity with the
    version and sourceSha fields re-validated there.

Accepted, not fixed (documented on the sign job's steps)

  • The post-sign --version smoke still runs the app-produced binary
    on the same runner as the unlocked keychain. That is a large
    reduction from the pre-split design (the app's own BUILD_CMD no
    longer executes anywhere near the key), and it's the same tier
    release.yml already accepts for its own sha-pinned helper smokes.
  • Nothing at release time re-verifies signedness: a scraped runner
    token could still poison the tarball between sign-and-package and
    gh release create. That class of attack is pre-existing and is
    bounded 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_run was already a workflow input that skips the release/pr
jobs 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 -dvv shows both the expected identifier
(com.mattstack.helper.<app>) and a Developer ID Application
authority, failing the step otherwise.

To run it: gh workflow run bundle-apps.yml -f apps=deck -f dry_run=true (or apps=all). This builds, signs, verifies, and
uploads the bundle-<app> artifact without dispatching a real release
or deps.lock PR. Not run from this session since dispatching it mints
tags/releases when dry_run is left off, and I was told not to
dispatch the real workflow.

Notes

  • actionlint .github/workflows/bundle-apps.yml passes (it is one of
    the workflows linted in checks.yml, unlike release.yml which is
    grandfathered).
  • scripts/repo-purity.sh passes.
  • bun test scripts/bundle-ci/ passes (26/26); no new script logic
    was added outside workflow YAML/inline shell, so no new test file
    per the repo's TDD convention for scripts.

🤖 Generated with Claude Code

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>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f63b99c9-b3c3-4b36-b1fe-d872d1f2fd32

📥 Commits

Reviewing files that changed from the base of the PR and between 0af81f0 and 991f22b.

📒 Files selected for processing (1)
  • .github/workflows/bundle-apps.yml

Comment @coderabbitai help to get the list of available commands.

m4ttheweric and others added 2 commits September 18, 2026 20:38
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>
@m4ttheweric
m4ttheweric merged commit 96843fa into main Sep 19, 2026
4 checks passed
@m4ttheweric
m4ttheweric deleted the bundle-apps-devid-signing branch September 19, 2026 01:51
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