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