Sign the automated version bump commit in CI #16

Merged
thatguygriff merged 1 commits from ci/sign-version-bump-commit into main 2026-09-12 15:03:42 +00:00
Member

Signs the commit the bump job makes after a stable release, so the automated bump pull request can be merged into main now that it requires signed commits. Ports the approach from unsupervised-scheduler#199.

4e45456 Set the working version to 1.2.5 on main is currently %G? = N — that is the commit this is about.

What changed

  • .gitea/workflows/publish.yml — new Configure signing as the release bot step in the bump job. It materialises secrets.RELEASE_BOT_SIGNING_KEY into $RUNNER_TEMP at 0600, derives the .pub beside it, and switches git to SSH signing (gpg.format ssh). The identity that used to be set in the pull request step moved here. The push step then re-reads the commit and refuses to push if there is no gpgsig header.
  • CLAUDE.md — new "Signing the bump commit" section under Publishing, covering the one-time instance setup and its trade-offs. This repo has no docs/ci.md; CLAUDE.md is where the rest of the publish rationale lives.

Setup required before the next release

This is inert — and the job will fail loudly — until both are in place:

  1. [repository.signing] TRUSTED_SSH_KEYS in app.ini lists the release-bot public key, and Gitea has been restarted.
  2. The organisation Actions secret RELEASE_BOT_SIGNING_KEY holds the matching OpenSSH private key.

No release-bot Gitea account is involved: an account key is only used for signature checking after the web Verify flow, which has no API, so TRUSTED_SSH_KEYS is what makes the signature verify without an account lookup. Being an organisation secret, if the scheduler work already set it then step 2 is done and only the TRUSTED_SSH_KEYS entry matters.

One departure from #199

The signature check is grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)" rather than a pipe into grep. This step runs under set -euo pipefail; with a pipe, grep -q exiting early on a match can SIGPIPE git cat-file, and pipefail would then read that as a failed check on a correctly signed commit. The commit object is small enough that it would almost never trip in practice, which is what makes it worth removing rather than living with.

Tests

Both step scripts were extracted from the YAML and run in throwaway repos with GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM pinned to /dev/null:

Secret shape Result
valid ed25519 key signed, committer Release Bot <[email protected]>
same key, CRLF endings signed
same key, trailing newline stripped signed
passphrase-protected key step fails with the intended message
truncated key step fails with the intended message
secret unset step fails with the intended message

Also confirmed that the gpgsig check trips on a deliberately unsigned commit, and that a commit signed this way passes git verify-commit against an allowed-signers file holding the derived public key.

Not verified: whether node:22 carries ssh-keygen — the local Docker socket refused the connection. It should (node:22 derives from buildpack-deps:bookworm, which includes openssh-client), and the step guards for it with one clear line, but it is an assumption until a release exercises it.

No fixture or test-suite change: nothing here touches src/.

🤖 Generated with Claude Code

Signs the commit the `bump` job makes after a stable release, so the automated bump pull request can be merged into `main` now that it requires signed commits. Ports the approach from unsupervised-scheduler#199. `4e45456 Set the working version to 1.2.5` on main is currently `%G? = N` — that is the commit this is about. ## What changed - **`.gitea/workflows/publish.yml`** — new **Configure signing as the release bot** step in the `bump` job. It materialises `secrets.RELEASE_BOT_SIGNING_KEY` into `$RUNNER_TEMP` at 0600, derives the `.pub` beside it, and switches git to SSH signing (`gpg.format ssh`). The identity that used to be set in the pull request step moved here. The push step then re-reads the commit and refuses to push if there is no `gpgsig` header. - **`CLAUDE.md`** — new "Signing the bump commit" section under Publishing, covering the one-time instance setup and its trade-offs. This repo has no `docs/ci.md`; CLAUDE.md is where the rest of the publish rationale lives. ## Setup required before the next release This is inert — and the job will fail loudly — until both are in place: 1. `[repository.signing] TRUSTED_SSH_KEYS` in `app.ini` lists the release-bot public key, and Gitea has been restarted. 2. The organisation Actions secret `RELEASE_BOT_SIGNING_KEY` holds the matching OpenSSH private key. No release-bot Gitea account is involved: an account key is only used for signature checking after the web *Verify* flow, which has no API, so `TRUSTED_SSH_KEYS` is what makes the signature verify without an account lookup. Being an organisation secret, if the scheduler work already set it then step 2 is done and only the `TRUSTED_SSH_KEYS` entry matters. ## One departure from #199 The signature check is `grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"` rather than a pipe into `grep`. This step runs under `set -euo pipefail`; with a pipe, `grep -q` exiting early on a match can `SIGPIPE` `git cat-file`, and `pipefail` would then read that as a failed check on a correctly signed commit. The commit object is small enough that it would almost never trip in practice, which is what makes it worth removing rather than living with. ## Tests Both step scripts were extracted from the YAML and run in throwaway repos with `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` pinned to `/dev/null`: | Secret shape | Result | | --- | --- | | valid ed25519 key | signed, committer `Release Bot <[email protected]>` | | same key, CRLF endings | signed | | same key, trailing newline stripped | signed | | passphrase-protected key | step fails with the intended message | | truncated key | step fails with the intended message | | secret unset | step fails with the intended message | Also confirmed that the `gpgsig` check trips on a deliberately unsigned commit, and that a commit signed this way passes `git verify-commit` against an allowed-signers file holding the derived public key. **Not verified:** whether `node:22` carries `ssh-keygen` — the local Docker socket refused the connection. It should (`node:22` derives from `buildpack-deps:bookworm`, which includes `openssh-client`), and the step guards for it with one clear line, but it is an assumption until a release exercises it. No fixture or test-suite change: nothing here touches `src/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Kydoimos added 1 commit 2026-09-12 14:59:32 +00:00
Sign the automated version bump commit
Publish / Build and push (pull_request) Successful in 3s
Publish / Move the working version on (pull_request) Skipped
CI / Typecheck, test, build (pull_request) Successful in 15s
0a196454d1
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]>
Kydoimos force-pushed ci/sign-version-bump-commit from 51dfade4f9 to 0a196454d1 2026-09-12 14:59:32 +00:00 Compare
thatguygriff merged commit bdc51df25f into main 2026-09-12 15:03:42 +00:00
thatguygriff deleted branch ci/sign-version-bump-commit 2026-09-12 15:03:42 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/antisocial#16