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:
[repository.signing] TRUSTED_SSH_KEYS in app.ini lists the release-bot public key, and Gitea has been restarted.
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.
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 SIGPIPEgit 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:
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/.
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)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Signs the commit the
bumpjob makes after a stable release, so the automated bump pull request can be merged intomainnow that it requires signed commits. Ports the approach from unsupervised-scheduler#199.4e45456 Set the working version to 1.2.5on 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 thebumpjob. It materialisessecrets.RELEASE_BOT_SIGNING_KEYinto$RUNNER_TEMPat 0600, derives the.pubbeside 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 nogpgsigheader.CLAUDE.md— new "Signing the bump commit" section under Publishing, covering the one-time instance setup and its trade-offs. This repo has nodocs/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:
[repository.signing] TRUSTED_SSH_KEYSinapp.inilists the release-bot public key, and Gitea has been restarted.RELEASE_BOT_SIGNING_KEYholds 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_KEYSis 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 theTRUSTED_SSH_KEYSentry matters.One departure from #199
The signature check is
grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"rather than a pipe intogrep. This step runs underset -euo pipefail; with a pipe,grep -qexiting early on a match canSIGPIPEgit cat-file, andpipefailwould 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_SYSTEMpinned to/dev/null:Release Bot <[email protected]>Also confirmed that the
gpgsigcheck trips on a deliberately unsigned commit, and that a commit signed this way passesgit verify-commitagainst an allowed-signers file holding the derived public key.Not verified: whether
node:22carriesssh-keygen— the local Docker socket refused the connection. It should (node:22derives frombuildpack-deps:bookworm, which includesopenssh-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
51dfade4f9to0a196454d1