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/workflow_test.go +58
| @@ -393,3 +393,61 @@ jobs: | |||
| 393 | 393 | t.Fatal("a job with too many labels was accepted") | |
| 394 | 394 | } | |
| 395 | 395 | } | |
| 396 | |||
| 397 | // TestJobEnvironment: a job's env is how it opts in to things the runner provides | ||
| 398 | // but does not decide, such as the cache at /cache. It is validated like the image | ||
| 399 | // definition's env, and it may not replace what the sandbox itself sets. | ||
| 400 | func TestJobEnvironment(t *testing.T) { | ||
| 401 | t.Parallel() | ||
| 402 | body := `version: 1 | ||
| 403 | jobs: | ||
| 404 | test: | ||
| 405 | image: alpine:3.22 | ||
| 406 | env: | ||
| 407 | GOCACHE: /cache/go-build | ||
| 408 | GOFLAGS: "-mod=readonly" | ||
| 409 | steps: | ||
| 410 | - run: go test ./... | ||
| 411 | ` | ||
| 412 | wf, problems := Parse("w.yaml", body) | ||
| 413 | if len(problems) > 0 { | ||
| 414 | t.Fatalf("rejected: %v", problems) | ||
| 415 | } | ||
| 416 | env := wf.Jobs[0].Env | ||
| 417 | if env["GOCACHE"] != "/cache/go-build" || env["GOFLAGS"] != "-mod=readonly" { | ||
| 418 | t.Fatalf("env = %v", env) | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | func TestJobEnvironmentIsValidated(t *testing.T) { | ||
| 423 | t.Parallel() | ||
| 424 | for name, tc := range map[string]struct{ env, want string }{ | ||
| 425 | "bad name": {"bad-name: x", "is not a variable name"}, | ||
| 426 | "leading digit": {"1X: x", "is not a variable name"}, | ||
| 427 | "newline": {"X: \"one\\ntwo\"", "must be one line"}, | ||
| 428 | "reserved CI": {"CI: \"false\"", "set by the runner"}, | ||
| 429 | "reserved home": {"HOME: \"/home/ci\"", "set by the runner"}, | ||
| 430 | "reserved tmp": {"TMPDIR: \"/var/tmp\"", "set by the runner"}, | ||
| 431 | } { | ||
| 432 | t.Run(name, func(t *testing.T) { | ||
| 433 | t.Parallel() | ||
| 434 | body := "version: 1\njobs:\n j:\n image: alpine:3.22\n env:\n " + tc.env + | ||
| 435 | "\n steps:\n - run: echo hi\n" | ||
| 436 | _, problems := Parse("w.yaml", body) | ||
| 437 | if len(problems) == 0 { | ||
| 438 | t.Fatalf("%s was accepted", tc.env) | ||
| 439 | } | ||
| 440 | if !strings.Contains(strings.Join(problems, "\n"), tc.want) { | ||
| 441 | t.Fatalf("problems = %v; want one mentioning %q", problems, tc.want) | ||
| 442 | } | ||
| 443 | }) | ||
| 444 | } | ||
| 445 | |||
| 446 | // A value that is one byte over the limit is refused, not truncated. | ||
| 447 | big := strings.Repeat("x", MaxEnvValue+1) | ||
| 448 | body := "version: 1\njobs:\n j:\n image: alpine:3.22\n env:\n BIG: " + big + | ||
| 449 | "\n steps:\n - run: echo hi\n" | ||
| 450 | if _, problems := Parse("w.yaml", body); len(problems) == 0 { | ||
| 451 | t.Fatal("an oversized value was accepted") | ||
| 452 | } | ||
| 453 | } | ||