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.
Land a change request on the server instead of by pushing
38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 13d ago · parent 536f08b9
Land a change request on the server instead of by pushing A change request could only be landed by merging locally and pushing the base branch: the web page was a review surface with no way to finish, and nothing recorded that the request had landed. `state: merged` was already accepted by the request reader and rendered by the activity feed, but nothing ever wrote it, so a merged request and an abandoned one looked identical. The gate that decides a landing lives in the pre-receive hook, and the hook only sees pushes. A merge performed inside the server moves the ref without the hook, so a server-side merge either re-runs that rule or becomes the easiest way to land an unapproved change. It now re-runs it: the approval and check rule moved to forge/landing, and both the hook and the new landing operation call the same Check. Landing is fast-forward when the base is an ancestor of the reviewed head, so the commit that passed CI is the commit that lands; otherwise a two-parent merge commit, which is the only other shape the gate accepts. Conflicts are refused with the conflicting paths, never resolved. The ref moves by compare-and-swap, so a concurrent push loses cleanly and the landing retries; the request is marked merged after the ref moves, which makes a retry after a crash finish the marking instead of failing. The request now records merged_head, merged_by and merged_at, and the reader refuses a merged request that names no commit, and refuses those fields on a request that is not merged. "merged" is no longer settable through the generic state writer: it has to travel with the commit. The browser gets a Merge button that preflights the same gate, so it only appears when the landing would succeed, and the page shows what landed, who landed it, and when. `vole change merge` follows in its own change: the CLI has no access to the ACL or the run queue, so it needs a verb on the SSH channel, and that transport is worth reviewing on its own. Closes the landing half of #01M5XF4XWHVMMCQEYENA8X32GK.
server/hook/checks.go −110
| @@ -1,110 +0,0 @@ | |||
| 1 | package hook | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/rodent-software/vole/ci" | ||
| 8 | "github.com/rodent-software/vole/ci/runs" | ||
| 9 | "github.com/rodent-software/vole/core/git" | ||
| 10 | "github.com/rodent-software/vole/forge/access" | ||
| 11 | "github.com/rodent-software/vole/internal/format" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // checkGaps reports which required CI jobs have not passed for a commit. | ||
| 15 | // | ||
| 16 | // The question is asked of the commit being landed — the approved head — not of | ||
| 17 | // the merge commit a person may be pushing, because CI runs on what was reviewed; | ||
| 18 | // a merge commit is a new object nobody built. | ||
| 19 | // checkGaps reports what a protected branch is still waiting for. A green run only | ||
| 20 | // counts when the host has allowed the account that reported it: a required check is | ||
| 21 | // a claim about a commit, and without that rule any writer could satisfy it by | ||
| 22 | // running CI on their own machine and saying it passed. | ||
| 23 | func checkGaps(g *git.Repo, runsDB, repo, sha, branch string, a *access.ACL) ([]string, error) { | ||
| 24 | wfs, problems := ci.At(g, sha) | ||
| 25 | if len(problems) > 0 { | ||
| 26 | return nil, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; ")) | ||
| 27 | } | ||
| 28 | var required []*ci.Workflow | ||
| 29 | for _, wf := range wfs { | ||
| 30 | if wf.Applies(branch) { | ||
| 31 | required = append(required, wf) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | if len(required) == 0 { | ||
| 35 | return nil, nil // nothing to run, so nothing to wait for | ||
| 36 | } | ||
| 37 | if runsDB == "" { | ||
| 38 | return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass") | ||
| 39 | } | ||
| 40 | store, err := runs.Open(runsDB) | ||
| 41 | if err != nil { | ||
| 42 | return nil, fmt.Errorf("cannot open the run queue: %w", err) | ||
| 43 | } | ||
| 44 | defer store.Close() | ||
| 45 | |||
| 46 | results, err := store.ForSHA(repo, sha) | ||
| 47 | if err != nil { | ||
| 48 | return nil, fmt.Errorf("cannot read run results: %w", err) | ||
| 49 | } | ||
| 50 | |||
| 51 | var gaps []string | ||
| 52 | for _, wf := range required { | ||
| 53 | for _, job := range wf.Jobs { | ||
| 54 | run := results[runs.Key(wf.Path, job.ID)] | ||
| 55 | switch { | ||
| 56 | case run == nil: | ||
| 57 | gaps = append(gaps, fmt.Sprintf( | ||
| 58 | "%s: job %q has no run for %s — push the branch and let CI run on it "+ | ||
| 59 | "(this job filters on [%s]; a commit tested only on other branches has no result here)", | ||
| 60 | wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " "))) | ||
| 61 | case run.Status == runs.StatusFailed: | ||
| 62 | gaps = append(gaps, fmt.Sprintf( | ||
| 63 | "%s: job %q failed for %s (exit %d)", wf.Path, job.ID, format.SHA(sha), run.ExitCode)) | ||
| 64 | case run.Status == runs.StatusCanceled: | ||
| 65 | gaps = append(gaps, fmt.Sprintf( | ||
| 66 | "%s: job %q was canceled for %s", wf.Path, job.ID, format.SHA(sha))) | ||
| 67 | case run.Status != runs.StatusSuccess: | ||
| 68 | gaps = append(gaps, fmt.Sprintf( | ||
| 69 | "%s: job %q is %s for %s; wait for it to finish", wf.Path, job.ID, run.Status, format.SHA(sha))) | ||
| 70 | } | ||
| 71 | if run == nil || run.Status != runs.StatusSuccess { | ||
| 72 | continue | ||
| 73 | } | ||
| 74 | // The result has to be attributable to somebody the host allowed, or it | ||
| 75 | // is a green light from a party with nothing at stake in this branch. | ||
| 76 | att, err := store.Attestation(run.ID) | ||
| 77 | if err != nil { | ||
| 78 | return nil, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err) | ||
| 79 | } | ||
| 80 | switch { | ||
| 81 | case att == nil: | ||
| 82 | gaps = append(gaps, fmt.Sprintf( | ||
| 83 | "%s: job %q passed for %s but the result is unsigned, so it cannot count as a check; "+ | ||
| 84 | "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha))) | ||
| 85 | case a != nil && !a.CanReport(att.Signer): | ||
| 86 | gaps = append(gaps, fmt.Sprintf( | ||
| 87 | "%s: job %q passed for %s, but %s reported it and is not a runner for this "+ | ||
| 88 | "repository (runners: %s) — so the result is recorded and does not count. "+ | ||
| 89 | "An admin can allow that account with `vole admin acl runners add %s %s`, "+ | ||
| 90 | "or a permitted runner can run the job", | ||
| 91 | wf.Path, job.ID, format.SHA(sha), att.Signer, | ||
| 92 | runnerSummary(a), repo, att.Signer)) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | return gaps, nil | ||
| 97 | } | ||
| 98 | |||
| 99 | // runnerSummary renders the allowlist for a message. Admins are named as implicit | ||
| 100 | // because an operator reading the refusal needs to know who already counts. | ||
| 101 | func runnerSummary(a *access.ACL) string { | ||
| 102 | if len(a.Runners) == 0 { | ||
| 103 | return "none named, so only admins" | ||
| 104 | } | ||
| 105 | out := make([]string, 0, len(a.Runners)) | ||
| 106 | for _, r := range a.Runners { | ||
| 107 | out = append(out, r) | ||
| 108 | } | ||
| 109 | return strings.Join(out, ", ") + " and admins" | ||
| 110 | } | ||