Sign the automated version bump commit in CI #199

Merged
thatguygriff merged 1 commits from ci/sign-version-bump-commit into main 2026-09-05 23:29:51 +00:00
2 changed files with 106 additions and 2 deletions
+50 -2
View File
@@ -149,6 +149,50 @@ jobs:
{ print } { print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md ' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# main requires signed commits, and Gitea refuses to merge a pull request
# that carries an unsigned one. The key Gitea signs merge commits with
# lives on the server and is not reachable from a runner, so the bump
# commit is signed here with a dedicated release-bot key that the instance
# trusts via TRUSTED_SSH_KEYS. Generating that key, trusting it and storing
# the secret is documented in docs/ci.md.
- name: Configure signing as Release Bot
env:
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
run: |
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 the runner image; git cannot make SSH signatures without it." >&2
exit 1
fi
# The secret holds an OpenSSH private key ("-----BEGIN OPENSSH PRIVATE
# KEY-----"). git signs by shelling out to ssh-keygen, which wants that
# key on disk next to the .pub it is pointed at, readable only by us,
# and rejects it unless the final newline survived the round trip.
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 or re-wrapped key fails here,
# with a clearer cause than "gpg failed to sign the data" later on.
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
# No Gitea account backs this address; TRUSTED_SSH_KEYS verifies the
# signature without an account lookup, so it is a label, not an identity.
git config user.name 'Release Bot'
git config user.email '[email protected]'
# Named gpg.format for historical reasons; "ssh" is what switches git
# over to signing with the SSH key above rather than a GPG key.
git config gpg.format ssh
git config user.signingkey "${keydir}/key.pub"
git config commit.gpgsign true
- name: Open pull request - name: Open pull request
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -157,10 +201,14 @@ jobs:
branch="release/bump-${next}" branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
git config user.name 'Release Bot'
git config user.email '[email protected]'
git checkout -b "${branch}" git checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section" git commit -am "Bump version to ${next} and open changelog section"
# A commit that came out unsigned would otherwise go unnoticed until
# someone tried to merge the PR, so fail here instead.
if ! git cat-file commit HEAD | grep -q '^gpgsig'; then
echo "Bump commit is unsigned; refusing to push it." >&2
exit 1
fi
git push origin "${branch}" git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \ curl -fsS -X POST "${api}/pulls" \
+56
View File
@@ -43,3 +43,59 @@ The Composer download cache lives at `/composer/cache` — `COMPOSER_HOME` is
The image has to exist first. Add the version to the `php` matrix in The image has to exist first. Add the version to the `php` matrix in
`ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test` `ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
matrix in `.gitea/workflows/ci.yml` here. matrix in `.gitea/workflows/ci.yml` here.
## Signing the version bump commit
`main` is a protected branch that requires signed commits, and Gitea will not
merge a pull request containing an unsigned one. The `bump-version` job in
`release.yml` therefore signs the commit it makes, using a dedicated
`release-bot` SSH key rather than the key Gitea signs merge commits with —
that one is `[repository.signing] SIGNING_KEY` on the server and no runner can
reach it. Keeping the CI key separate also means it can be rotated on its own
if the secret ever leaks.
There is deliberately no `release-bot` Gitea account. A key attached to an
account is only consulted for signature checking after it has been through the
web *Verify* flow, and that flow has no API — a bot account would need an
interactive login to be worth anything. Listing the key under
`TRUSTED_SSH_KEYS` instead makes Gitea verify commits signed with it without
any account lookup, which is all the protected branch asks for.
Set up once for the instance, and again only if the key is rotated:
1. Generate a passphrase-less key (it has to be usable unattended):
```
ssh-keygen -t ed25519 -C '[email protected]' -f release-bot -N ''
```
2. Add the public half to `app.ini` and restart Gitea:
```ini
[repository.signing]
TRUSTED_SSH_KEYS = ssh-ed25519 AAAAC3Nza... [email protected]
```
3. Store the private half as the **organisation** Actions secret
`RELEASE_BOT_SIGNING_KEY` (Org → Settings → Actions → Secrets): the whole
`release-bot` file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` header
and footer included — not the `.pub`, and not a GPG export. Organisation
secrets are readable as `secrets.RELEASE_BOT_SIGNING_KEY` from every
repository in the org, so no repository-level copy is needed. Delete both
local files afterwards.
Two consequences of trusting the key instance-wide are worth knowing. Any
commit signed with it verifies in *every* repository on the instance, not just
these — the trust is in the key, not in a user with permissions you can scope.
And the signature is attributed to `SIGNING_NAME` / `SIGNING_EMAIL`, not to the
`Release Bot <release-bot@unsupervised.ca>` committer the job sets; that
address backs no account and is only a label.
The job fails fast if the secret is missing or `ssh-keygen` is absent from the
runner image, and it re-reads the commit it just made to confirm a signature
is attached before pushing — an unsigned bump commit would otherwise look fine
until someone tried to merge the PR.
Nothing else in the pipeline signs anything: release tags are made by a human
through Gitea's release UI, and the merge commit is signed by the server when
the PR is merged.