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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 4: the runner signs the result it reports

22528ac3da576387a8311969641a7b5783b04924 · Keenan Nemetz · 17d ago · parent 86fe5bb4

Phase 4: the runner signs the result it reports

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
ran the steps — so a record signed that way would have been paperwork. The runner
already holds a key, the one it connects with, so the runner is what signs.

At finish the runner signs a claim: run, repo, ref, SHA, workflow blob, the digest of
the steps pinned when the job was queued, exit code, the log's {oid, size}, the
artifacts it published, and its own finishing time. The server rebuilds the same claim
from its own row plus what the runner reports, verifies the signature, and stores the
payload and signature verbatim — so a verifier re-checks the bytes that were signed
rather than re-deriving them from a format that might have drifted.

Who the signer is comes from the channel, not the claim: the account is the one the
SSH key resolved to, and the signature must verify under one of that account's keys.
A runner may therefore sign with a different key from the one it connected with — an
agent-held key to connect, a file to sign — but not as somebody else.

Refusals, all before anything is recorded, because half a record is worse than none:
an unsigned finish (an unattributed result is what the queue already had), a signature
by another account's key, and a signature over a different claim. The run page shows
`attested by <name> · key <fingerprint>`, and `vole runner` now pings first and refuses
to start work when the key it will sign with is not one its account holds — discovering
that after a ten-minute build is a wasted build.

`steps_sha` is recorded at enqueue and travels with the lease, so the claim covers the
steps that ran and not merely the workflow file they came from.

What this does not buy: truth. A runner can still lie about an exit code. It buys
accountability — the record is attributable to a party other than the instance.
Whether a required check must be attested by a particular runner (an ACL key defaulting
to the instance's own) is the next policy question and is deliberately not answered
here; any admin of the repository may still supply the signature.

Verified: TestAFinishedRunBelongsToTheRunnerThatSignsIt drives the real protocol over
SSH — the lease carries what the claim names, ping names the account and its keys,
unsigned and wrong-account and wrong-claim finishes are all refused with nothing
recorded, the correct one is accepted, and the stored payload still verifies under the
stored signature; TestClaimRendersOneWay pins the byte form (including artifact
ordering and an empty log) and TestClaimRefusesToForgeItself refuses a field carrying a
newline; TestStepDigestFollowsTheSteps shows the digest covering the steps themselves;
the cmd tests cover key discovery from VOLE_SSH_COMMAND/GIT_SSH_COMMAND, the missing-key
message, a non-key file, and the pre-work refusal; and the end-to-end test that runs the
real binary asserts the claim names the run, its log and its exit, and that the run page
says who attested it. scripts/ci.sh green (gofmt, vet, build, markdown links, go test
-race ./...).

docs/roadmap.md +35−11

@@ -81,7 +81,9 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust
81 81 - ACL v1: owner / write / read, stored on `refs/vole/acl`
82 82 - `pre-receive` + `update` hooks enforcing ACLs, protected branches, and object size limits
83 83 - Web UI: repo list, tree/blob browser, raw view, commit log
84 - Instance signing key generated at first boot and persisted (used from Phase 7)
84 - Instance signing key generated at first boot and persisted. It no longer signs run
85 records — those are signed by the runner that ran them (Phase 4 slice 4) — so its
86 remaining job is naming the instance at `/health` and on boot
85 87 - `vole index rebuild` implemented early and exercised by tests, even though the index has
86 88 little to hold yet
87 89
@@ -350,24 +352,45 @@ repository with no workflows is unaffected). The gate evaluates the approved hea
350 352 than a merge commit, since CI runs on what was reviewed. Runs are shown on the repository
351 353 page and on each change request.
352 354
355 ✅ **Slice 4 — the runner signs its result.** The party that did the work signs it: at
356 finish the runner signs a claim naming the run, the repository, the ref, the SHA, the
357 workflow blob, the job, the digest of the pinned steps, the exit code, the log's
358 `{oid, size}`, the artifacts it published and its own finishing time, and the server
359 records the claim and the signature verbatim. The signer is fixed by the channel, not
360 by the claim: the account comes from the SSH key that opened the connection, and the
361 signature must verify under one of *that* account's keys, so a runner can sign with a
362 different key from the one it connected with but not as somebody else. An unsigned
363 finish, a signature by another account's key and a signature over a different claim
364 are all refused, and nothing is recorded before the claim verifies — half a record is
365 worse than none. The run page says `attested by <name> · key <fingerprint>`, and
366 `vole runner` pings first and refuses to start work if the key it is about to sign
367 with is not one its account holds (learning that after a ten-minute build is a wasted
368 build). Steps are pinned at enqueue into a digest so the claim covers what ran, not
369 merely which workflow file it came from.
370
371 A signature adds accountability, not truth: a runner can still lie about an exit code,
372 and the claim records what it says, attributed to a key somebody is answerable for.
373 What it does buy is that the record is attributable to a party other than the instance,
374 which no instance-signed record could be.
375
353 376 Remaining:
354 377
355 - Run records committed to a ref and signed by the instance key (Phase 7)
356 378 - Push-event queue persisted in SQLite, with at-least-once delivery
357 - Runner protocol: `vole runner` registers, long-polls for work, leases a job, heartbeats;
358 an expired lease requeues the job
359 - Job execution: checkout by exact SHA from the server, sequential shell steps, per-step
360 timeout, logs uploaded to the content-addressed store when the attempt ends
361 - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key
379 - Whether a required check must be *attested*, and by whom (`vole acl runners`): today a
380 signature is required but any admin of the repository may supply one, so "CI passed"
381 and "an account says CI passed" are still the same sentence
382 - Run records are not portable: they live in the queue, not on a ref, so a mirror cannot
383 carry them. Not planned unless the instance stops being the only party serving the
384 objects the claim names
385 - Toolchain environment in the claim (the runner's Go version, OS, architecture) — the
386 claim names the job, not the machine
362 387 - Statuses surfaced on CRs and satisfying required-check gating from Phase 3
363 - Log and artifact storage onto the content-addressed store (shared with Phase 5).
364 Logs moved in Phase 5 slice 4; workflow artifacts landed in Phase 7 slice 6
365 388
366 389 **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in
367 390 the job being requeued and completing on another runner ✅; a workflow that mutates its
368 391 own definition mid-run still executes the version it was queued with ✅ (steps are pinned
369 at enqueue); a run record can be verified against the SHA it claims — the record carries
370 it, but signing is Phase 7.
392 at enqueue); a run record can be verified against the SHA it claims ✅ (the runner signs
393 the claim, and the stored bytes still verify under the roster afterwards).
371 394
372 395 ### Phase 5 — LFS — ✅ complete (artifact uploads and locking deferred)
373 396
@@ -832,6 +855,7 @@ Filled in as phases land. Append; don't rewrite.
832 855 | 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 |
833 856 | 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 |
834 857 | 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 |
858 | 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 |
835 859 | 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 |
836 860 | 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 |
837 861 | 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 |