diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f467431..512cfaa 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -149,6 +149,50 @@ jobs: { print } ' 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 'release-bot@unsupervised.ca' + # 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 env: TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -157,10 +201,14 @@ jobs: branch="release/bump-${next}" api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" - git config user.name 'Release Bot' - git config user.email 'release-bot@unsupervised.ca' git checkout -b "${branch}" 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}" curl -fsS -X POST "${api}/pulls" \ diff --git a/docs/ci.md b/docs/ci.md index aa45132..96ffe79 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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 `ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test` 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 'release-bot@unsupervised.ca' -f release-bot -N '' + ``` + +2. Add the public half to `app.ini` and restart Gitea: + + ```ini + [repository.signing] + TRUSTED_SSH_KEYS = ssh-ed25519 AAAAC3Nza... release-bot@unsupervised.ca + ``` + +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 ` 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.