Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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