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.
Add packaging, a CI workflow, and containerised local runs
3b39a84c913cabe15bfa6a60dd176ff2dd28d241 · Keenan Nemetz · 14d ago · parent 30a72490
Add packaging, a CI workflow, and containerised local runs A v* tag now builds a .deb and a .rpm for amd64 and arm64 with nfpm and attaches them, with checksums, to the release. The package ships the systemd unit, a config at /etc/vole/vole.toml, and a postinstall that creates the service account and prepares the instance that config names; `vole admin init --config` is what makes that possible, and the binary's version is stamped from the tag instead of the source tree. scripts/ci.sh becomes .vole/workflows/ci.yaml, so `vole workflow run ci` executes exactly what a push runs, and the Markdown link check moves to scripts/check-links.py. Local runs now always execute in the job image and share ci/container with the runner; --no-container is gone, because a run in a different environment proves nothing about CI. The runner mounts a per-repository namespace at /cache and sets no variable for it: a job opts in through its image's env or the new job-level env, which is pinned with the queue and delivered with the lease. Whole namespaces are evicted least-recently-written first so a module cache is never half-deleted, and contributor branches get their own namespace so an untrusted tip cannot seed the cache a protected branch builds into. Running the suite in that container found four bugs worth naming: /tmp was mounted noexec, which broke `go test` and `go run`; the read-only root is gone, because images make toolchain directories world-writable deliberately and the mount set is what protects the host; OpenSSH needs a passwd entry for the job's uid; and a lost metadata push reported as "cannot lock ref" was not classified as a race, so concurrent CLI writes gave up on the first collision. That retry now backs off exponentially with jitter.
ci/agent/cache_test.go +47
| @@ -0,0 +1,47 @@ | |||
| 1 | package agent | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "path/filepath" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // A cache is shared mutable state, so which namespace a job gets is a policy | ||
| 10 | // decision: one repository never sees another's, and an untrusted contributor | ||
| 11 | // branch never sees what a protected branch builds into. | ||
| 12 | func TestCacheNamespacesByRepoAndTrust(t *testing.T) { | ||
| 13 | t.Parallel() | ||
| 14 | root := t.TempDir() | ||
| 15 | s := &runnerSession{cacheRoot: root} | ||
| 16 | |||
| 17 | mounts := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/main", Image: "alpine:3.22"}) | ||
| 18 | if len(mounts) != 1 || mounts[0].Path != "/cache" { | ||
| 19 | t.Fatalf("mounts = %+v, want one at /cache", mounts) | ||
| 20 | } | ||
| 21 | if !strings.HasPrefix(mounts[0].Host, root+string(filepath.Separator)) { | ||
| 22 | t.Fatalf("cache %q is not under the runner's cache root", mounts[0].Host) | ||
| 23 | } | ||
| 24 | if !strings.Contains(mounts[0].Host, "demo") { | ||
| 25 | t.Fatalf("cache %q is not namespaced by repository", mounts[0].Host) | ||
| 26 | } | ||
| 27 | |||
| 28 | contribMounts := s.cacheFor(&runnerJob{ | ||
| 29 | Repo: "demo", Ref: "refs/heads/contrib/alice/patch", Image: "alpine:3.22", | ||
| 30 | }) | ||
| 31 | if contribMounts[0].Host == mounts[0].Host { | ||
| 32 | t.Fatalf("a contributor branch shares the protected branch's cache: %s", mounts[0].Host) | ||
| 33 | } | ||
| 34 | |||
| 35 | otherMounts := s.cacheFor(&runnerJob{Repo: "other", Ref: "refs/heads/main", Image: "alpine:3.22"}) | ||
| 36 | if otherMounts[0].Host == mounts[0].Host { | ||
| 37 | t.Fatal("two repositories share a cache namespace") | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestCacheCanBeTurnedOff(t *testing.T) { | ||
| 42 | t.Parallel() | ||
| 43 | s := &runnerSession{} | ||
| 44 | if mounts := s.cacheFor(&runnerJob{Repo: "demo"}); mounts != nil { | ||
| 45 | t.Fatalf("a disabled cache produced %v", mounts) | ||
| 46 | } | ||
| 47 | } | ||