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/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.

Test the merge result before landing it

e5478085e5b53a2892d0649d6f07c6f0a5653378 · Keenan Nemetz · 11d ago · parent db5e4382

Test the merge result before landing it

A protected branch could receive a non-fast-forward landing whose tree was
never built: the gate asked for checks on the request head, and the merge
commit was made at landing time. A pushed merge with the right parents but
extra content was accepted too.

Publish the merge commit under refs/vole/candidates/, test it as the base
branch, and make the gate require its results. Approvals name the head and
the base branch, so a moved base retests without a new approval. A merger
who clicks while that run is still going waits for it instead of clicking
again. A pushed merge commit now has to carry exactly the tree a clean merge
would produce.

Closes: 01M61QQ56TESQ1HTMB1Z5C3733

forge/landing/gate.go +131−41

@@ -10,6 +10,7 @@
10 10 package landing
11 11
12 12 import (
13 "errors"
13 14 "fmt"
14 15 "strings"
15 16
@@ -82,15 +83,66 @@ func (g Gate) Check(branch, newTip, oldTip string) (*metadata.Merge, error) {
82 83 if g.Admin() {
83 84 return nil, nil
84 85 }
85 required := g.ACL.RequiredApprovals()
86 if required <= 0 {
87 return nil, fmt.Errorf(
88 "%s is protected and this repository requires approvals_required=0, so only admins may land it (role: %s)",
89 g.Ref(branch), g.Role)
86 m, refusal, err := g.Approve(branch, newTip, oldTip)
87 if err != nil {
88 var mismatch *metadata.TreeMismatchError
89 if errors.As(err, &mismatch) {
90 return nil, fmt.Errorf("%s: %w", g.Ref(branch), err)
91 }
92 return nil, fmt.Errorf("cannot evaluate approvals, refusing the landing: %w", err)
93 }
94 if m == nil {
95 return nil, errors.New(refusal)
90 96 }
97 // Approvals say a person read the change; checks say it works. Both are required
98 // unless the repository turns checks off. For a merge landing the checks are the
99 // ones for the combined result, not for the reviewed head alone: the tree that
100 // lands is the tree that has to pass.
101 if g.ACL.RequiresChecks() {
102 // A repository with no workflows owes no checks, and a merge of two commits
103 // with none cannot acquire one. Ask both sides before demanding a candidate:
104 // publishing one just to discover there is nothing to run would make an
105 // ordinary merge push wait on a job that will never exist.
106 headHas, err := HasRequiredChecks(g.Repo, m.Request.Head, branch)
107 if err != nil {
108 return nil, fmt.Errorf("cannot verify required checks: %w", err)
109 }
110 baseHas, err := HasRequiredChecks(g.Repo, oldTip, branch)
111 if err != nil {
112 return nil, fmt.Errorf("cannot verify required checks: %w", err)
113 }
114 if !headHas && !baseHas {
115 return m, nil
116 }
117 tested, err := g.testedCommit(m.Request, newTip)
118 if err != nil {
119 return nil, err
120 }
121 gaps, err := g.CheckGaps(tested, branch)
122 if err != nil {
123 return nil, fmt.Errorf("cannot verify required checks: %w", err)
124 }
125 if len(gaps) > 0 {
126 return nil, fmt.Errorf("checks have not passed for %s:\n %s",
127 format.SHA(tested), strings.Join(gaps, "\n "))
128 }
129 }
130 return m, nil
131 }
132
133 // Approve answers the approval half of the gate: which request authorises this
134 // landing, or why none does. It is separate from Check so a plan can show the
135 // combined result even while approvals are still missing.
136 func (g Gate) Approve(branch, newTip, oldTip string) (*metadata.Merge, string, error) {
91 137 reqs, err := metadata.LoadRequests(g.Repo, metadata.Ref)
92 138 if err != nil {
93 return nil, fmt.Errorf("cannot read change requests, refusing the landing: %w", err)
139 return nil, "", fmt.Errorf("cannot read change requests: %w", err)
140 }
141 required := g.ACL.RequiredApprovals()
142 if required <= 0 {
143 return nil, fmt.Sprintf(
144 "%s is protected and this repository requires approvals_required=0, so only admins may land it (role: %s)",
145 g.Ref(branch), g.Role), nil
94 146 }
95 147 m, err := metadata.FindApprovedMatch(g.Repo, reqs, metadata.ApprovalRequirement{
96 148 Branch: branch, NewTip: newTip, OldTip: oldTip, Required: required,
@@ -99,28 +151,51 @@ func (g Gate) Check(branch, newTip, oldTip string) (*metadata.Merge, error) {
99 151 Eligible: g.ACL.EligibleApprover,
100 152 })
101 153 if err != nil {
102 return nil, fmt.Errorf("cannot evaluate approvals, refusing the landing: %w", err)
154 return nil, "", err
103 155 }
104 156 if m == nil {
105 return nil, fmt.Errorf(
157 return nil, fmt.Sprintf(
106 158 "%s is protected: %s needs %d approval(s) on an open change request whose head is exactly %s. "+
107 159 "Add requests/<ulid>/request.md with base=%q and head=%s, then have an approver commit approvals/<their-name>.md naming that same head.",
108 g.Ref(branch), g.Actor, required, format.SHA(newTip), branch, format.SHA(newTip))
160 g.Ref(branch), g.Actor, required, format.SHA(newTip), branch, format.SHA(newTip)), nil
109 161 }
110 // Approvals say a person read the change; checks say it works. Both are required
111 // unless the repository turns checks off. The checks are asked of the approved
112 // head, not of the commit being written: CI runs on what was reviewed.
113 if g.ACL.RequiresChecks() {
114 gaps, err := g.CheckGaps(m.Request.Head, branch)
115 if err != nil {
116 return nil, fmt.Errorf("cannot verify required checks: %w", err)
117 }
118 if len(gaps) > 0 {
119 return nil, fmt.Errorf("checks have not passed for %s:\n %s",
120 format.SHA(m.Request.Head), strings.Join(gaps, "\n "))
162 return m, "", nil
163 }
164
165 // testedCommit names the commit whose checks decide this landing. A fast-forward
166 // lands the reviewed head, so that is the commit. A merge lands a tree that only
167 // exists in the published candidate, so the candidate is the commit whose results
168 // count; without one the combined result has not been tested.
169 func (g Gate) testedCommit(req *metadata.Request, newTip string) (string, error) {
170 if newTip == req.Head {
171 return req.Head, nil
172 }
173 c, ok, err := LookupCandidate(g.Repo, req)
174 if err != nil {
175 return "", fmt.Errorf("cannot read the combined result for change request %s: %w", req.ID, err)
176 }
177 if !ok {
178 return "", fmt.Errorf(
179 "the combined result for %s and %s has not been tested yet; Vole tests it automatically, then retry",
180 req.Base, format.SHA(req.Head))
181 }
182 return c.SHA, nil
183 }
184
185 // HasRequiredChecks reports whether any workflow at sha applies to branch. It runs
186 // before the candidate is required: a merge of two commits with no workflows owes
187 // no combined result.
188 func HasRequiredChecks(repo *git.Repo, sha, branch string) (bool, error) {
189 wfs, problems := ci.At(repo, sha)
190 if len(problems) > 0 {
191 return false, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
192 }
193 for _, wf := range wfs {
194 if wf.Applies(branch) {
195 return true, nil
121 196 }
122 197 }
123 return m, nil
198 return false, nil
124 199 }
125 200
126 201 // CheckGaps reports which required CI jobs have not passed for a commit.
@@ -129,9 +204,17 @@ func (g Gate) Check(branch, newTip, oldTip string) (*metadata.Merge, error) {
129 204 // required check is a claim about a commit, and without that rule any writer could
130 205 // satisfy it by running CI on their own machine and saying it passed.
131 206 func (g Gate) CheckGaps(sha, branch string) ([]string, error) {
207 gaps, _, err := g.CheckSummary(sha, branch)
208 return gaps, err
209 }
210
211 // CheckSummary is CheckGaps plus whether every gap is a wait. The page uses the
212 // second answer to tell "testing" from "failed": a queued run is not a reason to
213 // give up, a red or unattributable run is.
214 func (g Gate) CheckSummary(sha, branch string) ([]string, bool, error) {
132 215 wfs, problems := ci.At(g.Repo, sha)
133 216 if len(problems) > 0 {
134 return nil, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
217 return nil, false, fmt.Errorf("cannot read the workflows at %s: %s", format.SHA(sha), strings.Join(problems, "; "))
135 218 }
136 219 var required []*ci.Workflow
137 220 for _, wf := range wfs {
@@ -140,43 +223,50 @@ func (g Gate) CheckGaps(sha, branch string) ([]string, error) {
140 223 }
141 224 }
142 225 if len(required) == 0 {
143 return nil, nil // nothing to run, so nothing to wait for
226 return nil, false, nil // nothing to run, so nothing to wait for
144 227 }
145 228 if g.Runs == nil {
146 return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
229 return nil, false, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
147 230 }
148 231 store, err := g.Runs()
149 232 if err != nil {
150 return nil, fmt.Errorf("cannot open the run queue: %w", err)
233 return nil, false, fmt.Errorf("cannot open the run queue: %w", err)
151 234 }
152 235 if store == nil {
153 return nil, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
236 return nil, false, fmt.Errorf("this repository has workflows but no queue is configured, so required checks can never pass")
154 237 }
155 238
156 239 results, err := store.ForSHA(g.Name, sha)
157 240 if err != nil {
158 return nil, fmt.Errorf("cannot read run results: %w", err)
241 return nil, false, fmt.Errorf("cannot read run results: %w", err)
159 242 }
160 243
161 244 var gaps []string
245 pending := true
246 add := func(problem string, wait bool) {
247 gaps = append(gaps, problem)
248 if !wait {
249 pending = false
250 }
251 }
162 252 for _, wf := range required {
163 253 for _, job := range wf.Jobs {
164 254 run := results[runs.Key(wf.Path, job.ID)]
165 255 switch {
166 256 case run == nil:
167 gaps = append(gaps, fmt.Sprintf(
257 add(fmt.Sprintf(
168 258 "%s: job %q has no run for %s — push the branch and let CI run on it "+
169 259 "(this job filters on [%s]; a commit tested only on other branches has no result here)",
170 wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " ")))
260 wf.Path, job.ID, format.SHA(sha), strings.Join(wf.Branches, " ")), true)
171 261 case run.Status == runs.StatusFailed:
172 gaps = append(gaps, fmt.Sprintf(
173 "%s: job %q failed for %s (exit %d)", wf.Path, job.ID, format.SHA(sha), run.ExitCode))
262 add(fmt.Sprintf(
263 "%s: job %q failed for %s (exit %d)", wf.Path, job.ID, format.SHA(sha), run.ExitCode), false)
174 264 case run.Status == runs.StatusCanceled:
175 gaps = append(gaps, fmt.Sprintf(
176 "%s: job %q was canceled for %s", wf.Path, job.ID, format.SHA(sha)))
265 add(fmt.Sprintf(
266 "%s: job %q was canceled for %s", wf.Path, job.ID, format.SHA(sha)), false)
177 267 case run.Status != runs.StatusSuccess:
178 gaps = append(gaps, fmt.Sprintf(
179 "%s: job %q is %s for %s; wait for it to finish", wf.Path, job.ID, run.Status, format.SHA(sha)))
268 add(fmt.Sprintf(
269 "%s: job %q is %s for %s; wait for it to finish", wf.Path, job.ID, run.Status, format.SHA(sha)), true)
180 270 }
181 271 if run == nil || run.Status != runs.StatusSuccess {
182 272 continue
@@ -185,25 +275,25 @@ func (g Gate) CheckGaps(sha, branch string) ([]string, error) {
185 275 // is a green light from a party with nothing at stake in this branch.
186 276 att, err := store.Attestation(run.ID)
187 277 if err != nil {
188 return nil, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err)
278 return nil, false, fmt.Errorf("cannot read who reported the run for %s: %w", format.SHA(sha), err)
189 279 }
190 280 switch {
191 281 case att == nil:
192 gaps = append(gaps, fmt.Sprintf(
282 add(fmt.Sprintf(
193 283 "%s: job %q passed for %s but the result is unsigned, so it cannot count as a check; "+
194 "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha)))
284 "re-run it with a current `vole runner`", wf.Path, job.ID, format.SHA(sha)), false)
195 285 case !g.ACL.CanReport(att.Signer):
196 gaps = append(gaps, fmt.Sprintf(
286 add(fmt.Sprintf(
197 287 "%s: job %q passed for %s, but %s reported it and is not a runner for this "+
198 288 "repository (runners: %s) — so the result is recorded and does not count. "+
199 289 "An admin can allow that account with `vole admin acl runners add %s %s`, "+
200 290 "or a permitted runner can run the job",
201 291 wf.Path, job.ID, format.SHA(sha), att.Signer,
202 RunnerSummary(g.ACL), g.Name, att.Signer))
292 RunnerSummary(g.ACL), g.Name, att.Signer), false)
203 293 }
204 294 }
205 295 }
206 return gaps, nil
296 return gaps, len(gaps) > 0 && pending, nil
207 297 }
208 298
209 299 // RunnerSummary renders the allowlist for a message. Admins are named as implicit