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.
internal/workflow/workflow_test.go +38
| @@ -122,3 +122,41 @@ jobs: | |||
| 122 | 122 | t.Fatalf("%d problems reported, want 4+:\n%s", len(problems), strings.Join(problems, "\n")) | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 125 | |||
| 126 | // TestArtifactsAreValidatedAtPush is the reason artifacts are declared rather than | ||
| 127 | // run as a shell command: a bad path fails the push that introduced it, not a job | ||
| 128 | // hours later. | ||
| 129 | func TestArtifactsAreValidatedAtPush(t *testing.T) { | ||
| 130 | ok := `version: 1 | ||
| 131 | jobs: | ||
| 132 | build: | ||
| 133 | steps: | ||
| 134 | - run: make dist | ||
| 135 | artifacts: | ||
| 136 | - path: dist/* | ||
| 137 | - path: bin/tool-* | ||
| 138 | ` | ||
| 139 | wf, problems := Parse("ci.yaml", ok) | ||
| 140 | if len(problems) > 0 { | ||
| 141 | t.Fatalf("a valid declaration was refused: %v", problems) | ||
| 142 | } | ||
| 143 | if got := wf.Jobs[0].Artifacts; len(got) != 2 || got[0].Path != "dist/*" || got[1].Path != "bin/tool-*" { | ||
| 144 | t.Fatalf("artifacts = %+v", got) | ||
| 145 | } | ||
| 146 | |||
| 147 | bad := []string{ | ||
| 148 | " - path: \"\"\n", // empty | ||
| 149 | " - path: /etc/passwd\n", // absolute | ||
| 150 | " - path: ../outside/*\n", // climbs out | ||
| 151 | " - path: dist/\n", // a directory | ||
| 152 | " - path: dist/**\n", // a glob people expect to recurse | ||
| 153 | " - path: dist/[\n", // not a glob | ||
| 154 | " - pathx: dist/*\n", // unknown key | ||
| 155 | } | ||
| 156 | for _, decl := range bad { | ||
| 157 | body := "version: 1\njobs:\n build:\n steps:\n - run: true\n artifacts:\n" + decl | ||
| 158 | if _, problems := Parse("ci.yaml", body); len(problems) == 0 { | ||
| 159 | t.Errorf("declaration %q was accepted", strings.TrimSpace(decl)) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||