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.

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 }