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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 13.9 KiB
1package 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
13import (
14 "context"
15 "fmt"
16 "strings"
17 "time"
18
19 "rodent.software/vole/ci/runs"
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/forge/landing"
22 "rodent.software/vole/forge/repo"
23 "rodent.software/vole/internal/audit"
24 "rodent.software/vole/internal/format"
25 "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.
31const candidateSeconds = 30
32
33func (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.
49func (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.
95func (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. The changed paths are those of the landing itself, so
177 // a path-filtered workflow is selected by what the merge actually touches.
178 changed, err := changedPaths(m.Git(), baseTip, cand.SHA)
179 if err != nil {
180 s.Log.Warn("ci: cannot read a combined result's paths", "repo", m.Name, "request", req.ID, "err", err)
181 }
182 n := s.queue(m, m.Name, queueSpec{
183 ref: cand.Ref, branch: req.Base, commit: cand.SHA, changed: changed,
184 deployPages: false, push: true, trigger: "push", action: "combined",
185 })
186 // A combined result derived from a contributor head reuses that head's allow
187 // decision. Recording it against the derived ref says both things the checklist
188 // needs: which head was allowed, and which commit was built from it.
189 if _, ok := access.ContributorRefName("refs/heads/" + req.HeadRef); ok {
190 if approver, allowed, err := s.Runs.CIApprover(m.Name, "refs/heads/"+req.HeadRef, head); err != nil {
191 s.Log.Warn("ci: cannot read the contributor approval", "repo", m.Name, "request", req.ID, "err", err)
192 } else if allowed {
193 if err := s.Runs.AllowCI(m.Name, cand.Ref, cand.SHA, approver, now); err != nil {
194 s.Log.Warn("ci: cannot record the combined result allowance", "repo", m.Name, "request", req.ID, "err", err)
195 }
196 }
197 }
198 s.pruneCandidates(m, req, cand.Ref)
199 return n, nil
200}
201
202// headRunnable decides whether the head may be built at all. A contributor's code
203// is only run after a maintainer allowed that exact tip; a trusted branch, or a
204// commit reachable from one, is runnable without a second decision.
205func (s *Server) headRunnable(m *repo.Meta, req *metadata.Request, head string) (bool, error) {
206 if req.HeadRef != "" {
207 ref := "refs/heads/" + req.HeadRef
208 if _, ok := access.ContributorRefName(ref); ok {
209 return s.Runs.CIAllowed(m.Name, ref, head)
210 }
211 // A normal branch is trusted, but only the head the branch can actually
212 // reach. A request file that names a branch and an unrelated commit must not
213 // borrow that branch's trust.
214 tip, err := m.Git().RevParse(ref)
215 if err != nil {
216 return false, err
217 }
218 if tip == "" {
219 return false, nil
220 }
221 if tip == head {
222 return true, nil
223 }
224 return m.Git().IsAncestor(head, tip)
225 }
226 refs, err := m.Git().ListRefs("refs/heads/")
227 if err != nil {
228 return false, err
229 }
230 for ref, tip := range refs {
231 if ref == metadata.Ref || ref == "" || tip == "" {
232 continue
233 }
234 if _, contributor := access.ContributorRefName(ref); contributor {
235 if tip == head {
236 if allowed, err := s.Runs.CIAllowed(m.Name, ref, head); err != nil {
237 return false, err
238 } else if allowed {
239 return true, nil
240 }
241 }
242 continue
243 }
244 // A pinned commit that is already an ancestor of a normal branch is trusted
245 // code. Equality covers a branch tip; the ancestor check covers a commit
246 // pushed to a branch that has since moved.
247 if tip == head {
248 return true, nil
249 }
250 if ancestor, err := m.Git().IsAncestor(head, tip); err == nil && ancestor {
251 return true, nil
252 }
253 }
254 // The contributor branch may be gone while its allow record is not: the allow is
255 // keyed by the commit, which is what lets a pinned request keep running.
256 return s.Runs.CIAllowedAny(m.Name, head)
257}
258
259// pruneCandidates removes combined-result refs for one request except keep. A ref
260// is kept while any of its runs is still queued or running: the runner fetches the
261// commit by id, and deleting the ref underneath it would turn a queued job into a
262// failure. Ref deletion is what makes the commit eligible for a later git gc.
263func (s *Server) pruneCandidates(m *repo.Meta, req *metadata.Request, keep string) {
264 refs, err := landing.CandidateRefs(m.Git(), req.ID)
265 if err != nil {
266 s.Log.Debug("ci: cannot list combined-result refs", "repo", m.Name, "request", req.ID, "err", err)
267 return
268 }
269 for ref, sha := range refs {
270 if ref == keep {
271 continue
272 }
273 pending, err := s.Runs.HasPending(m.Name, sha)
274 if err != nil {
275 s.Log.Warn("ci: cannot check whether a combined result is still running", "repo", m.Name, "ref", ref, "err", err)
276 continue
277 }
278 if pending {
279 continue
280 }
281 if err := m.Git().DeleteRef(ref); err != nil {
282 s.Log.Warn("ci: cannot remove a stale combined-result ref", "repo", m.Name, "ref", ref, "err", err)
283 }
284 }
285}
286
287// watchAutoMerges lands requests whose merger asked for "merge when ready" and
288// whose combined result is green. Every pass re-runs the full gate: an armed row is
289// a reason to look, never an authorisation to land.
290func (s *Server) watchAutoMerges(ctx context.Context) {
291 s.landArmed()
292 t := time.NewTicker(5 * time.Second)
293 defer t.Stop()
294 for {
295 select {
296 case <-ctx.Done():
297 return
298 case <-t.C:
299 s.landArmed()
300 }
301 }
302}
303
304func (s *Server) landArmed() {
305 arms, err := s.Runs.AutoMerges()
306 if err != nil {
307 s.Log.Warn("ci: cannot read merge-when-ready requests", "err", err)
308 return
309 }
310 for _, arm := range arms {
311 s.landArmedOne(arm)
312 }
313}
314
315func (s *Server) landArmedOne(arm runs.AutoMerge) {
316 now := time.Now().UTC()
317 m, err := s.Repos.Open(arm.Repo)
318 if err != nil {
319 s.disarm(arm, "repository is gone")
320 return
321 }
322 req, err := metadata.GetRequest(m.Git(), arm.Request)
323 if err != nil || !req.Open() {
324 s.disarm(arm, "change is no longer open")
325 return
326 }
327 acl, err := m.ACL()
328 if err != nil || acl == nil || !acl.CanTriage(arm.Actor) {
329 s.disarm(arm, "the merger no longer has permission to merge")
330 return
331 }
332 gate := landing.Gate{
333 Repo: m.Git(), Name: m.Name, ACL: acl, Actor: arm.Actor,
334 Role: acl.EffectiveRole(arm.Actor, s.Identity != nil && s.Identity.IsAdmin(arm.Actor)),
335 Runs: func() (*runs.Store, error) { return s.Runs, nil },
336 Audit: func(action, actor, subject, detail string) {
337 _ = audit.Append(s.Cfg.AuditLog(), audit.Record{
338 Action: "auto." + action, Actor: actor, Subject: subject, Detail: detail,
339 })
340 },
341 }
342 // A merge request is a wait, not a one-shot: publish and queue the current
343 // combined result before asking whether it is ready.
344 if _, err := s.ensureRequestCandidate(m, acl, req, true, now); err != nil {
345 s.Log.Warn("ci: cannot prepare an armed landing", "repo", m.Name, "request", req.ID, "err", err)
346 return
347 }
348 plan, err := landing.Prepare(gate, req.ID, arm.Actor, now)
349 if err != nil {
350 s.Log.Warn("ci: cannot prepare an armed landing", "repo", m.Name, "request", req.ID, "err", err)
351 return
352 }
353 switch {
354 case len(plan.Conflicts) > 0:
355 // The author has to decide how the two sides combine; the server must not.
356 s.disarm(arm, "the branch conflicts with its base")
357 return
358 case plan.Blocked != "":
359 // Without approvals there is no path to a landing, and retrying forever
360 // would only hide that from the page.
361 s.disarm(arm, plan.Blocked)
362 return
363 case len(plan.Gaps) > 0:
364 if !plan.Pending {
365 // A red combined result needs a change on the branch, and that change
366 // needs a new approval. Waiting would hide that from the reader.
367 s.disarm(arm, "the combined result failed")
368 }
369 return // still building; the page shows the state
370 case !plan.Ready:
371 return
372 }
373 res, err := landing.Land(gate, req.ID, arm.Actor, now)
374 if err != nil {
375 // A base move between Prepare and Land is the expected race: the next pass
376 // tests the new combination and lands it. Every other refusal has already
377 // been dealt with above, so leave the arm in place for one more look.
378 s.Log.Info("ci: merge-when-ready is waiting", "repo", m.Name, "request", req.ID, "err", err)
379 return
380 }
381 s.disarm(arm, "")
382 if res != nil {
383 s.Log.Info("ci: landed a merge-when-ready change", "repo", m.Name, "request", req.ID,
384 "tip", format.SHA(res.NewTip), "actor", arm.Actor)
385 }
386}
387
388func (s *Server) disarm(arm runs.AutoMerge, why string) {
389 if err := s.Runs.DisarmAutoMerge(arm.Repo, arm.Request); err != nil {
390 s.Log.Warn("ci: cannot clear merge-when-ready", "repo", arm.Repo, "request", arm.Request, "err", err)
391 return
392 }
393 if why != "" {
394 s.Log.Info("ci: merge-when-ready stopped", "repo", arm.Repo, "request", arm.Request, "reason", why)
395 }
396}
397
398// ensureCandidateForWeb is the synchronous half the merge button uses: it publishes
399// and queues the current combined result before the request is armed, so the first
400// look does not wait for the next reconciler tick.
401func (s *Server) ensureCandidateForWeb(repoName, requestID string) error {
402 m, err := s.Repos.Open(repoName)
403 if err != nil {
404 return err
405 }
406 acl, err := m.ACL()
407 if err != nil || acl == nil {
408 return fmt.Errorf("cannot read access rules for %s", repoName)
409 }
410 req, err := metadata.GetRequest(m.Git(), requestID)
411 if err != nil {
412 return err
413 }
414 if !req.Open() {
415 return nil
416 }
417 _, err = s.ensureRequestCandidate(m, acl, req, true, time.Now().UTC())
418 return err
419}
420