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 uploads the log, so the server cannot write it
86fe5bb49ddeeb89a5d58459f27a86a83f82481a · Keenan Nemetz · 17d ago · parent 9cc2f087
Phase 4: the runner uploads the log, so the server cannot write it Streaming made the server the author of bytes the runner produced. Heartbeats carried output chunks; the server concatenated them into a SQLite column and, at finish, hashed the result itself with `LFS.PutBytes`. So the archived object's id was the server's, not the writer's — the one output in the system that worked that way, since artifacts are uploaded by the runner through put-object and pages/releases are pushed by it. Nothing depended on it, which is why it was harmless; a signed run record could never have bound it, which is why it had to go. Streaming is dropped. At finish the runner uploads its log through the same git-lfs-transfer path artifacts use, gets the object id the store computed from the content, and names it in the finish message. The server's rule is the artifact rule: the store is the authority on whether the bytes exist, and a claimed size that is not the object's size is refused. Heartbeats now carry nothing but the lease. A log is per attempt, not per run. A job that kills a runner is requeued, so one run can have several logs, and the interesting one is usually the attempt that died: `run_logs(run_id, attempt, oid, size)` replaces the single column, `AttemptLogs` reads them oldest first, `/runs/<id>/log` serves the newest and `?attempt=N` picks one, and `vole gc` treats every attempt's object as a root. Rows written before this change still have their `log_oid`, which reads keep honouring; `Store.Log` and `SetLogOID` are gone with the code that used them. The cost, stated plainly: there is no live tail any more, and an attempt whose runner was killed leaves no log at all — the attempt that hung is exactly the log somebody wants. That is the trade: a log that can be shown to be the runner's is worth more than one that arrives early. It also removes an unbounded text column from the queue, which was growing into the WAL and every backup until a run finished. Verified: TestCheckRunLogTrustsTheStore pins the new rule at the boundary (stored log accepted, size mismatch refused, object absent refused, non-oid refused, no store refused); TestLogsAreRecordedPerAttempt shows two attempts recorded separately, `Get` returning the newest, both objects named for the sweep, and a finished run refusing a log; TestHeartbeatAndFinishNeedTheLease now proves a stranger can neither heartbeat, nor finish, nor record a log, and that a finished run carries no log text; and the end-to-end test that runs the real binary now asserts the served log hashes to the object id in the run row and that the queue kept no copy of it. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
docs/roadmap.md +9−6
| @@ -340,8 +340,9 @@ update; the server ingests them into `data/runs.db` (unique on repo+ref+sha+work | |||
| 340 | 340 | so re-reading the spool never double-queues) and pins each run to the commit and the | |
| 341 | 341 | workflow blob it came from. `vole runner` connects over SSH with the user's key — no | |
| 342 | 342 | token — long-polls for a lease, checks the commit out, runs the steps with per-step | |
| 343 | timeouts, heartbeats while it works and reports the exit code and log. A lease that | ||
| 344 | expires returns the job to the queue; a job that kills five runners is failed. | ||
| 343 | timeouts, heartbeats while it works, uploads the attempt's log to the object store | ||
| 344 | and reports the exit code and the log's object id. A lease that expires returns the | ||
| 345 | job to the queue; a job that kills five runners is failed. | ||
| 345 | 346 | ||
| 346 | 347 | ✅ **Slice 3 — checks on the gate.** A protected branch now needs green checks for the | |
| 347 | 348 | approved head as well as approvals (`vole acl checks <repo> on|off`, default on; a | |
| @@ -356,7 +357,7 @@ Remaining: | |||
| 356 | 357 | - Runner protocol: `vole runner` registers, long-polls for work, leases a job, heartbeats; | |
| 357 | 358 | an expired lease requeues the job | |
| 358 | 359 | - Job execution: checkout by exact SHA from the server, sequential shell steps, per-step | |
| 359 | timeout, streaming logs | ||
| 360 | timeout, logs uploaded to the content-addressed store when the attempt ends | ||
| 360 | 361 | - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key | |
| 361 | 362 | - Statuses surfaced on CRs and satisfying required-check gating from Phase 3 | |
| 362 | 363 | - Log and artifact storage onto the content-addressed store (shared with Phase 5). | |
| @@ -398,9 +399,10 @@ pointer is accepted with a warning naming `git lfs track "*.ext"` and | |||
| 398 | 399 | bypass is about review, not about whether the repository works. Deleting a broken | |
| 399 | 400 | pointer remains possible, or there would be no way out of the mistake. | |
| 400 | 401 | ||
| 401 | ✅ **Slice 4 — CI logs on the same store, and a sweep.** A finished run's log becomes a | ||
| 402 | content-addressed object in the same write that records the status, is served as | ||
| 403 | `text/plain` at `/r/<repo>/runs/<id>/log`, and is linked from the repository page. | ||
| 402 | ✅ **Slice 4 — CI logs on the same store, and a sweep.** A finished attempt's log is a | ||
| 403 | content-addressed object the **runner** uploaded and named (see the decision log: | ||
| 404 | logs are not streamed), stored per attempt, served as `text/plain` at | ||
| 405 | `/r/<repo>/runs/<id>/log?attempt=N`, and linked from the repository page. | ||
| 404 | 406 | `vole gc [--dry-run] [--grace]` marks the object ids named by pointers in every tree | |
| 405 | 407 | reachable from every ref in every repository — plus archived logs, which no ref reaches | |
| 406 | 408 | — and removes the rest. Anything younger than the grace period (default 1h) is kept, | |
| @@ -830,6 +832,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 830 | 832 | | 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 | | |
| 831 | 833 | | 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 | | |
| 832 | 834 | | 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 | | |
| 835 | | 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 | | ||
| 833 | 836 | | 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 | | |
| 834 | 837 | | 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 | | |
| 835 | 838 | | 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 | | |