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.go +58−6
| @@ -35,6 +35,10 @@ const ( | |||
| 35 | 35 | // MaxLabels bounds runs-on. A job naming more than a handful of labels is | |
| 36 | 36 | // describing a specific machine, which is what --repos and the allowlist are for. | |
| 37 | 37 | MaxLabels = 8 | |
| 38 | // MaxEnv and MaxEnvValue bound a job's environment, matching what an image | ||
| 39 | // definition may set. An environment is configuration, not a payload. | ||
| 40 | MaxEnv = 64 | ||
| 41 | MaxEnvValue = 8 << 10 | ||
| 38 | 42 | // MaxArtifactPathBytes bounds one pattern. A declaration is a path, not a | |
| 39 | 43 | // manifest: the files it matches are discovered when the job runs. | |
| 40 | 44 | MaxArtifactPathBytes = 256 | |
| @@ -53,6 +57,16 @@ var ( | |||
| 53 | 57 | // runner says it is with --label. Lowercase because a label is an identity, and | |
| 54 | 58 | // `Linux` and `linux` being different machines is a support question nobody wants. | |
| 55 | 59 | labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) | |
| 60 | // reservedEnv is what the sandbox itself sets. A job that could replace these | ||
| 61 | // could make the container lie about being in CI or move HOME out of the writable | ||
| 62 | // path, so they are refused rather than overridden. | ||
| 63 | reservedEnv = map[string]bool{ | ||
| 64 | "CI": true, | ||
| 65 | "VOLE_CI": true, | ||
| 66 | "GIT_TERMINAL_PROMPT": true, | ||
| 67 | "HOME": true, | ||
| 68 | "TMPDIR": true, | ||
| 69 | } | ||
| 56 | 70 | ) | |
| 57 | 71 | ||
| 58 | 72 | // ValidLabel reports whether s can be a runner label. It is exported so the runner | |
| @@ -104,6 +118,10 @@ type Job struct { | |||
| 104 | 118 | ImageDefinition string | |
| 105 | 119 | // ImageSHA is the digest of ImageDefinition. | |
| 106 | 120 | ImageSHA string | |
| 121 | // Env is the environment every step of this job runs with, on top of the | ||
| 122 | // image's own. It is where a job opts in to things the runner provides but does | ||
| 123 | // not decide for it, such as the cache mounted at /cache. | ||
| 124 | Env map[string]string | ||
| 107 | 125 | // Artifacts are the files this job publishes as the run's outputs. | |
| 108 | 126 | Artifacts []Artifact | |
| 109 | 127 | // Pages, when set, publishes a directory as the repository's site. It is on the | |
| @@ -175,12 +193,13 @@ type rawPages struct { | |||
| 175 | 193 | } | |
| 176 | 194 | ||
| 177 | 195 | type rawJob struct { | |
| 178 | Steps []rawStep `yaml:"steps"` | ||
| 179 | RunsOn rawLabels `yaml:"runs-on"` | ||
| 180 | Image string `yaml:"image"` | ||
| 181 | ImageFile string `yaml:"image-file"` | ||
| 182 | Artifacts []rawArtifact `yaml:"artifacts"` | ||
| 183 | Pages *rawPages `yaml:"pages"` | ||
| 196 | Steps []rawStep `yaml:"steps"` | ||
| 197 | RunsOn rawLabels `yaml:"runs-on"` | ||
| 198 | Image string `yaml:"image"` | ||
| 199 | ImageFile string `yaml:"image-file"` | ||
| 200 | Artifacts []rawArtifact `yaml:"artifacts"` | ||
| 201 | Pages *rawPages `yaml:"pages"` | ||
| 202 | Env map[string]string `yaml:"env"` | ||
| 184 | 203 | } | |
| 185 | 204 | ||
| 186 | 205 | // rawLabels accepts both spellings of runs-on, because both are the obvious way to | |
| @@ -423,6 +442,39 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 423 | 442 | } | |
| 424 | 443 | job.Artifacts = append(job.Artifacts, Artifact{Path: p, Release: ar.Release}) | |
| 425 | 444 | } | |
| 445 | // The job's own environment. The runner sets a small fixed set (CI, | ||
| 446 | // VOLE_CI, HOME, TMPDIR, GIT_TERMINAL_PROMPT) that the sandbox depends on, | ||
| 447 | // so a workflow cannot replace those: the refusal names the variable rather | ||
| 448 | // than letting docker's last-wins rule silently disagree with the sandbox. | ||
| 449 | if len(jr.Env) > MaxEnv { | ||
| 450 | bad("jobs.%s: %d environment variables, limit %d", id, len(jr.Env), MaxEnv) | ||
| 451 | } | ||
| 452 | names := make([]string, 0, len(jr.Env)) | ||
| 453 | for name := range jr.Env { | ||
| 454 | names = append(names, name) | ||
| 455 | } | ||
| 456 | sort.Strings(names) | ||
| 457 | for _, name := range names { | ||
| 458 | value := jr.Env[name] | ||
| 459 | switch { | ||
| 460 | case !envNameRE.MatchString(name): | ||
| 461 | bad("jobs.%s.env: %q is not a variable name (letters, digits and _, not starting with a digit)", id, name) | ||
| 462 | continue | ||
| 463 | case reservedEnv[name]: | ||
| 464 | bad("jobs.%s.env: %s is set by the runner and cannot be replaced", id, name) | ||
| 465 | continue | ||
| 466 | case strings.ContainsAny(value, "\r\n"): | ||
| 467 | bad("jobs.%s.env.%s: the value must be one line", id, name) | ||
| 468 | continue | ||
| 469 | case len(value) > MaxEnvValue: | ||
| 470 | bad("jobs.%s.env.%s: the value is %d bytes, limit %d", id, name, len(value), MaxEnvValue) | ||
| 471 | continue | ||
| 472 | } | ||
| 473 | if job.Env == nil { | ||
| 474 | job.Env = map[string]string{} | ||
| 475 | } | ||
| 476 | job.Env[name] = value | ||
| 477 | } | ||
| 426 | 478 | if jr.Pages != nil { | |
| 427 | 479 | where := fmt.Sprintf("jobs.%s.pages.path", id) | |
| 428 | 480 | p := strings.TrimSpace(jr.Pages.Path) | |