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/land.go +67−14

@@ -29,11 +29,32 @@ type Plan struct {
29 29 Base string
30 30 OldTip string
31 31 Head string
32 // NewTip is the commit that would land: Head for a fast-forward, a merge commit
33 // otherwise. Empty when the plan cannot land.
32 // NewTip is the commit that would land: Head for a fast-forward, the published
33 // combined result otherwise. Empty when the plan cannot land.
34 34 NewTip string
35 35 FastForward bool
36 36 MergeCommit bool
37 // CandidateRef is the server-only ref holding NewTip when a merge is needed.
38 // Empty for a fast-forward, which lands the head itself.
39 CandidateRef string
40 // Blocked is why the approval half of the gate would refuse this landing. It is
41 // reported rather than returned as an error so the page can show the combined
42 // result even before approvals arrive.
43 Blocked string
44 // Gaps are the required jobs that have not passed for NewTip. A plan with gaps
45 // is still useful: it says what CI is doing, not that the request is invalid.
46 Gaps []string
47 // Pending is true when every gap is a wait (queued or running) rather than a
48 // failure. It is what the page uses to offer "Merge when ready" instead of
49 // reporting a red result.
50 Pending bool
51 // Checks is true when a workflow applies to the landing tree. It separates
52 // "every required job passed" from "no workflow applies", which need different
53 // words on a page and in `change show`.
54 Checks bool
55 // Ready is true when Land would move the ref now: approvals satisfied (or not
56 // required) and every required job green for the commit that lands.
57 Ready bool
37 58 // AlreadyLanded is true when Head is already in OldTip: there is no ref to move,
38 59 // only the record to write. It is what a retry finds after a landing whose
39 60 // metadata write failed, and what a request landed by a push looks like.
@@ -92,6 +113,13 @@ func Land(g Gate, id, actor string, now time.Time) (*Plan, error) {
92 113 "%s does not merge into %s: %s conflict. Resolve it on the branch and push again",
93 114 format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", "))
94 115 }
116 if plan.Blocked != "" {
117 return plan, errors.New(plan.Blocked)
118 }
119 if len(plan.Gaps) > 0 {
120 return plan, fmt.Errorf("checks have not passed for %s:\n %s",
121 format.SHA(plan.NewTip), strings.Join(plan.Gaps, "\n "))
122 }
95 123 if plan.AlreadyLanded {
96 124 if plan.Request.State == "merged" && plan.Request.MergedHead != "" {
97 125 return plan, nil
@@ -176,6 +204,9 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) {
176 204 } else if landed {
177 205 plan.AlreadyLanded = true
178 206 plan.NewTip = oldTip
207 // There is nothing left to test: the commit is in the branch. Land only has
208 // the record to write, and a caller that armed a merge must not wait forever.
209 plan.Ready = true
179 210 return plan, nil
180 211 }
181 212
@@ -187,7 +218,10 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) {
187 218 // The commit that passed CI is the commit that lands.
188 219 plan.FastForward = true
189 220 } else {
190 tree, conflicts, err := g.Repo.MergeTree(oldTip, head)
221 // Publish the combined result before asking about it: the gate reads the
222 // candidate ref, and the runner needs the ref to fetch the commit. A
223 // conflict is a refusal, not a resolution; the branch has to change.
224 cand, conflicts, err := EnsureCandidate(g.Repo, req, oldTip, head)
191 225 if err != nil {
192 226 return plan, err
193 227 }
@@ -196,22 +230,41 @@ func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) {
196 230 plan.NewTip = ""
197 231 return plan, nil
198 232 }
199 commit, err := g.Repo.CommitMergeTree(tree, []string{oldTip, head},
200 fmt.Sprintf("Merge change request %s: %s", req.ID, req.Title), actor)
233 plan.MergeCommit = true
234 plan.NewTip = cand.SHA
235 plan.CandidateRef = cand.Ref
236 }
237
238 // The approval half of the gate is asked separately from the checks so the page
239 // can show "testing the combined result" instead of treating a queued run as a
240 // refusal.
241 needsApproval := g.RequiresApprovals(branch) && !g.Admin()
242 if needsApproval {
243 merge, refusal, err := g.Approve(branch, plan.NewTip, oldTip)
201 244 if err != nil {
202 245 return plan, err
203 246 }
204 plan.MergeCommit = true
205 plan.NewTip = commit
247 plan.Approval = merge
248 plan.Blocked = refusal
206 249 }
207
208 // The same question the hook would have asked of a push of this commit.
209 merge, err := g.Check(branch, plan.NewTip, oldTip)
210 if err != nil {
211 plan.NewTip = ""
212 return plan, err
250 // The commit whose checks count is the one that lands: the candidate for a
251 // merge, the head for a fast-forward. Even an unapproved request gets this, so
252 // the page can show the combined result before anybody approves.
253 if needsApproval && g.ACL.RequiresChecks() {
254 plan.Checks, err = HasRequiredChecks(g.Repo, plan.NewTip, branch)
255 if err != nil {
256 return plan, err
257 }
258 if plan.Checks {
259 gaps, pending, err := g.CheckSummary(plan.NewTip, branch)
260 if err != nil {
261 return plan, err
262 }
263 plan.Gaps = gaps
264 plan.Pending = pending
265 }
213 266 }
214 plan.Approval = merge
267 plan.Ready = len(plan.Conflicts) == 0 && plan.Blocked == "" && len(plan.Gaps) == 0
215 268 return plan, nil
216 269 }
217 270