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