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