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

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 }