22. The release commit is signed by a GitHub App¶
Status¶
Accepted. Narrows 014, which chose gh-ship and left the release credentials as a personal access token.
Context¶
ADR-014 put SHIP_TOKEN — a fine-grained PAT — in a release environment,
because the default GITHUB_TOKEN cannot trigger the CI that should run on the
Release PR. That solved attribution of the push and nothing else. The release
commit itself, the one bumping Cargo.toml and rewriting CHANGELOG.md,
arrived unsigned and attributed to github-actions[bot] by two git config
lines copied from an example.
For a project whose entire subject matter is repository configuration, that is an awkward gap: we can require signed commits of our contributors and not manage one ourselves.
A commit created on a runner is never signed, whichever token pushes it. Git signs with a key, there is no key on a runner, and the token only authenticates the push. Three ways out:
- Import a signing key. Verified as a person or an organisation, no API round trip — at the cost of a durable private key sitting in secrets, which is exactly the kind of long-lived credential ADR-009 says we would rather not hold.
- Let GitHub sign it. GitHub signs a commit it creates itself on behalf of
a bot, but only when the request carries no author, committer or signature of
its own. Adding an identity is what suppresses the signature, so attribution
and verification are mutually exclusive in a single call. The route is
therefore: commit and push as usual, then re-create the tip through
POST /git/commitsand move the branch onto it. gh-ship shipsgh ship signto do precisely this. - Nothing. Keep the unsigned commit.
The second requires the token to belong to a bot. A fine-grained PAT belongs to a person, and the API returns the re-created commit unsigned — so choosing to sign is also choosing to stop using a PAT.
Decision¶
A GitHub App mints an installation token per job, and gh ship sign
re-creates the release commit so GitHub signs it.
APP_CLIENT_ID (a variable) and APP_PRIVATE_KEY (a secret) live in the
release environment. actions/create-github-app-token mints a token at the
start of each job and revokes it at the end; what we store is a private key,
useless without a workflow run to use it in.
SHIP_TOKEN is gone. publish-release does not dispatch workflows and does not
push commits needing CI, so it drops to the default token rather than being
migrated.
At the same time, the deprecated ship_id correlation nonce is removed from
both dispatched workflows. gh-ship now correlates a dispatch on the ref, the
event type and run novelty, so neither the input nor the run-name stamping it
is used any more.
Consequences¶
- The release commit carries Verified. Its committer is
GitHuband its author the App's bot user — neither is ours to choose, because choosing is what stops GitHub signing. Thegit config user.*lines stay only because a runner has no git identity andgit commitrefuses without one; the identity they set is discarded with the commit it produces. - Only the tip is re-created; parents keep their SHAs. The prepare workflow makes exactly one commit, so this is total for us, and would silently sign only the last if that ever changed.
gh ship signexits non-zero when the re-created commit comes back unsigned, which is what a non-bot token produces. A misconfigured App fails the prepare loudly instead of quietly shipping an unsigned release.- Installation tokens expire after one hour.
gh ship prepareandgh ship releaseboth block on a dispatched run for up to 60 minutes, so every job minting a token capstimeout-minutesbelow that — the release job moved from 60 to 50 for this reason alone. - The App token cannot go in a job-level
env:. It is a step output, and the job'senv:is evaluated before any step runs, soGH_TOKENthere is the empty string andghfalls back to the default token without complaining. Every step runningghsets it itself, which is more repetition than the previous shape and is not an accident. environment: releasebecomes load-bearing beyond gating: an environment variable is invisible to a job that does not declare its environment, so omitting it makesvars.APP_CLIENT_IDexpand to nothing and the mint step fail obscurely.- Setting up the App is a manual prerequisite, and its installation permissions
are fixed when it is installed — adding a permission to the App later does
not grant it to an existing installation until an administrator approves.
It needs
metadata:read,contents:write,actions:write,pull_requests:write,issues:write, andworkflows:write, the last because a release commit here can touch.github/workflows/.