Sign the automated version bump commit in CI #199
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user