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
server/candidates.go +411
| @@ -0,0 +1,411 @@ | |||
| 1 | package server | ||
| 2 | |||
| 3 | // Combined-result testing and merge-when-ready. | ||
| 4 | // | ||
| 5 | // A non-fast-forward landing writes a merge commit that exists nowhere until Vole | ||
| 6 | // makes it. This file makes that commit, publishes it under refs/vole/candidates/, | ||
| 7 | // and queues the workflows at that tree, so the thing that lands is the thing that | ||
| 8 | // was tested. The trigger policy is deliberately eager on pushes and conservative on | ||
| 9 | // base moves: a head is always tested, but a base move only retests requests | ||
| 10 | // somebody is reviewing or has already asked to land. That keeps abandoned work from | ||
| 11 | // costing a full CI run every time a branch moves. | ||
| 12 | |||
| 13 | import ( | ||
| 14 | "context" | ||
| 15 | "fmt" | ||
| 16 | "strings" | ||
| 17 | "time" | ||
| 18 | |||
| 19 | "github.com/rodent-software/vole/ci/runs" | ||
| 20 | "github.com/rodent-software/vole/forge/access" | ||
| 21 | "github.com/rodent-software/vole/forge/landing" | ||
| 22 | "github.com/rodent-software/vole/forge/repo" | ||
| 23 | "github.com/rodent-software/vole/internal/audit" | ||
| 24 | "github.com/rodent-software/vole/internal/format" | ||
| 25 | "github.com/rodent-software/vole/metadata" | ||
| 26 | ) | ||
| 27 | |||
| 28 | // candidateSeconds is how often open requests are examined for a combined result | ||
| 29 | // that is missing or stale. The queue is idempotent, so a pass that has nothing to | ||
| 30 | // do costs a ref read per request. | ||
| 31 | const candidateSeconds = 30 | ||
| 32 | |||
| 33 | func (s *Server) watchCandidates(ctx context.Context) { | ||
| 34 | s.reconcileCandidates() | ||
| 35 | t := time.NewTicker(candidateSeconds * time.Second) | ||
| 36 | defer t.Stop() | ||
| 37 | for { | ||
| 38 | select { | ||
| 39 | case <-ctx.Done(): | ||
| 40 | return | ||
| 41 | case <-t.C: | ||
| 42 | s.reconcileCandidates() | ||
| 43 | } | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | // reconcileCandidates publishes and queues the combined result for requests that | ||
| 48 | // are ready to be tested. | ||
| 49 | func (s *Server) reconcileCandidates() { | ||
| 50 | repos, err := s.Repos.List() | ||
| 51 | if err != nil { | ||
| 52 | s.Log.Warn("ci: cannot list repositories for combined results", "err", err) | ||
| 53 | return | ||
| 54 | } | ||
| 55 | now := time.Now().UTC() | ||
| 56 | queued := 0 | ||
| 57 | for _, m := range repos { | ||
| 58 | acl, err := m.ACL() | ||
| 59 | if err != nil { | ||
| 60 | s.Log.Warn("ci: cannot read access rules for combined results", "repo", m.Name, "err", err) | ||
| 61 | continue | ||
| 62 | } | ||
| 63 | reqs, err := metadata.LoadRequests(m.Git(), metadata.Ref) | ||
| 64 | if err != nil { | ||
| 65 | s.Log.Warn("ci: cannot read change requests for combined results", "repo", m.Name, "err", err) | ||
| 66 | continue | ||
| 67 | } | ||
| 68 | for _, req := range reqs { | ||
| 69 | if req.State != "open" || req.Head == "" { | ||
| 70 | // A closed or merged request has no landing to test. Its refs can go | ||
| 71 | // once the runs that were using them have finished. | ||
| 72 | s.pruneCandidates(m, req, "") | ||
| 73 | continue | ||
| 74 | } | ||
| 75 | if acl == nil || !acl.RequiresChecks() || !acl.IsProtected("refs/heads/"+req.Base) { | ||
| 76 | continue | ||
| 77 | } | ||
| 78 | n, err := s.ensureRequestCandidate(m, acl, req, false, now) | ||
| 79 | if err != nil { | ||
| 80 | s.Log.Warn("ci: cannot test a combined result", "repo", m.Name, "request", req.ID, "err", err) | ||
| 81 | continue | ||
| 82 | } | ||
| 83 | queued += n | ||
| 84 | } | ||
| 85 | } | ||
| 86 | if queued > 0 { | ||
| 87 | s.Log.Info("ci: queued combined results", "runs", queued) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | // ensureRequestCandidate makes sure the combined result for req is published and | ||
| 92 | // queued. force is for the caller that has just been asked to merge: it tests even | ||
| 93 | // when the base moved and the request has no approval yet, because the merger is | ||
| 94 | // waiting. It returns how many runs were newly queued. | ||
| 95 | func (s *Server) ensureRequestCandidate(m *repo.Meta, acl *access.ACL, req *metadata.Request, force bool, now time.Time) (int, error) { | ||
| 96 | baseTip, err := m.Git().RevParse("refs/heads/" + req.Base) | ||
| 97 | if err != nil { | ||
| 98 | return 0, err | ||
| 99 | } | ||
| 100 | head, err := m.Git().RevParse(req.Head) | ||
| 101 | if err != nil { | ||
| 102 | return 0, err | ||
| 103 | } | ||
| 104 | if baseTip == "" || head == "" { | ||
| 105 | return 0, nil | ||
| 106 | } | ||
| 107 | // Nothing to merge when the head is already in the base, or when the base is | ||
| 108 | // already in the head: the first is a landing that happened, the second is a | ||
| 109 | // fast-forward whose head checks are the ones that count. | ||
| 110 | if landed, err := m.Git().IsAncestor(head, baseTip); err != nil { | ||
| 111 | return 0, err | ||
| 112 | } else if landed { | ||
| 113 | s.pruneCandidates(m, req, "") | ||
| 114 | return 0, nil | ||
| 115 | } | ||
| 116 | if ff, err := m.Git().IsAncestor(baseTip, head); err != nil { | ||
| 117 | return 0, err | ||
| 118 | } else if ff { | ||
| 119 | s.pruneCandidates(m, req, "") | ||
| 120 | return 0, nil | ||
| 121 | } | ||
| 122 | // With no workflow on either side there is no job a combined result could run. | ||
| 123 | headChecks, err := landing.HasRequiredChecks(m.Git(), head, req.Base) | ||
| 124 | if err != nil { | ||
| 125 | return 0, err | ||
| 126 | } | ||
| 127 | baseChecks, err := landing.HasRequiredChecks(m.Git(), baseTip, req.Base) | ||
| 128 | if err != nil { | ||
| 129 | return 0, err | ||
| 130 | } | ||
| 131 | if !headChecks && !baseChecks { | ||
| 132 | return 0, nil | ||
| 133 | } | ||
| 134 | |||
| 135 | refs, err := landing.CandidateRefs(m.Git(), req.ID) | ||
| 136 | if err != nil { | ||
| 137 | return 0, err | ||
| 138 | } | ||
| 139 | newHead := true | ||
| 140 | for ref := range refs { | ||
| 141 | if strings.HasSuffix(ref, "-"+head) { | ||
| 142 | newHead = false | ||
| 143 | break | ||
| 144 | } | ||
| 145 | } | ||
| 146 | required := acl.RequiredApprovals() | ||
| 147 | approved := required > 0 && req.CurrentApprovalsBy(acl.EligibleApprover) >= required | ||
| 148 | armed, err := s.Runs.AutoMerge(m.Name, req.ID) | ||
| 149 | if err != nil { | ||
| 150 | return 0, err | ||
| 151 | } | ||
| 152 | if !force && !newHead && !approved && armed == nil { | ||
| 153 | // The base moved under a request nobody is reviewing and nobody asked to | ||
| 154 | // land. Its old result is stale, but spending a run on every such request | ||
| 155 | // for every base move is worse than waiting for an approval or a Merge click. | ||
| 156 | return 0, nil | ||
| 157 | } | ||
| 158 | runnable, err := s.headRunnable(m, req, head) | ||
| 159 | if err != nil { | ||
| 160 | return 0, err | ||
| 161 | } | ||
| 162 | if !runnable { | ||
| 163 | s.Log.Debug("ci: combined result is waiting for the contributor head to be allowed", | ||
| 164 | "repo", m.Name, "request", req.ID, "head", format.SHA(head)) | ||
| 165 | return 0, nil | ||
| 166 | } | ||
| 167 | |||
| 168 | cand, conflicts, err := landing.EnsureCandidate(m.Git(), req, baseTip, head) | ||
| 169 | if err != nil { | ||
| 170 | return 0, err | ||
| 171 | } | ||
| 172 | if len(conflicts) > 0 { | ||
| 173 | return 0, nil // the page names the paths; a server does not resolve them | ||
| 174 | } | ||
| 175 | // Queueing again is idempotent and repairs a queue whose rows were deleted | ||
| 176 | // without losing the ref. | ||
| 177 | n := s.queue(m, m.Name, cand.Ref, req.Base, "", cand.SHA, false, false) | ||
| 178 | // A combined result derived from a contributor head reuses that head's allow | ||
| 179 | // decision. Recording it against the derived ref says both things the checklist | ||
| 180 | // needs: which head was allowed, and which commit was built from it. | ||
| 181 | if _, ok := access.ContributorRefName("refs/heads/" + req.HeadRef); ok { | ||
| 182 | if approver, allowed, err := s.Runs.CIApprover(m.Name, "refs/heads/"+req.HeadRef, head); err != nil { | ||
| 183 | s.Log.Warn("ci: cannot read the contributor approval", "repo", m.Name, "request", req.ID, "err", err) | ||
| 184 | } else if allowed { | ||
| 185 | if err := s.Runs.AllowCI(m.Name, cand.Ref, cand.SHA, approver, now); err != nil { | ||
| 186 | s.Log.Warn("ci: cannot record the combined result allowance", "repo", m.Name, "request", req.ID, "err", err) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 190 | s.pruneCandidates(m, req, cand.Ref) | ||
| 191 | return n, nil | ||
| 192 | } | ||
| 193 | |||
| 194 | // headRunnable decides whether the head may be built at all. A contributor's code | ||
| 195 | // is only run after a maintainer allowed that exact tip; a trusted branch, or a | ||
| 196 | // commit reachable from one, is runnable without a second decision. | ||
| 197 | func (s *Server) headRunnable(m *repo.Meta, req *metadata.Request, head string) (bool, error) { | ||
| 198 | if req.HeadRef != "" { | ||
| 199 | ref := "refs/heads/" + req.HeadRef | ||
| 200 | if _, ok := access.ContributorRefName(ref); ok { | ||
| 201 | return s.Runs.CIAllowed(m.Name, ref, head) | ||
| 202 | } | ||
| 203 | // A normal branch is trusted, but only the head the branch can actually | ||
| 204 | // reach. A request file that names a branch and an unrelated commit must not | ||
| 205 | // borrow that branch's trust. | ||
| 206 | tip, err := m.Git().RevParse(ref) | ||
| 207 | if err != nil { | ||
| 208 | return false, err | ||
| 209 | } | ||
| 210 | if tip == "" { | ||
| 211 | return false, nil | ||
| 212 | } | ||
| 213 | if tip == head { | ||
| 214 | return true, nil | ||
| 215 | } | ||
| 216 | return m.Git().IsAncestor(head, tip) | ||
| 217 | } | ||
| 218 | refs, err := m.Git().ListRefs("refs/heads/") | ||
| 219 | if err != nil { | ||
| 220 | return false, err | ||
| 221 | } | ||
| 222 | for ref, tip := range refs { | ||
| 223 | if ref == metadata.Ref || ref == "" || tip == "" { | ||
| 224 | continue | ||
| 225 | } | ||
| 226 | if _, contributor := access.ContributorRefName(ref); contributor { | ||
| 227 | if tip == head { | ||
| 228 | if allowed, err := s.Runs.CIAllowed(m.Name, ref, head); err != nil { | ||
| 229 | return false, err | ||
| 230 | } else if allowed { | ||
| 231 | return true, nil | ||
| 232 | } | ||
| 233 | } | ||
| 234 | continue | ||
| 235 | } | ||
| 236 | // A pinned commit that is already an ancestor of a normal branch is trusted | ||
| 237 | // code. Equality covers a branch tip; the ancestor check covers a commit | ||
| 238 | // pushed to a branch that has since moved. | ||
| 239 | if tip == head { | ||
| 240 | return true, nil | ||
| 241 | } | ||
| 242 | if ancestor, err := m.Git().IsAncestor(head, tip); err == nil && ancestor { | ||
| 243 | return true, nil | ||
| 244 | } | ||
| 245 | } | ||
| 246 | // The contributor branch may be gone while its allow record is not: the allow is | ||
| 247 | // keyed by the commit, which is what lets a pinned request keep running. | ||
| 248 | return s.Runs.CIAllowedAny(m.Name, head) | ||
| 249 | } | ||
| 250 | |||
| 251 | // pruneCandidates removes combined-result refs for one request except keep. A ref | ||
| 252 | // is kept while any of its runs is still queued or running: the runner fetches the | ||
| 253 | // commit by id, and deleting the ref underneath it would turn a queued job into a | ||
| 254 | // failure. Ref deletion is what makes the commit eligible for a later git gc. | ||
| 255 | func (s *Server) pruneCandidates(m *repo.Meta, req *metadata.Request, keep string) { | ||
| 256 | refs, err := landing.CandidateRefs(m.Git(), req.ID) | ||
| 257 | if err != nil { | ||
| 258 | s.Log.Debug("ci: cannot list combined-result refs", "repo", m.Name, "request", req.ID, "err", err) | ||
| 259 | return | ||
| 260 | } | ||
| 261 | for ref, sha := range refs { | ||
| 262 | if ref == keep { | ||
| 263 | continue | ||
| 264 | } | ||
| 265 | pending, err := s.Runs.HasPending(m.Name, sha) | ||
| 266 | if err != nil { | ||
| 267 | s.Log.Warn("ci: cannot check whether a combined result is still running", "repo", m.Name, "ref", ref, "err", err) | ||
| 268 | continue | ||
| 269 | } | ||
| 270 | if pending { | ||
| 271 | continue | ||
| 272 | } | ||
| 273 | if err := m.Git().DeleteRef(ref); err != nil { | ||
| 274 | s.Log.Warn("ci: cannot remove a stale combined-result ref", "repo", m.Name, "ref", ref, "err", err) | ||
| 275 | } | ||
| 276 | } | ||
| 277 | } | ||
| 278 | |||
| 279 | // watchAutoMerges lands requests whose merger asked for "merge when ready" and | ||
| 280 | // whose combined result is green. Every pass re-runs the full gate: an armed row is | ||
| 281 | // a reason to look, never an authorisation to land. | ||
| 282 | func (s *Server) watchAutoMerges(ctx context.Context) { | ||
| 283 | s.landArmed() | ||
| 284 | t := time.NewTicker(5 * time.Second) | ||
| 285 | defer t.Stop() | ||
| 286 | for { | ||
| 287 | select { | ||
| 288 | case <-ctx.Done(): | ||
| 289 | return | ||
| 290 | case <-t.C: | ||
| 291 | s.landArmed() | ||
| 292 | } | ||
| 293 | } | ||
| 294 | } | ||
| 295 | |||
| 296 | func (s *Server) landArmed() { | ||
| 297 | arms, err := s.Runs.AutoMerges() | ||
| 298 | if err != nil { | ||
| 299 | s.Log.Warn("ci: cannot read merge-when-ready requests", "err", err) | ||
| 300 | return | ||
| 301 | } | ||
| 302 | for _, arm := range arms { | ||
| 303 | s.landArmedOne(arm) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | func (s *Server) landArmedOne(arm runs.AutoMerge) { | ||
| 308 | now := time.Now().UTC() | ||
| 309 | m, err := s.Repos.Open(arm.Repo) | ||
| 310 | if err != nil { | ||
| 311 | s.disarm(arm, "repository is gone") | ||
| 312 | return | ||
| 313 | } | ||
| 314 | req, err := metadata.GetRequest(m.Git(), arm.Request) | ||
| 315 | if err != nil || !req.Open() { | ||
| 316 | s.disarm(arm, "change is no longer open") | ||
| 317 | return | ||
| 318 | } | ||
| 319 | acl, err := m.ACL() | ||
| 320 | if err != nil || acl == nil || !acl.CanTriage(arm.Actor) { | ||
| 321 | s.disarm(arm, "the merger no longer has permission to merge") | ||
| 322 | return | ||
| 323 | } | ||
| 324 | gate := landing.Gate{ | ||
| 325 | Repo: m.Git(), Name: m.Name, ACL: acl, Actor: arm.Actor, | ||
| 326 | Role: acl.EffectiveRole(arm.Actor, s.Identity != nil && s.Identity.IsAdmin(arm.Actor)), | ||
| 327 | Runs: func() (*runs.Store, error) { return s.Runs, nil }, | ||
| 328 | Audit: func(action, actor, subject, detail string) { | ||
| 329 | _ = audit.Append(s.Cfg.AuditLog(), audit.Record{ | ||
| 330 | Action: "auto." + action, Actor: actor, Subject: subject, Detail: detail, | ||
| 331 | }) | ||
| 332 | }, | ||
| 333 | } | ||
| 334 | // A merge request is a wait, not a one-shot: publish and queue the current | ||
| 335 | // combined result before asking whether it is ready. | ||
| 336 | if _, err := s.ensureRequestCandidate(m, acl, req, true, now); err != nil { | ||
| 337 | s.Log.Warn("ci: cannot prepare an armed landing", "repo", m.Name, "request", req.ID, "err", err) | ||
| 338 | return | ||
| 339 | } | ||
| 340 | plan, err := landing.Prepare(gate, req.ID, arm.Actor, now) | ||
| 341 | if err != nil { | ||
| 342 | s.Log.Warn("ci: cannot prepare an armed landing", "repo", m.Name, "request", req.ID, "err", err) | ||
| 343 | return | ||
| 344 | } | ||
| 345 | switch { | ||
| 346 | case len(plan.Conflicts) > 0: | ||
| 347 | // The author has to decide how the two sides combine; the server must not. | ||
| 348 | s.disarm(arm, "the branch conflicts with its base") | ||
| 349 | return | ||
| 350 | case plan.Blocked != "": | ||
| 351 | // Without approvals there is no path to a landing, and retrying forever | ||
| 352 | // would only hide that from the page. | ||
| 353 | s.disarm(arm, plan.Blocked) | ||
| 354 | return | ||
| 355 | case len(plan.Gaps) > 0: | ||
| 356 | if !plan.Pending { | ||
| 357 | // A red combined result needs a change on the branch, and that change | ||
| 358 | // needs a new approval. Waiting would hide that from the reader. | ||
| 359 | s.disarm(arm, "the combined result failed") | ||
| 360 | } | ||
| 361 | return // still building; the page shows the state | ||
| 362 | case !plan.Ready: | ||
| 363 | return | ||
| 364 | } | ||
| 365 | res, err := landing.Land(gate, req.ID, arm.Actor, now) | ||
| 366 | if err != nil { | ||
| 367 | // A base move between Prepare and Land is the expected race: the next pass | ||
| 368 | // tests the new combination and lands it. Every other refusal has already | ||
| 369 | // been dealt with above, so leave the arm in place for one more look. | ||
| 370 | s.Log.Info("ci: merge-when-ready is waiting", "repo", m.Name, "request", req.ID, "err", err) | ||
| 371 | return | ||
| 372 | } | ||
| 373 | s.disarm(arm, "") | ||
| 374 | if res != nil { | ||
| 375 | s.Log.Info("ci: landed a merge-when-ready change", "repo", m.Name, "request", req.ID, | ||
| 376 | "tip", format.SHA(res.NewTip), "actor", arm.Actor) | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 380 | func (s *Server) disarm(arm runs.AutoMerge, why string) { | ||
| 381 | if err := s.Runs.DisarmAutoMerge(arm.Repo, arm.Request); err != nil { | ||
| 382 | s.Log.Warn("ci: cannot clear merge-when-ready", "repo", arm.Repo, "request", arm.Request, "err", err) | ||
| 383 | return | ||
| 384 | } | ||
| 385 | if why != "" { | ||
| 386 | s.Log.Info("ci: merge-when-ready stopped", "repo", arm.Repo, "request", arm.Request, "reason", why) | ||
| 387 | } | ||
| 388 | } | ||
| 389 | |||
| 390 | // ensureCandidateForWeb is the synchronous half the merge button uses: it publishes | ||
| 391 | // and queues the current combined result before the request is armed, so the first | ||
| 392 | // look does not wait for the next reconciler tick. | ||
| 393 | func (s *Server) ensureCandidateForWeb(repoName, requestID string) error { | ||
| 394 | m, err := s.Repos.Open(repoName) | ||
| 395 | if err != nil { | ||
| 396 | return err | ||
| 397 | } | ||
| 398 | acl, err := m.ACL() | ||
| 399 | if err != nil || acl == nil { | ||
| 400 | return fmt.Errorf("cannot read access rules for %s", repoName) | ||
| 401 | } | ||
| 402 | req, err := metadata.GetRequest(m.Git(), requestID) | ||
| 403 | if err != nil { | ||
| 404 | return err | ||
| 405 | } | ||
| 406 | if !req.Open() { | ||
| 407 | return nil | ||
| 408 | } | ||
| 409 | _, err = s.ensureRequestCandidate(m, acl, req, true, time.Now().UTC()) | ||
| 410 | return err | ||
| 411 | } | ||