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.

README.md +14−10

@@ -365,16 +365,20 @@ server wrote, and the runner is the party that can be held to it. `/r/<repo>/run
365 365 serves it, `?attempt=N` picks one attempt out of a retried job, and an attempt whose
366 366 runner was killed has no log at all.
367 367
368 A runner signs every result it reports. The signature covers what was run and what
369 came of it — repo, ref, SHA, the workflow blob, the digest of the pinned steps, the
370 exit code, the log's object id and the artifacts — and the claim is stored as the
371 bytes that were signed, so it can be checked later and not merely believed. The
372 signing key must belong to the account the connection authenticated as: the SSH
373 channel decides who the runner is, and a runner may sign with a different key from
374 the one it connected with but not as somebody else. `vole runner --key` defaults to
375 the `-i` of `$VOLE_SSH_COMMAND`/`$GIT_SSH_COMMAND`, else `~/.ssh/id_ed25519`, and the
376 runner refuses to start if that key is not one its account holds. The run page names
377 the account and the key.
368 A runner publishes every result it reports, as a signed commit. When an attempt ends
369 the runner commits `runs/<run id>/<attempt>/` — the record (repo, ref, SHA, the
370 workflow blob, the digest of the pinned steps, the exit code, the finish time) plus a
371 pointer to the log and one per artifact — together with any release pointers the build
372 published, and pushes that once. `git commit -S` signs it with the runner's key, so the
373 signature covers the record and the objects it names, and the push is what
374 `pre-receive` checks: the account must be on the repository's runner allowlist
375 (`vole acl runners`), the commit must be signed by that account's key, and the record
376 must describe the job that account was leased. The server writes none of it — a record
377 it created would be the box vouching for a party — so nothing here can be forged by the
378 instance, and a clone can verify the result without asking it. `vole runner --key`
379 defaults to the `-i` of `$VOLE_SSH_COMMAND`/`$GIT_SSH_COMMAND`, else
380 `~/.ssh/id_ed25519`, and the runner refuses to start if that key is not one its account
381 holds. The run page names the account, the key and the commit.
378 382
379 383 What a signature does not buy is truth: a runner can still lie about an exit code.
380 384 It buys accountability — the record is attributable to a party other than the