Sign the automated version bump commit
Publish / Build and push (pull_request) Successful in 3s
Publish / Move the working version on (pull_request) Skipped
CI / Typecheck, test, build (pull_request) Successful in 15s

main requires signed commits, so the pull request the bump job opens after a
release cannot be merged while the commit in it is unsigned. The key the server
signs merge commits with is not reachable from a runner, so the job signs with a
dedicated release-bot SSH key that the instance trusts through
TRUSTED_SSH_KEYS — no bot account, because an account key is only consulted
after the web Verify flow and that flow has no API.

Inert until the key is trusted and RELEASE_BOT_SIGNING_KEY is set, and loudly so:
the step checks the secret and ssh-keygen before it starts, runs the key through
ssh-keygen -y so a truncated or re-wrapped one is caught as itself rather than as
"gpg failed to sign the data", and the commit is re-read for a gpgsig header
before it is pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 11:58:47 -03:00
co-authored by Claude Opus 5
parent 4f60367e2f
commit 0a196454d1
2 changed files with 96 additions and 5 deletions
+36
View File
@@ -279,6 +279,42 @@ image just published. The job is the only one that runs in a container (`node:22
npm), and a job in a container is handed `sh`, not bash — hence the explicit
`shell: bash`, without which `set -o pipefail` fails the first line of the first step.
### Signing the bump commit
main requires signed commits, and a pull request carrying an unsigned one cannot be
merged — so the bump job signs the commit it makes. Not with the key the server signs
merge commits with: that one lives on the server and no runner can reach it. It uses a
dedicated release-bot SSH key instead, which also means it can be rotated on its own if
the secret ever leaks.
There is deliberately no release-bot account. A key attached to an account is only
consulted for signature checking once it has been through the web *Verify* flow, and
that flow has no API, so a bot account would need an interactive login to be worth
anything. Listing the key under `[repository.signing] TRUSTED_SSH_KEYS` instead makes
the signature verify with no account lookup at all, which is all the protected branch
asks for. `[email protected]` is therefore a label and not an identity, and
the signature is attributed to the instance's `SIGNING_NAME`/`SIGNING_EMAIL` rather
than to it. The other side of trusting a key instance-wide: a commit signed with it
verifies in *every* repository on that instance, because the trust is in the key and not
in a user whose permissions you could scope.
Set up once per instance, and again only on rotation:
1. Generate a passphrase-less key — it has to be usable unattended:
`ssh-keygen -t ed25519 -C release-bot -f release-bot -N ''`.
2. Add the public half to `TRUSTED_SSH_KEYS` in the server config and restart.
3. Store the private half as the `RELEASE_BOT_SIGNING_KEY` Actions secret — the whole
file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` and footer included, not the
`.pub` and not a GPG export. An organisation secret covers every repository at once.
Delete both local files afterwards.
Until both are in place the bump job fails, loudly and on purpose: it checks the secret
is set and that `ssh-keygen` exists before it starts, feeds the key through
`ssh-keygen -y` so a truncated or re-wrapped one is caught as itself rather than as
"gpg failed to sign the data", and re-reads the commit for a `gpgsig` header before
pushing. Nothing else in the pipeline signs anything — release tags are made by hand,
and the merge commit is signed by the server.
Two things any deployment has to get right, both learned the hard way:
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a