vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 · 15d 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/sandbox_test.go −71
| @@ -1,80 +1,9 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | "strings" | ||
| 5 | 4 | "testing" | |
| 6 | 5 | ) | |
| 7 | 6 | ||
| 8 | // The container arguments are the security boundary in one list. The defaults have | ||
| 9 | // to stay closed: only the job tree is mounted, the root filesystem is read-only, | ||
| 10 | // capabilities are dropped, and no host environment or SSH agent is passed through. | ||
| 11 | func TestContainerRunArgsAreClosed(t *testing.T) { | ||
| 12 | t.Parallel() | ||
| 13 | cfg := containerConfig{ | ||
| 14 | runtime: "podman", | ||
| 15 | network: "none", | ||
| 16 | pull: "missing", | ||
| 17 | user: "1000:1000", | ||
| 18 | userns: "keep-id", | ||
| 19 | memory: "2g", | ||
| 20 | cpus: "2", | ||
| 21 | pids: 512, | ||
| 22 | id: "runner1", | ||
| 23 | } | ||
| 24 | args := strings.Join(containerRunArgs(cfg, "golang:1.27@sha256:abc", "vole-runner1-run1-1", "/work/job", "run1"), " ") | ||
| 25 | |||
| 26 | for _, want := range []string{ | ||
| 27 | "run -d --rm", | ||
| 28 | "--name vole-runner1-run1-1", | ||
| 29 | "--workdir /workspace", | ||
| 30 | "--user 1000:1000", | ||
| 31 | "--userns keep-id", | ||
| 32 | "--volume /work/job:/workspace:rw", | ||
| 33 | "--read-only", | ||
| 34 | "--cap-drop ALL", | ||
| 35 | "--security-opt no-new-privileges", | ||
| 36 | "--network none", | ||
| 37 | "--pull missing", | ||
| 38 | "--pids-limit 512", | ||
| 39 | "--memory 2g", | ||
| 40 | "--cpus 2", | ||
| 41 | "--entrypoint /bin/sh", | ||
| 42 | "golang:1.27@sha256:abc", | ||
| 43 | } { | ||
| 44 | if !strings.Contains(args, want) { | ||
| 45 | t.Fatalf("container arguments do not contain %q:\n%s", want, args) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | for _, unwanted := range []string{ | ||
| 49 | "--privileged", | ||
| 50 | "--network host", | ||
| 51 | "/var/run/docker.sock", | ||
| 52 | "SSH_AUTH_SOCK", | ||
| 53 | "/home/runner", | ||
| 54 | } { | ||
| 55 | if strings.Contains(args, unwanted) { | ||
| 56 | t.Fatalf("container arguments contain %q:\n%s", unwanted, args) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | if strings.Count(args, "--volume") != 1 { | ||
| 60 | t.Fatalf("expected exactly one bind mount, got:\n%s", args) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | func TestContainerRunArgsOmitEmptyLimits(t *testing.T) { | ||
| 65 | t.Parallel() | ||
| 66 | cfg := containerConfig{ | ||
| 67 | runtime: "docker", network: "none", pull: "never", | ||
| 68 | user: "1000:1000", id: "runner1", | ||
| 69 | } | ||
| 70 | args := strings.Join(containerRunArgs(cfg, "alpine:3", "name", "/work", "run"), " ") | ||
| 71 | for _, unwanted := range []string{"--memory", "--cpus", "--pids-limit"} { | ||
| 72 | if strings.Contains(args, unwanted) { | ||
| 73 | t.Fatalf("an unset limit was passed as %q:\n%s", unwanted, args) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | 7 | func TestContainerOptionsRequireRuntime(t *testing.T) { | |
| 79 | 8 | if _, err := containerOptions(Options{ContainerRuntime: "definitely-not-a-runtime"}, "/tmp/cache"); err == nil { | |
| 80 | 9 | t.Fatal("an unknown container runtime was accepted") | |