Sign the automated version bump commit
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:
@@ -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" </dev/null > "${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 '[email protected]'
|
||||
# `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 '[email protected]'
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user