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/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")