vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: the runner publishes its result as a signed commit
90fdaf397a1120677a1246201369e6dcc82b0aed · Keenan Nemetz · 16d ago · parent 96cbb6fa
Phase 4: the runner publishes its result as a signed commit A finished run's result existed only in data/runs.db and the object store, so a clone carried the commits and the workflow but not the fact that CI passed, who said so, or the log it said it with. And the previous cut of this change had the server commit the record — which is the box vouching for a party, the same mistake as the instance signing key that was deleted. The runner now publishes it. When an attempt ends the runner commits runs/<run id>/<attempt>/ — record.md (repo, ref, sha, workflow blob, job, steps digest, exit, finish time, signer, key) plus an LFS pointer for the log and one per artifact — together with any release pointers the build published, and pushes that once. One push per run: the result and the artifacts it produced are one fact, so a release cannot carry bytes whose build has no record. The commit is signed with the runner's SSH key, and that signature is the whole attestation: it covers the tree, the parents and the message, so the separate claim and signature file are gone, and there is no second statement to keep in step with the files it describes. pre-receive checks it against an allowed-signers file built from the pushing account's keys, so identity comes from the key and not from a name typed into a commit. Who may report is the repository's runner allowlist — the same list that decides who may lease a job — and the record must describe the job the queue handed that account: same run, same attempt, same commit, workflow, job and steps digest. Without that an allowed runner could report a green result for a commit nobody asked it to build, or invent attempt 99 and have the next rebuild believe it. Records are append-only, with no admin override: an edit or a deletion is a rewrite of evidence, and the alternative to refusing is running the job again. runs.db keeps the queue (queued and leased jobs have no record because they have not happened) and mirrors finished runs as derived rows, which vole index rebuild reconstructs from the branch. The attestation row now names the commit instead of storing a claim and a signature. Measured: a real run finished, a bare clone carried runs/<id>/1/record.md, git verify-commit accepted that commit under the roster and refused an unsigned one; a writer who is not a runner was refused by pre-receive naming the allowlist; one CI release commit carried both the record and releases/v1.0.0/tool-linux; deleting runs.db and rebuilding restored the row, the attestation and the log.
docs/roadmap.md +68−11
| @@ -153,7 +153,11 @@ Shipped: | |||
| 153 | 153 | there is no counter to assign. It is a branch rather than a custom ref so that a | |
| 154 | 154 | plain `git clone` carries the issues, and issues share it with change requests | |
| 155 | 155 | because both are human-authored and low-frequency — one push, one fetch, one | |
| 156 | validation path. Machine-written state (CI runs) must not use this ref. | ||
| 156 | validation path. Machine-written state (CI runs) uses this ref as well, since Phase 4 | ||
| 157 | slice 6: the runner commits the finished attempt's record under `runs/` and pushes it | ||
| 158 | with the artifacts the build published, because a result a clone is meant to carry is | ||
| 159 | a result worth committing — and the gate checks who may write that prefix rather than | ||
| 160 | refusing the prefix outright. | ||
| 157 | 161 | - **Branch rules**, enforced in `pre-receive`: only `issues/` and `requests/` may | |
| 158 | 162 | appear in the tree; history must be linear (no merge commits); force-push | |
| 159 | 163 | requires repo admin and is written to the audit log. | |
| @@ -370,10 +374,16 @@ build). Steps are pinned at enqueue into a digest so the claim covers what ran, | |||
| 370 | 374 | merely which workflow file it came from. | |
| 371 | 375 | ||
| 372 | 376 | A signature adds accountability, not truth: a runner can still lie about an exit code, | |
| 373 | and the claim records what it says, attributed to a key somebody is answerable for. | ||
| 374 | What it does buy is that the record is attributable to a party other than the instance, | ||
| 377 | and the record says what it says, attributed to a key somebody is answerable for. What | ||
| 378 | it does buy is that the record is attributable to a party other than the instance, | ||
| 375 | 379 | which no instance-signed record could be. | |
| 376 | 380 | ||
| 381 | **Revised by slice 6:** the separate claim is gone. The runner signs the *commit* that | ||
| 382 | carries its record, so there is one statement instead of two and nothing the server | ||
| 383 | assembles on a runner's behalf. Everything this slice established — the key is the | ||
| 384 | account's, the channel decides who is reporting, an unsigned result is refused — is | ||
| 385 | checked at the push boundary instead of the finish message. | ||
| 386 | |||
| 377 | 387 | ✅ **Slice 5 — only allowed runners green-light a branch.** Signing made a result | |
| 378 | 388 | attributable; this makes the attribution matter. `acl.runners` is an allowlist of | |
| 379 | 389 | accounts whose CI results count for a repository, and the gate's rule is: a green run | |
| @@ -391,21 +401,56 @@ not after it. | |||
| 391 | 401 | following git's way of editing a list (`config --add`, `config --unset-all`, | |
| 392 | 402 | `remote set-url --add`) instead of inventing signs. | |
| 393 | 403 | ||
| 404 | ✅ **Slice 6 — the runner publishes its result, as a signed commit.** A finished | ||
| 405 | attempt is committed to `refs/heads/vole` by the **runner**: | ||
| 406 | `runs/<run id>/<attempt>/record.md` (run, repo, ref, sha, workflow, workflow blob, | ||
| 407 | job, steps digest, exit, finish time, signer, key) plus an LFS pointer for the log and | ||
| 408 | one per published artifact. The commit is signed by the runner's SSH key, and that | ||
| 409 | signature *is* the attestation: it covers the tree, the parents and the message, so | ||
| 410 | there is no second statement to sign and no claim format that can drift from the files | ||
| 411 | it describes. The instance signs nothing and creates nothing on a runner's behalf — | ||
| 412 | the same rule that deleted the instance signing key, applied to results. | ||
| 413 | |||
| 414 | It rides the run's **last push**: the record and any release pointers the build | ||
| 415 | published are one commit, so a run costs one push, and a release cannot carry bytes | ||
| 416 | whose build has no record. Pointer files are the format the sweep already marks and | ||
| 417 | `pre-receive` already validates, so portability cost no GC rule, no new store code, | ||
| 418 | and no new object check — a log the store does not have fails the same push a pointer | ||
| 419 | hygiene check has always failed. | ||
| 420 | |||
| 421 | `pre-receive` decides who may report, and it asks three things of an added record: the | ||
| 422 | pushing account is on the repository's runner allowlist (`acl runners`, admins | ||
| 423 | implicit — the same list that decides who may lease a job), the commit is signed by a | ||
| 424 | key of that account and the record names that key, and the record describes the job | ||
| 425 | the queue handed *that* account — same run, same attempt, same commit, workflow, job | ||
| 426 | and steps digest. The last rule is what stops an allowed runner from reporting a green | ||
| 427 | result for a commit nobody asked it to build, or inventing attempt 99 for a finished | ||
| 428 | run. Records are append-only: an edit or a deletion is refused, with no admin | ||
| 429 | override, because a record that can change is not evidence. | ||
| 430 | |||
| 431 | `runs.db` keeps what has not happened yet (queued and leased jobs, which have no | ||
| 432 | record because they have not happened) and mirrors finished runs as a derived read | ||
| 433 | model — signer, key, commit id, log, artifacts — so a page or a gate needs no tree | ||
| 434 | walk. `vole index rebuild` reconstructs that from the branch, so deleting the queue | ||
| 435 | loses only in-flight work. | ||
| 436 | (Measured through the binary: a real run finished, `git clone --bare` carried | ||
| 437 | `runs/<id>/1/record.md`, and `git verify-commit` against the roster's key accepted the | ||
| 438 | commit while an unsigned one was refused; a writer who is not a runner was refused by | ||
| 439 | `pre-receive` naming the allowlist; `runs.db` deleted, `vole index rebuild` restored | ||
| 440 | the row, the attestation and the log object id.) | ||
| 441 | |||
| 394 | 442 | Remaining: | |
| 395 | 443 | ||
| 396 | 444 | - Push-event queue persisted in SQLite, with at-least-once delivery | |
| 397 | - Run records are not portable: they live in the queue, not on a ref, so a mirror cannot | ||
| 398 | carry them. Not planned unless the instance stops being the only party serving the | ||
| 399 | objects the claim names | ||
| 400 | - Toolchain environment in the claim (the runner's Go version, OS, architecture) — the | ||
| 401 | claim names the job, not the machine | ||
| 402 | - Statuses surfaced on CRs and satisfying required-check gating from Phase 3 | ||
| 445 | - Toolchain environment in the record (the runner's Go version, OS, architecture) — the | ||
| 446 | record names the job, not the machine | ||
| 403 | 447 | ||
| 404 | 448 | **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in | |
| 405 | 449 | the job being requeued and completing on another runner ✅; a workflow that mutates its | |
| 406 | 450 | own definition mid-run still executes the version it was queued with ✅ (steps are pinned | |
| 407 | 451 | at enqueue); a run record can be verified against the SHA it claims ✅ (the runner signs | |
| 408 | the claim, and the stored bytes still verify under the roster afterwards). | ||
| 452 | the commit, `git verify-commit` accepts it under the roster afterwards, and the record | ||
| 453 | is on the branch a plain clone carries, so the check needs no access to the instance). | ||
| 409 | 454 | ||
| 410 | 455 | ### Phase 5 — LFS — ✅ complete (artifact uploads and locking deferred) | |
| 411 | 456 | ||
| @@ -756,6 +801,13 @@ OpenSSH 10.2): | |||
| 756 | 801 | format-version branch to grandfather existing approvals and mandatory signing | |
| 757 | 802 | configuration for every approver. | |
| 758 | 803 | ||
| 804 | One version of this did turn out to be cheap, and it shipped: a **runner-signed | ||
| 805 | commit** carries a run's record (Phase 4 slice 6), because one commit per attempt makes | ||
| 806 | "the commit that last touched `runs/<id>/<attempt>/` is signed by the account it names" | ||
| 807 | a rule that needs no format version and no extra file. The trigger for doing the same | ||
| 808 | for approvals is approval-by-commit-signature, which needs approvers to hold signing | ||
| 809 | keys and a grandfather rule for existing approvals. | ||
| 810 | |||
| 759 | 811 | If this returns, the cheaper shape is instance-signed push attestations plus a | |
| 760 | 812 | `vole verify` that replays the tree-checkable rules from a clone — not mandatory user | |
| 761 | 813 | signatures. | |
| @@ -873,10 +925,15 @@ Filled in as phases land. Append; don't rewrite. | |||
| 873 | 925 | | 2026-10-23 | CLI edits to a list are flags, not signs | The first cut of `acl runners` used `+name` to add and `none` to clear, which is a local dialect: git edits lists with flags (`config --add`, `config --unset-all`, `remote set-url --add`) or with subcommands (`sparse-checkout add`). The sigil appeared because `-name` collides with the flag parser, which is a reason to use flags, not a reason to invent syntax. Names replace the list (matching `acl protect`), `--add`/`--remove` edit, `--clear` empties. The refusal message a push prints quotes the same form, so a copy from the terminal is never stale | | |
| 874 | 926 | | 2026-10-23 | A result counts only if the host allowed the account that reported it | An attributable record still had a hole: any writer who was an admin could green-light their own branch, and an admin of a repository is exactly the party the check is meant to constrain. So the question "whose build do we believe" became its own allowlist — `acl.runners`, with admins implicit because they can rewrite it. It also governs who may lease a job, since running one executes the repository's code. The result from an unlisted account is still recorded and visible; it simply does not count, and the page says so. Deliberately *not* a role: read/write answers "may this account touch the repository", and conflating the two would make every writer trusted about builds | | |
| 875 | 927 | | 2026-10-23 | The instance holds no signing key at all | The instance key existed to sign run records, and runner signing (Phase 4 slice 4) made it pointless. Deleting it is not tidying: an instance-signed anything is a statement that can only be checked by trusting the instance, so a mirror or a third party cannot verify it — trust would sit on the box. With users, runners and approvers each holding a key, trust sits on the parties, and the server becomes a relay that can withhold service but cannot forge a result. `/healthz` no longer reports a key fingerprint, and the host key is the only secret the instance stores | | |
| 876 | | 2026-10-23 | A finished run is signed by the runner that ran it, not by the instance | An instance-key signature is the server witnessing itself: it cannot be checked without trusting the box that produced it, and it says nothing about the party that actually ran the steps. The runner already has a key — the one it connected with — so the claim binds repo, ref, SHA, workflow blob, step digest, exit, log object, artifacts and the runner's own finishing time, and the server verifies it against the account the SSH channel authenticated as. Consequences: the runner must have its private key on disk (nothing signs arbitrary bytes through an agent), an unsigned finish is refused, and "signed" still only means "attributed" — a runner can lie about an exit code. Whether a required check must be attested by a particular runner is the next policy question, deliberately not answered here | | ||
| 928 | | 2026-10-23 | A finished run is signed by the runner that ran it, not by the instance (superseded in part 2026-10-23: the signature is over the commit, not a separate claim) | An instance-key signature is the server witnessing itself: it cannot be checked without trusting the box that produced it, and it says nothing about the party that actually ran the steps. The runner already has a key — the one it connected with — so the claim binds repo, ref, SHA, workflow blob, step digest, exit, log object, artifacts and the runner's own finishing time, and the server verifies it against the account the SSH channel authenticated as. Consequences: the runner must have its private key on disk (nothing signs arbitrary bytes through an agent), an unsigned finish is refused, and "signed" still only means "attributed" — a runner can lie about an exit code. Whether a required check must be attested by a particular runner is the next policy question, deliberately not answered here | | ||
| 877 | 929 | | 2026-10-23 | A run log is uploaded by the runner, one object per attempt; nothing is streamed | Streaming made the server the author of bytes the runner produced: heartbeats shipped chunks, the server concatenated them in a SQLite column and hashed the result, so the archived object's id was the server's, not the writer's — and a signed record could never bind it. A log is now uploaded through the same put-object path as an artifact, and the store is the authority on whether the bytes exist. The cost is a live tail: nothing is visible until the attempt ends, and an attempt that is killed leaves no log at all (the attempt that hung is exactly the log somebody wants). Accepted, because a log that can be shown to be the runner's is worth more than one that arrives early | | |
| 878 | 930 | | 2026-10-23 | A review comment anchors on the blob of the content reviewed, and the page decides whether it is stale | A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse — any unrelated change to the branch would invalidate every anchor. The blob is content-addressed, so a comment stays glued to exactly what was read, current for as long as the file is unchanged, and an anchor that lies can only make itself stale. It also means nothing has to be checked at the push boundary: the page resolves the blob and compares, so a later head never retroactively rejects an old comment | | |
| 879 | 931 | | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit | | |
| 932 | | 2026-10-23 | A finished run's record is committed to `refs/heads/vole` by the runner, one directory per attempt | A result the forge recorded is state a clone should carry, and `refs/heads/*` is what a plain clone fetches — the same reason issues live on a branch rather than a custom ref. The log and artifacts are LFS pointer files, so the sweep marks them by its ordinary walk and `pre-receive` pointer hygiene still applies. The row in `runs.db` becomes a mirror, which is the project's rule that git is authoritative and SQLite is derived — now true of results as well as issues | | ||
| 933 | | 2026-10-23 | The runner signs the commit, and the commit is the attestation | The instance signing anything is the mistake that deleted the instance key: a statement only the box can make is worth nothing to a mirror or a third party. A run record is one commit per attempt, so the commit's SSH signature covers the record's tree (every field and every pointer), the parents and the message — one signature, no separate claim format to keep in step, and nothing the server has to assemble. Verified by `git verify-commit` against an allowed-signers file built from the pushing account's keys, which is also where identity comes from: the principal git reports is the account, not a name typed into a commit | | ||
| 934 | | 2026-10-23 | Only the repository's runner allowlist may report, and only for the attempt it was leased | Whose build the host believes was already the rule for counting a check (`acl runners`, admins implicit); making the same list decide who may *write* a result means a non-runner cannot record one at all, and the allowlist is checked where every other policy is — `pre-receive`. The record must also describe the job the queue handed that account (run, attempt, commit, workflow, job, steps digest), or an allowed runner could report a green result for a commit it was never asked to build, or invent a higher attempt number and have the next rebuild believe it | | ||
| 935 | | 2026-10-23 | A run's record and its release pointers are one commit and one push | The result and the artifacts it produced are one fact; splitting them lets a release carry bytes whose build has no record, and makes a run pay a push per output. Append-only falls out of the same rule: an added record is accepted, an edit or a deletion is a rewrite of evidence with no admin override, since the alternative to refusing is running the job again | | ||
| 936 | | 2026-10-23 | `runs.db` holds the queue; finished runs are rebuilt from the branch | A queued or leased run has no record because it has not happened, and that is exactly what a database is for. Once an attempt finishes, the record is the copy that matters, so `vole index rebuild` reconstructs the row, the attestation and the artifact list from git — the queue stops being the only place a result exists, which is what made run records non-portable | | ||
| 880 | 937 | | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise | | |
| 881 | 938 | ||
| 882 | 939 | ## Open questions | |