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.

docs/runner.md +78−1

@@ -60,11 +60,30 @@ one.
60 60 Container defaults are intentionally closed:
61 61
62 62 - no network (`--container-network none`); add `bridge` only when a job needs it;
63 - read-only root filesystem, with `/tmp` as a writable tmpfs;
64 63 - no capabilities, `no-new-privileges`, and no host mounts except the job tree;
65 64 - no Docker socket, SSH agent, or host environment;
65 - `/tmp` is a writable, executable tmpfs (compilers write their output there and
66 run it);
66 67 - the steps run as the runner user's `uid:gid` by default.
67 68
69 The container's own filesystem is writable, and thrown away with the container:
70 nothing a job writes there survives the run, and an unprivileged uid cannot write
71 the image's root-owned paths anyway. What protects the machine is the mount set —
72 the job tree is the only host path a job can see — not the writability of the
73 container's own layer.
74
75 Because the uid is the runner's own, an image whose tools call `getpwuid` — OpenSSH
76 is the common case — needs a passwd entry for that uid, or `ssh` and `ssh-keygen`
77 exit with `No user exists for uid N`. Define one in the image definition, or pass
78 `--container-user` naming a user the image already has. This is the same container
79 `vole workflow run` starts, so an image that fails here fails there too.
80
81 The uid decides how immutable the image is. An ordinary user cannot write the
82 image's root-owned files, so a job's view of its own toolchain is the one that was
83 built. If the runner runs as root (or `--container-user` says so), the job can
84 rewrite its own image, and the runner prints a warning at startup: run the runner
85 as an unprivileged user.
86
68 87 Use `--container-runtime` to choose `docker`, `podman`, or `nerdctl`. The runner
69 88 user must be able to talk to that runtime; rootless Podman is preferred. Do not
70 89 mount the host's Docker socket into a job; that would undo the isolation.
@@ -80,6 +99,64 @@ the runtime's mapping (container root is the runner user) or use a runtime with
80 99 The run record names the image, the definition digest when there is one, and the
81 100 runtime, so the run page says what actually executed.
82 101
102 ## Caching
103
104 A job starts with an empty container, so without help every run downloads its
105 dependencies and compiles them again: on this project that is about 23 seconds of
106 `go build`/`go vet` that a warm cache turns into one. The runner keeps a directory
107 per job and mounts it at `/cache`.
108
109 It mounts the directory and sets **no** variable that points at it. Which tool uses
110 `/cache`, and therefore whether a job shares state with the job before it, is the
111 repository's decision. A runner that guessed would be making a claim about a
112 toolchain it does not test, and a job that wants no shared state would have to
113 defend against a default it never asked for.
114
115 Opt in where the environment already lives — the image definition:
116
117 ```yaml
118 # .vole/images/ci.yaml
119 version: 1
120 from: golang:1.27-bookworm
121 env:
122 GOMODCACHE: "/cache/go-mod"
123 GOCACHE: "/cache/go-build"
124 ```
125
126 or per job, which works for a registry image too:
127
128 ```yaml
129 jobs:
130 test:
131 image: golang:1.27-bookworm
132 env:
133 GOCACHE: /cache/go-build
134 GOMODCACHE: /cache/go-mod
135 steps:
136 - run: go test ./...
137 ```
138
139 The variables for the common toolchains are the tools' own; there is nothing
140 Vole-specific to learn. `GOMODCACHE`/`GOCACHE` for Go, `CARGO_HOME`,
141 `npm_config_cache`, `PIP_CACHE_DIR`, `MAVEN_ARGS`/`-Dmaven.repo.local`,
142 `GRADLE_USER_HOME`, `NUGET_PACKAGES`, `PUB_CACHE`, `COMPOSER_CACHE_DIR`,
143 `CCACHE_DIR`, and `XDG_CACHE_HOME` for anything that follows the XDG convention.
144 An image that sets none of them gets an empty directory and no behaviour change.
145
146 The cache lives under `--dir/cache`, keyed by repository, trust, and image:
147 `<dir>/cache/<repo>/<direct|contrib>/<image>`. The parts matter. A cache is shared
148 mutable state: a repository never sees another's, and a contributor branch gets its
149 own namespace, so a branch nobody has vouched for cannot seed the cache a protected
150 branch builds into. The image is part of the key so a cache built with one
151 toolchain is not consumed by another.
152
153 Namespaces are evicted whole and least-recently-written first, so a cache is never
154 left half-deleted — a Go module cache missing one file is a checksum error deep in
155 a build rather than a download. `--cache-size` caps the total (5G by default) and
156 the runner trims at startup and after each job. `--no-cache` keeps nothing
157 between jobs: the directory is still mounted — an image that points at `/cache`
158 has to work either way — but it is emptied after every job.
159
83 160 ## Who can run jobs and report results
84 161
85 162 A job runs repository code, so Vole uses a runner allowlist for both taking a job