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/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 }