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: only allowed runners green-light a branch
852c067d5fb8226a46574c82077c2fc10bba2eb4 · Keenan Nemetz · 17d ago · parent 5d6db024
Phase 4: only allowed runners green-light a branch Signing made a result attributable; this makes the attribution matter. A required check is a claim about a commit, and until now any admin of the repository could satisfy it — which is exactly the party the check exists to constrain. `acl.runners` is an allowlist of the accounts whose CI results count for a repository, and the gate's rule is: a green run counts only when its attestation names an account on that list. Admins are implicit, because they can rewrite the list and a protected branch already lets them land anything with no checks at all — so the list is about everybody else. A writer running CI on their laptop gets their result recorded and not counted, and the run page says which, so the answer to "why is my green run not gating?" is where somebody looks for it rather than only in a refusal. It is deliberately not a role. Read/write answers "may this account touch the repository"; this answers "may this account's build be believed about it", and conflating them would make every writer trusted about builds. The same list decides who may lease a job, since running one executes the repository's code: the answer arrives before the build rather than after it. That also loosens the old lease rule, which required admin — a named runner needs no write access at all. `vole acl runners <repo>` shows the list, bare names replace it, `+name` adds, and `none` clears it. There is no `-name`: revoking trust is the operation where being made to state the whole list is a feature, and the list is printed after every change so a dropped name is visible at once. Fixed on the way: the new "skip non-successful runs" test would have dereferenced a nil run for the no-run case, which TestMissingRunBlocksWithAHint caught as a panic in the hook rather than a message. Verified: TestOnlyAllowedRunnersGreenLightABranch walks the whole rule — an unlisted writer is given no work, and the run page and the refusal both explain it; naming them a runner lets them lease, run and report (attested to them); revoking the runner refuses the merge naming the account and the command that would allow it, with the run page saying "does not count as a check"; allowing them again lets the same green result land, with nothing else changed. TestACLRunnersEditsTheList covers show, +add, replacement and the unknown-account refusal. internal/acl tests pin the rule itself: admins implicit, listed accounts count, read-only accounts do not, and an empty account never does; plus list normalisation and the round trip through the ref it is stored on. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
docs/roadmap.md +17−3
| @@ -374,12 +374,25 @@ and the claim records what it says, attributed to a key somebody is answerable f | |||
| 374 | 374 | What it does buy is that the record is attributable to a party other than the instance, | |
| 375 | 375 | which no instance-signed record could be. | |
| 376 | 376 | ||
| 377 | ✅ **Slice 5 — only allowed runners green-light a branch.** Signing made a result | ||
| 378 | attributable; this makes the attribution matter. `acl.runners` is an allowlist of | ||
| 379 | accounts whose CI results count for a repository, and the gate's rule is: a green run | ||
| 380 | counts only when its attestation names an account on that list. Admins are implicit — | ||
| 381 | they can rewrite the list, and a protected branch already lets them land anything with | ||
| 382 | no checks at all, so restricting them here would be theatre. Everyone else, a writer | ||
| 383 | running CI on their laptop included, gets their result recorded and *not* counted, and | ||
| 384 | the run page says which: `counts as a check` or `does not count as a check: wilbur is | ||
| 385 | not a runner for this repository`. The same list decides who may lease a job, since | ||
| 386 | running a job executes the repository's code — so the answer arrives before the build, | ||
| 387 | not after it. | ||
| 388 | |||
| 389 | `vole acl runners <repo>` shows the list, bare names replace it, `+name` adds, and | ||
| 390 | `none` clears it. There is no `-name`: revoking trust is the operation where being made | ||
| 391 | to state the whole list is a feature, and the list is printed after every change. | ||
| 392 | |||
| 377 | 393 | Remaining: | |
| 378 | 394 | ||
| 379 | 395 | - Push-event queue persisted in SQLite, with at-least-once delivery | |
| 380 | - Whether a required check must be *attested*, and by whom (`vole acl runners`): today a | ||
| 381 | signature is required but any admin of the repository may supply one, so "CI passed" | ||
| 382 | and "an account says CI passed" are still the same sentence | ||
| 383 | 396 | - Run records are not portable: they live in the queue, not on a ref, so a mirror cannot | |
| 384 | 397 | carry them. Not planned unless the instance stops being the only party serving the | |
| 385 | 398 | objects the claim names | |
| @@ -856,6 +869,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 856 | 869 | | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently | | |
| 857 | 870 | | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path | | |
| 858 | 871 | | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before | | |
| 872 | | 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 | | ||
| 859 | 873 | | 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 | | |
| 860 | 874 | | 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 | | |
| 861 | 875 | | 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 | | |