vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 · 15d 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.
client/branch.go +18−8
| @@ -97,7 +97,7 @@ func (b *Branch) CommitAndPush(noPush bool, reapply func() error) error { | |||
| 97 | 97 | return nil | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | const attempts = 8 | ||
| 100 | const attempts = 12 | ||
| 101 | 101 | announced := false | |
| 102 | 102 | for attempt := 1; ; attempt++ { | |
| 103 | 103 | err := b.pushOnce() | |
| @@ -114,9 +114,15 @@ func (b *Branch) CommitAndPush(noPush bool, reapply func() error) error { | |||
| 114 | 114 | fmt.Fprintln(os.Stderr, "metadata moved underneath us; re-applying on the new tip") | |
| 115 | 115 | announced = true | |
| 116 | 116 | } | |
| 117 | // Jitter matters: without it, concurrent writers back off in lockstep and | ||
| 118 | // collide again on every round. | ||
| 119 | time.Sleep(time.Duration(attempt*10+rand.IntN(30)) * time.Millisecond) | ||
| 117 | // Back off exponentially and spread the jitter across the whole interval. | ||
| 118 | // Concurrent writers that sleep the same amount collide again on the next | ||
| 119 | // round, and a linear delay gives the last of a crowd too little room to win | ||
| 120 | // before the budget runs out. | ||
| 121 | wait := 25 * time.Millisecond << min(attempt-1, 4) | ||
| 122 | if wait > 400*time.Millisecond { | ||
| 123 | wait = 400 * time.Millisecond | ||
| 124 | } | ||
| 125 | time.Sleep(wait/2 + time.Duration(rand.IntN(int(wait/2)+1))) | ||
| 120 | 126 | if ferr := b.Fetch(); ferr != nil { | |
| 121 | 127 | return ferr | |
| 122 | 128 | } | |
| @@ -139,10 +145,13 @@ func (b *Branch) pushOnce() error { | |||
| 139 | 145 | } | |
| 140 | 146 | ||
| 141 | 147 | // isLostRace distinguishes a lost race, which is retryable, from a policy | |
| 142 | // rejection, which is not. A lost race has two shapes: our commit is no longer a | ||
| 143 | // fast-forward, or our push command arrives after someone else moved the ref and | ||
| 144 | // carries a stale old-oid ("incorrect old value provided"), or the branch is | ||
| 145 | // created by two writers at once ("reference already exists"). | ||
| 148 | // rejection, which is not. A lost race has several shapes: our commit is no | ||
| 149 | // longer a fast-forward, our push arrives after someone else moved the ref and | ||
| 150 | // carries a stale old-oid ("incorrect old value provided"), the branch is created | ||
| 151 | // by two writers at once ("reference already exists"), or the server is holding | ||
| 152 | // the ref lock for another push while its value changes ("cannot lock ref"). The | ||
| 153 | // last one is why this has to look at more than the obvious rejections: it is a | ||
| 154 | // lock, not a policy, and it clears on its own. | ||
| 146 | 155 | func isLostRace(err error) bool { | |
| 147 | 156 | msg := err.Error() | |
| 148 | 157 | for _, marker := range []string{ | |
| @@ -150,6 +159,7 @@ func isLostRace(err error) bool { | |||
| 150 | 159 | "fetch first", | |
| 151 | 160 | "incorrect old value provided", | |
| 152 | 161 | "reference already exists", | |
| 162 | "cannot lock ref", | ||
| 153 | 163 | "stale info", | |
| 154 | 164 | "Updates were rejected", | |
| 155 | 165 | } { | |