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 7 slice 6: a job declares its outputs, the runner publishes them
213f4d6dc6707cc9199f97acdacd617740bfb84d · Keenan Nemetz · 17d ago · parent 4c0f2aef
Phase 7 slice 6: a job declares its outputs, the runner publishes them
Workflow artifacts are a declaration, not a CLI call in a step. A workflow is
validated at the push that introduces it, so a bad pattern fails the push; a
shell command calling vole would fail at run time, need the binary on a runner's
PATH, and get idempotency wrong under retries.
- workflow: artifacts: [{path: dist/*}] on a job, validated at push — relative,
no '..', no '**', no trailing slash, a glob that parses. Patterns are pinned
into the queued run beside the pinned steps
- runner: expands the patterns in its checkout after the steps pass (one segment
at a time, .git excluded), uploads through the same SSH object transfer git-lfs
uses, and reports the list with its finish. A pattern matching nothing fails the
job: a silent empty artifact is the failure found a week later
- server: the artifact list rides the finish message, so a run's outputs and its
result are one fact; nothing is recorded unless the lease is still held, and
every oid must be in the store at the stated size, or the finish is refused
- storage: run_artifacts rows point at objects in the existing store. A build
output is machine-written state, so it does not touch the metadata branch; the
queue is its only index, and gc marks it from there the way it marks logs
- web: a run page (status, job, sha, log, artifacts) and
/r/<repo>/runs/<id>/artifacts/<name> served from the store, ETag = oid, always
an attachment, nosniff, Range. The CI table now links the run rather than only
its log
Measured through the binary on a real instance: a two-file dist/* declaration ran
on a real runner, both files uploaded over SSH, the run page served them with a
matching sha256, and vole gc --dry-run reported 3 objects, 3 reachable roots (the
log and both artifacts), removing nothing.
docs/roadmap.md +30−3
| @@ -273,7 +273,7 @@ Remaining: | |||
| 273 | 273 | - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key | |
| 274 | 274 | - Statuses surfaced on CRs and satisfying required-check gating from Phase 3 | |
| 275 | 275 | - Log and artifact storage onto the content-addressed store (shared with Phase 5). | |
| 276 | Logs moved in Phase 5 slice 4; workflow artifact uploads are still open | ||
| 276 | Logs moved in Phase 5 slice 4; workflow artifacts landed in Phase 7 slice 6 | ||
| 277 | 277 | ||
| 278 | 278 | **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in | |
| 279 | 279 | the job being requeued and completing on another runner ✅; a workflow that mutates its | |
| @@ -321,8 +321,9 @@ because an upload that has not been pushed yet is not garbage. | |||
| 321 | 321 | ||
| 322 | 322 | Remaining: | |
| 323 | 323 | ||
| 324 | - Artifact uploads from workflows. Logs are the only CI output today; there is no | ||
| 325 | `artifact:` step and no download endpoint for one. | ||
| 324 | - (Artifact uploads from workflows landed in Phase 7 as run artifacts: a job declares | ||
| 325 | `artifacts:`, the runner publishes the matches to the object store, and the run page | ||
| 326 | serves them. See Phase 7 slice 6.) | ||
| 326 | 327 | - Per-repository object scoping. The store is instance-wide, so an object is fetchable | |
| 327 | 328 | from any repository whose read rule you pass, if you know its id. Acceptable for | |
| 328 | 329 | release artifacts, worth revisiting if repositories ever need to be isolated from | |
| @@ -474,6 +475,29 @@ deployed and served at `demo.pages.test` within the one-second window; a deleted | |||
| 474 | 475 | 404d after the next deploy; re-deploy after unpublish took 325 ms; `vole pages status` | |
| 475 | 476 | reported `3 file(s), 19 B`; unpublish twice is a no-op.) | |
| 476 | 477 | ||
| 478 | ✅ **Slice 6 — run artifacts.** A job declares outputs rather than shelling out to a | ||
| 479 | CLI: `artifacts: [{path: dist/*}]`, validated at push (relative, no `..`, no `**`, a | ||
| 480 | glob that parses), so a typo fails the push that introduces it instead of a job hours | ||
| 481 | later. The patterns are pinned into the queued run next to the pinned steps, expanded | ||
| 482 | by the runner in its checkout after the steps pass, and uploaded over SSH through the | ||
| 483 | same object transfer git-lfs uses — the runner holds no other credential, and the | ||
| 484 | store is the authority on whether the bytes exist, so a claim it cannot serve is | ||
| 485 | refused. The list rides the **finish** message rather than a separate call: a run's | ||
| 486 | outputs and its result are one fact, so there is no window in which a success has no | ||
| 487 | artifacts or a failure has some. Run artifacts live in the object store and are named | ||
| 488 | by a `run_artifacts` table, **not** on the metadata branch: a build output is | ||
| 489 | machine-written state, and the run queue is the only index it should have. They are | ||
| 490 | downloadable at `/r/<repo>/runs/<id>/artifacts/<name>` with the object id as `ETag`, | ||
| 491 | `Content-Disposition: attachment`, `nosniff` and `Range` — the same rules as release | ||
| 492 | artifacts, because it is the same kind of byte — and `vole gc` marks them from the | ||
| 493 | queue the way it marks archived logs. A pattern that matches nothing fails the job, | ||
| 494 | and two files with one base name are refused, because a silent empty artifact is the | ||
| 495 | failure people find a week later. | ||
| 496 | (Measured through the binary against a real instance: a two-file `dist/*` declaration | ||
| 497 | ran on a real runner, uploaded both over SSH, and the run page served them with a | ||
| 498 | matching `sha256`; `vole gc --dry-run` reported 3 objects and 3 reachable roots — the | ||
| 499 | log and both artifacts — removing nothing.) | ||
| 500 | |||
| 477 | 501 | Planned: | |
| 478 | 502 | ||
| 479 | 503 | - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an | |
| @@ -648,6 +672,9 @@ Filled in as phases land. Append; don't rewrite. | |||
| 648 | 672 | | 2026-10-22 | No release record file, and no drafts | The artifact list is the pointer directory and published-ness is the ref itself; a `releases/<tag>.json` would carry a flag that duplicates the tag and an edit history nobody asked for | | |
| 649 | 673 | | 2026-10-22 | Attach uploads bytes before it commits the pointer | The reverse order publishes a download that 404s; the hook cannot see bytes that were never in the pack, so the pointer check is the only guard and the GC grace period covers the gap | | |
| 650 | 674 | | 2026-10-22 | Artifact downloads are always an attachment, served through `http.ServeContent` | An uploaded `.html` rendered inline is stored XSS on the forge origin; `ServeContent` gives `Range` and conditional requests for free, and the LFS object id is the validator | | |
| 675 | | 2026-10-23 | Workflow outputs are declared (`artifacts:`), not CLI calls in steps | A workflow is validated at push, so a bad path fails the push; a step calling the CLI fails at run time, needs the binary on the runner's PATH, and gets idempotency wrong under retries | | ||
| 676 | | 2026-10-23 | Run artifacts are named by the run queue, not by a ref | A build output is machine-written state; the metadata branch is for human-authored entities, and the sweep already takes extra roots from the queue for logs | | ||
| 677 | | 2026-10-23 | A run's artifact list rides the finish message | Outputs and result are one fact: a separate call leaves a window where a successful run has no artifacts, or a failed one has some | | ||
| 651 | 678 | | 2026-10-23 | `vole pages deploy` publishes a snapshot, not a merge | A site is a build directory: merging leaves deleted pages served forever. Snapshot is a normal commit on the tip, so protection and review still apply | | |
| 652 | 679 | | 2026-10-23 | Pages commands ask the server (`git ls-remote`), not the clone | A local branch outlives an unpublish, so the clone describes a site that 404s; the deploy said "already published" until it looked at the server | | |
| 653 | 680 | | 2026-10-22 | A published release is immutable, admin-overridable and audited | A link whose bytes can change is worse than no link; but refusing outright leaves a mistyped tag unrepairable, so the override follows the force-push precedent: admin only, always audited | | |