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 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 |