Merge pull request 'Sign the automated version bump commit in CI' (#199) from ci/sign-version-bump-commit into main
CI / Coding Standards (push) Successful in 30s
CI / Tests (PHP 8.5) (push) Successful in 27s
CI / Tests (PHP 8.3) (push) Successful in 34s
CI / No Debug Code (push) Successful in 6s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Build Plugin Zip (push) Successful in 23s
CI / Coding Standards (push) Successful in 30s
CI / Tests (PHP 8.5) (push) Successful in 27s
CI / Tests (PHP 8.3) (push) Successful in 34s
CI / No Debug Code (push) Successful in 6s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Build Plugin Zip (push) Successful in 23s
Reviewed-on: #199 Reviewed-by: James Griffin-Allwood <[email protected]>
This commit was merged in pull request #199.
This commit is contained in:
@@ -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 '[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
|
||||
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 '[email protected]'
|
||||
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" \
|
||||
|
||||
+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
|
||||
`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 '[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