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.
forge/landing/gate.go +211
| @@ -0,0 +1,211 @@ | |||
| 1 | // Package landing answers one question, "may this commit land on this branch, and | ||
| 2 | // where does it go?", for both callers that can move a branch: the pre-receive hook | ||
| 3 | // and the server's own merge operation. | ||
| 4 | // | ||
| 5 | // It exists because those two callers must not disagree. The hook is the gate a | ||
| 6 | // push meets; a merge performed inside the server moves the ref without running the | ||
| 7 | // hook at all, so if the rule lived only in the hook, the merge button would be the | ||
| 8 | // easiest way to land an unapproved change. One function, called twice, is the whole | ||
| 9 | // design. | ||
| 10 | package landing | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "fmt" | ||
| 14 | "strings" | ||
| 15 | |||
| 16 | "github.com/rodent-software/vole/ci" | ||
| 17 | "github.com/rodent-software/vole/ci/runs" | ||
| 18 | "github.com/rodent-software/vole/core/git" | ||
| 19 | "github.com/rodent-software/vole/forge/access" | ||
| 20 | "github.com/rodent-software/vole/internal/format" | ||
| 21 | "github.com/rodent-software/vole/metadata" | ||
| 22 | ) | ||
| 23 | |||
| 24 | // Gate is what one check needs to know: which repository, whose turn it is, and | ||
| 25 | // what the rules are. It is the same struct for a push and for a merge. | ||
| 26 | type Gate struct { | ||
| 27 | // Repo is the repository the commit lands in. | ||
| 28 | Repo *git.Repo | ||
| 29 | // Name is the repository's name, which is the key the run queue files results | ||
| 30 | // under. | ||
| 31 | Name string | ||
| 32 | // Runs returns the run queue. Nil, or a nil store, means this instance has no | ||
| 33 | // queue, which only matters when a workflow requires a check. It is a function | ||
| 34 | // rather than a path because the two callers hold the queue differently: the | ||
| 35 | // pre-receive hook is a separate process that must open it, and the server | ||
| 36 | // already has it open. Neither is closed here — the caller owns it. | ||
| 37 | Runs func() (*runs.Store, error) | ||
| 38 | // ACL holds the approvals and checks settings for the repository. | ||
| 39 | ACL *access.ACL | ||
| 40 | // Audit receives one line per landing, in the same wording the hook writes. | ||
| 41 | // Nil means the caller records it another way. | ||
| 42 | Audit func(string) | ||
| 43 | // Actor is the account landing the change, and Role is what the ACL says about | ||
| 44 | // it. The two are passed rather than looked up because the hook already resolved | ||
| 45 | // them for this push, and the name it resolved is the one the key owns. | ||
| 46 | Actor string | ||
| 47 | Role access.Role | ||
| 48 | } | ||
| 49 | |||
| 50 | // Ref is the protected ref a landing writes. | ||
| 51 | func (g Gate) Ref(branch string) string { return "refs/heads/" + branch } | ||
| 52 | |||
| 53 | // Admin reports whether this actor keeps the bypass the hook has always had: an | ||
| 54 | // admin may move a protected branch directly, and the caller records that it | ||
| 55 | // happened. | ||
| 56 | func (g Gate) Admin() bool { return g.Role.AtLeast(access.Admin) } | ||
| 57 | |||
| 58 | // RequiresApprovals reports whether this branch is review-gated at all. An | ||
| 59 | // unprotected branch is not: push access was the only rule, and inventing a review | ||
| 60 | // requirement for it here would refuse landings the hook accepts. | ||
| 61 | func (g Gate) RequiresApprovals(branch string) bool { | ||
| 62 | return g.ACL != nil && g.ACL.IsProtected(g.Ref(branch)) | ||
| 63 | } | ||
| 64 | |||
| 65 | // Check decides whether newTip may land on branch, replacing oldTip. It returns the | ||
| 66 | // request that authorised the landing when one did. | ||
| 67 | // | ||
| 68 | // The three answers, which the callers word for their own audience: | ||
| 69 | // | ||
| 70 | // (nil, nil) the landing needs no approval: unprotected branch, or an admin | ||
| 71 | // (merge, nil) an approved request whose head is exactly this commit | ||
| 72 | // (nil, err) refused, and err says why | ||
| 73 | func (g Gate) Check(branch, newTip, oldTip string) (*metadata.Merge, error) { | ||
| 74 | if g.ACL == nil { | ||
| 75 | return nil, fmt.Errorf("no access rules for this repository, refusing the landing") | ||
| 76 | } | ||
| 77 | if !g.RequiresApprovals(branch) { | ||
| 78 | return nil, nil | ||
| 79 | } | ||
| 80 | if g.Admin() { | ||
| 81 | return nil, nil | ||
| 82 | } | ||
| 83 | required := g.ACL.RequiredApprovals() | ||
| 84 | if required <= 0 { | ||
| 85 | return nil, fmt.Errorf( | ||
| 86 | "%s is protected and this repository requires approvals_required=0, so only admins may land it (role: %s)", | ||
| 87 | g.Ref(branch), g.Role) | ||
| 88 | } | ||
| 89 | reqs, err := metadata.LoadRequests(g.Repo, metadata.Ref) | ||
| 90 | if err != nil { | ||
| 91 | return nil, fmt.Errorf("cannot read change requests, refusing the landing: %w", err) | ||
| 92 | } | ||
| 93 | m, err := metadata.FindApprovedMatch(g.Repo, reqs, metadata.ApprovalRequirement{ | ||
| 94 | Branch: branch, NewTip: newTip, OldTip: oldTip, Required: required, | ||
| 95 | }) | ||
| 96 | if err != nil { | ||
| 97 | return nil, fmt.Errorf("cannot evaluate approvals, refusing the landing: %w", err) | ||
| 98 | } | ||
| 99 | if m == nil { | ||
| 100 | return nil, fmt.Errorf( | ||
| 101 | "%s is protected: %s needs %d approval(s) on an open change request whose head is exactly %s. "+ | ||
| 102 | "Add requests/<ulid>/request.md with base=%q and head=%s, then have an approver commit approvals/<their-name>.md naming that same head.", | ||
| 103 | g.Ref(branch), g.Actor, required, format.SHA(newTip), branch, format.SHA(newTip)) | ||
| 104 | } | ||
| 105 | // Approvals say a person read the change; checks say it works. Both are required | ||
| 106 | // unless the repository turns checks off. The checks are asked of the approved | ||
| 107 | // head, not of the commit being written: CI runs on what was reviewed. | ||
| 108 | if g.ACL.RequiresChecks() { | ||
| 109 | gaps, err := g.CheckGaps(m.Request.Head, branch) | ||
| 110 | if err != nil { | ||
| 111 | return nil, fmt.Errorf("cannot verify required checks: %w", err) | ||
| 112 | } | ||
| 113 | if len(gaps) > 0 { | ||
| 114 | return nil, fmt.Errorf("checks have not passed for %s:\n %s", | ||
| 115 | format.SHA(m.Request.Head), strings.Join(gaps, "\n ")) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | return m, nil | ||
| 119 | } | ||
| 120 | |||
| 121 | // CheckGaps reports which required CI jobs have not passed for a commit. | ||
| 122 | // | ||
| 123 | // A green run only counts when the host has allowed the account that reported it: a | ||
| 124 | // required check is a claim about a commit, and without that rule any writer could | ||
| 125 | // satisfy it by running CI on their own machine and saying it passed. | ||
| 126 | func (g Gate) CheckGaps(sha, branch string) ([]string, error) { | ||
| 127 | wfs, problems := ci.At(g.Repo, sha) | ||
| 128 | if len(problems) > 0 { | ||
| 129 | return nil, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; ")) | ||
| 130 | } | ||
| 131 | var required []*ci.Workflow | ||
| 132 | for _, wf := range wfs { | ||
| 133 | if wf.Applies(branch) { | ||
| 134 | required = append(required, wf) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | if len(required) == 0 { | ||
| 138 | return nil, nil // nothing to run, so nothing to wait for | ||
| 139 | } | ||
| 140 | if g.Runs == nil { | ||
| 141 | return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass") | ||
| 142 | } | ||
| 143 | store, err := g.Runs() | ||
| 144 | if err != nil { | ||
| 145 | return nil, fmt.Errorf("cannot open the run queue: %w", err) | ||
| 146 | } | ||
| 147 | if store == nil { | ||
| 148 | return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass") | ||
| 149 | } | ||
| 150 | |||
| 151 | results, err := store.ForSHA(g.Name, sha) | ||
| 152 | if err != nil { | ||
| 153 | return nil, fmt.Errorf("cannot read run results: %w", err) | ||
| 154 | } | ||
| 155 | |||
| 156 | var gaps []string | ||
| 157 | for _, wf := range required { | ||
| 158 | for _, job := range wf.Jobs { | ||
| 159 | run := results[runs.Key(wf.Path, job.ID)] | ||
| 160 | switch { | ||
| 161 | case run == nil: | ||
| 162 | gaps = append(gaps, fmt.Sprintf( | ||
| 163 | "%s: job %q has no run for %s — push the branch and let CI run on it "+ | ||
| 164 | "(this job filters on [%s]; a commit tested only on other branches has no result here)", | ||
| 165 | wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " "))) | ||
| 166 | case run.Status == runs.StatusFailed: | ||
| 167 | gaps = append(gaps, fmt.Sprintf( | ||
| 168 | "%s: job %q failed for %s (exit %d)", wf.Path, job.ID, format.SHA(sha), run.ExitCode)) | ||
| 169 | case run.Status == runs.StatusCanceled: | ||
| 170 | gaps = append(gaps, fmt.Sprintf( | ||
| 171 | "%s: job %q was canceled for %s", wf.Path, job.ID, format.SHA(sha))) | ||
| 172 | case run.Status != runs.StatusSuccess: | ||
| 173 | gaps = append(gaps, fmt.Sprintf( | ||
| 174 | "%s: job %q is %s for %s; wait for it to finish", wf.Path, job.ID, run.Status, format.SHA(sha))) | ||
| 175 | } | ||
| 176 | if run == nil || run.Status != runs.StatusSuccess { | ||
| 177 | continue | ||
| 178 | } | ||
| 179 | // The result has to be attributable to somebody the host allowed, or it | ||
| 180 | // is a green light from a party with nothing at stake in this branch. | ||
| 181 | att, err := store.Attestation(run.ID) | ||
| 182 | if err != nil { | ||
| 183 | return nil, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err) | ||
| 184 | } | ||
| 185 | switch { | ||
| 186 | case att == nil: | ||
| 187 | gaps = append(gaps, fmt.Sprintf( | ||
| 188 | "%s: job %q passed for %s but the result is unsigned, so it cannot count as a check; "+ | ||
| 189 | "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha))) | ||
| 190 | case !g.ACL.CanReport(att.Signer): | ||
| 191 | gaps = append(gaps, fmt.Sprintf( | ||
| 192 | "%s: job %q passed for %s, but %s reported it and is not a runner for this "+ | ||
| 193 | "repository (runners: %s) — so the result is recorded and does not count. "+ | ||
| 194 | "An admin can allow that account with `vole admin acl runners add %s %s`, "+ | ||
| 195 | "or a permitted runner can run the job", | ||
| 196 | wf.Path, job.ID, format.SHA(sha), att.Signer, | ||
| 197 | RunnerSummary(g.ACL), g.Name, att.Signer)) | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } | ||
| 201 | return gaps, nil | ||
| 202 | } | ||
| 203 | |||
| 204 | // RunnerSummary renders the allowlist for a message. Admins are named as implicit | ||
| 205 | // because an operator reading the refusal needs to know who already counts. | ||
| 206 | func RunnerSummary(a *access.ACL) string { | ||
| 207 | if len(a.Runners) == 0 { | ||
| 208 | return "none named, so only admins" | ||
| 209 | } | ||
| 210 | return strings.Join(a.Runners, ", ") + " and admins" | ||
| 211 | } | ||