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.
scripts/ci.sh −47
| @@ -1,47 +0,0 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Everything that must pass before a push. Keep this the single entry point so CI | ||
| 3 | # and humans cannot drift apart. | ||
| 4 | set -eu | ||
| 5 | cd "$(dirname "$0")/.." | ||
| 6 | |||
| 7 | fail() { echo "FAIL: $*" >&2; exit 1; } | ||
| 8 | |||
| 9 | echo "== gofmt ==" | ||
| 10 | unformatted=$(gofmt -l . 2>/dev/null | grep -v '^$' || true) | ||
| 11 | [ -z "$unformatted" ] || fail "unformatted files: | ||
| 12 | $unformatted" | ||
| 13 | |||
| 14 | echo "== go vet ==" | ||
| 15 | go vet ./... || fail "vet" | ||
| 16 | |||
| 17 | echo "== build ==" | ||
| 18 | go build ./... || fail "build" | ||
| 19 | |||
| 20 | echo "== markdown links ==" | ||
| 21 | python3 - <<'EOF' || fail "markdown links" | ||
| 22 | import os, re, sys | ||
| 23 | bad = [] | ||
| 24 | for root, dirs, files in os.walk('.'): | ||
| 25 | dirs[:] = [d for d in dirs if d not in ('.git', 'data')] | ||
| 26 | for f in files: | ||
| 27 | if not f.endswith('.md'): | ||
| 28 | continue | ||
| 29 | p = os.path.join(root, f) | ||
| 30 | text = open(p, encoding='utf-8').read() | ||
| 31 | for m in re.finditer(r'\]\(([^)#][^)]*)\)', text): | ||
| 32 | target = m.group(1) | ||
| 33 | if target.startswith(('http://', 'https://', 'mailto:')): | ||
| 34 | continue | ||
| 35 | path = os.path.normpath(os.path.join(root, target.split('#')[0])) | ||
| 36 | if not os.path.exists(path): | ||
| 37 | bad.append(f"{p}: {target}") | ||
| 38 | if bad: | ||
| 39 | print("\n".join(bad)) | ||
| 40 | sys.exit(1) | ||
| 41 | EOF | ||
| 42 | |||
| 43 | echo "== tests (race) ==" | ||
| 44 | go test -race -count=1 ./... || fail "tests" | ||
| 45 | |||
| 46 | echo | ||
| 47 | echo "all checks passed" | ||