From 0a196454d1fb5662398725d5a080692a19319aba Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Sat, 5 Sep 2026 20:30:09 -0300 Subject: [PATCH] Sign the automated version bump commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/publish.yml | 65 +++++++++++++++++++++++++++++++++--- CLAUDE.md | 36 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index d157095..9e29711 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -13,6 +13,7 @@ name: Publish # vars.IMAGE_NAME optional, defaults to this repository's owner/name # vars.REGISTRY_USER optional, defaults to the actor running the workflow # secrets.REGISTRY_TOKEN required to push +# secrets.RELEASE_BOT_SIGNING_KEY required to sign the version bump commit # # Point REGISTRY at a host the runner reaches directly, without an intermediate # proxy that caps request bodies: a browser image has layers well over 100MB, @@ -218,6 +219,56 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" echo "package.json ${current} -> ${NEXT}" + # main requires signed commits, and a pull request carrying an unsigned + # one cannot be merged. The key the server signs merge commits with lives + # on the server and no runner can reach it, so the bump commit is signed + # here with a dedicated key the instance trusts through + # `[repository.signing] TRUSTED_SSH_KEYS`. Setting that up is in + # CLAUDE.md; nothing about it is committed here. + - name: Configure signing as the release bot + if: steps.bump.outputs.changed == 'true' + env: + SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }} + run: | + set -euo pipefail + + if [ -z "${SIGNING_KEY}" ]; then + echo "RELEASE_BOT_SIGNING_KEY is not set; the bump commit would be unsigned and unmergeable." >&2 + exit 1 + fi + if ! command -v ssh-keygen >/dev/null; then + echo "ssh-keygen is missing from this image; git cannot make SSH signatures without it." >&2 + exit 1 + fi + + # The secret holds an OpenSSH private key. git signs by shelling out + # to ssh-keygen, which wants the key on disk beside the `.pub` it is + # pointed at, readable only by us, and rejects it unless the trailing + # newline survived the round trip through the secret store. + keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing" + install -m 700 -d "${keydir}" + printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key" + chmod 600 "${keydir}/key" + + # Doubles as a format check: a truncated, re-wrapped or + # passphrase-protected key fails here rather than as "gpg failed to + # sign the data" three steps later. + if ! ssh-keygen -y -f "${keydir}/key" "${keydir}/key.pub"; then + echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2 + exit 1 + fi + + # A name that is not a person, and an address no account backs: the + # signature verifies against the trusted key rather than against a + # user, so this is a label on the commit and not an identity. + git config user.name 'Release Bot' + git config user.email 'release-bot@unsupervised.ca' + # `gpg.format` is the historical name; `ssh` is what switches git to + # signing with the key above rather than with a GPG key. + git config gpg.format ssh + git config user.signingkey "${keydir}/key.pub" + git config commit.gpgsign true + # The bump arrives as a pull request rather than as a commit straight to # main. Pushing a branch asks nothing of the task token beyond ordinary # write access, so nothing here depends on being allowed past whatever @@ -236,14 +287,18 @@ jobs: branch="release/bump-${NEXT}" - # A name that is not a person, and a reserved address that can never - # resolve to one. Nothing here names the instance it runs on. - git config user.name 'Release bot' - git config user.email 'release-bot@noreply.invalid' - git checkout -b "${branch}" git add package.json package-lock.json git commit -m "Set the working version to ${NEXT}" + + # An unsigned commit would go unnoticed until someone tried to merge + # the pull request, so it fails here instead, where the cause is in + # front of you. + if ! grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"; then + echo "The bump commit came out unsigned; refusing to push it." >&2 + exit 1 + fi + git push origin "${branch}" # node rather than jq to build the request body: jq is not in this diff --git a/CLAUDE.md b/CLAUDE.md index ee7ca03..718374d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. `release-bot@unsupervised.ca` 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