Why
When a protected branch requires checks, the gate asks for runs of the request head:
// forge/landing/gate.go:111-121
if g.ACL.RequiresChecks() {
gaps, err := g.CheckGaps(m.Request.Head, branch)
...
}
A landing whose base has moved is not that commit. Prepare builds a merge
commit (MergeTree + CommitMergeTree, forge/landing/land.go:190-205) and
moves the base to it, but CheckGaps still looks up m.Request.Head, and
nothing queues a run for the merge commit: server/ci.go only enqueues from
pushes. So for any non-fast-forward landing, the branch receives a tree that
was never built or tested. Both sides can be green and still break when
combined: renamed symbol, changed signature, duplicate registration, migration
order, or a test added to the base that the head never saw.
The same lookup also reads workflows at the head (ci.At(g.Repo, sha)), so a
workflow change on the base does not apply to the landing.
There is a separate hole in the same path. FindApprovedMatch accepts a pushed
merge commit from its parents alone, without checking its tree:
// metadata/requests.go:509-518
if len(parents) == 2 {
matches = (parents[0] == req.OldTip && parents[1] == r.Head) || ...
}
“Merge, then push” (documented at docs/change-requests.md:128-132) can
therefore land content that was neither approved nor tested, as long as the
commit has the right two parents. Any fix should close that too.
Fast-forward-only: what it costs
Requiring FF (current tip is an ancestor of head) does close the test gap: the landed tree is the head tree, and the head’s green run covers it. But as the default it is expensive:
- Approval churn. Approvals name
base+head(metadata/requests.go:529), checks name the head SHA, and contributor CI allow names the exact tip (docs/workflows.md:130-139). Any base move forces every open request to move its head (rebase, or merge the base in), voiding both approvals and checks. Landing N requests costs roughly N(N-1) re-approvals and CI runs; reviewer availability becomes the throughput limit. - Livelock and races. There is no queue or fairness, so under steady traffic
a request can be perpetually behind. Two simultaneous landings used to
self-heal through the retry in
Land(forge/landing/land.go:104-118); with FF-only the loser must go back for a rebase and fresh approval. - Maintainer friction. Non-admin maintainers cannot rewrite their own
non-contrib branches (
server/hook/hook.go:219-227), so rebasing needs an admin, a delete/recreate, or merging the base into the branch. Contributor tips need a fresh Allow CI per rebase. - Pinned and stacked requests. A bare-commit request (
--head <sha>) can never move, and stacked requests need another rebase and head change after the lower one lands. - History. There is no
Merge change request <id>: <title>commit;git log --first-parentloses review units, and reverting becomes a range revert. FF-only also does not force linear history unless merge commits in the branch are separately banned.
It could be an opt-in strict mode for a repository that wants linear history, but it is a poor primary fix.
Options
A. Test the candidate merge commit (recommended direction)
Make the thing that lands the thing that was tested:
- On demand (merge preflight) or when a request is approved, compute the
candidate with the code
Preparealready uses:C = CommitMergeTree(MergeTree(baseTip, head), [baseTip, head]). - Publish
Cunder a server-only ref such asrefs/vole/candidates/<request-id>/<baseTip>-<head>, and enqueue the applicable jobs againstCwithBranch = req.Base. The ref is needed: the runner fetchesorigin <sha>(ci/agent/worktree.go:96-110) and repository creation does not setuploadpack.allowAnySHA1InWant(forge/repo/repo.go), so an unreferenced commit is not fetchable.refs/vole/*is already outside the only writable namespaces (refs/heads/*,refs/tags/*). - Require green required-job results for
C, and read workflows atC(the candidate tree includes both sides). For an FF landingC == head, so nothing changes and no duplicate run is needed. - If the base moves,
Cchanges. The old result is stale and a new run is needed, but approvals stay valid because the head did not move. That is the property FF-only loses.
Two delivery shapes:
- A1, explicit test-then-merge.
vole change test-merge <id>(and a request-page button) publishes and queues the candidate; when it is green, Merge is enabled and the gate checks the candidate for the current base. If the base moved, the button says “stale, test again”. Small surface, no worker, easy to reason about. - A2, landing queue. A worker leases approved requests, computes the candidate, waits for green, lands, and comments on red. Base moves are retried automatically and approvals never churn. More operational machinery: queue state, fairness, timeouts, cancellation. The right end state if traffic is steady.
Keep conflict resolution out of the server. If MergeTree conflicts, or the
tree needs manual resolution, stop and require the author to push a new head
(which needs fresh approval). A server that resolves conflicts produces a
commit nobody reviewed.
B. Serialized merge train (Bors/GitHub merge queue)
Queue approved requests, merge them in order into a candidate train, test the train, land it on green, and retry or bisect on red. Highest throughput under contention, and catches cross-request interactions; also the most machinery. Only worth it if A2’s per-request cost becomes the bottleneck.
C. Verify the landed tree without testing it (partial, do anyway)
For a pushed merge commit, require its tree to equal MergeTree(oldTip, head);
reject a two-parent commit that carries extra or different content. This closes
the “merge, then push” hole from metadata/requests.go:509-518 and makes the
approval attestation mean what it says. It does not test the merge, so it is a
hardening step, not the fix. It also means a human-resolved merge cannot ride
an old approval; it must become a new head and be re-approved and re-tested.
D. Require up to date / FF-only (rejected as default)
See above. Acceptable as an opt-in branch policy (require_up_to_date, or a
linear-history rule) for repositories that value that history more than
throughput.
E. Content-addressed approvals (escape hatch for FF-only)
If FF-only is kept, approve a patch fingerprint (merge-base..head diff) rather than a SHA, so a clean rebase does not need re-approval. This weakens “approval names the exact commit”, needs a careful fingerprint definition, and must invalidate on conflict resolution. It reduces the O(N^2) approval cost but not the CI cost or the rebase traps, so it patches D rather than replacing A.
F. Warn only
Show “merge result untested” and keep landing. Rejected: the gate would still report green for a commit other than the one that lands, which is the bug.
Recommendation
Do C now (small, closes a security-relevant hole), then A1 as the minimum fix,
growing to A2 if merge traffic makes explicit preflight annoying. Treat D as an
optional strict mode, not the default. Do not rely on merge-tree/conflict
checks as a proxy; they catch only textual conflicts.
Scope / checklist
- Decide scope: all protected branches, or only when
checks_requiredis on. - Candidate ref namespace and lifecycle; GC must keep a candidate while a run needs it.
- Enqueue candidate runs with the base branch filter; avoid duplicate runs when the candidate is unchanged.
- Gate: require the candidate result for the landing tree, and keep the head-only path for FF.
- Harden
FindApprovedMatchwith tree equality (independent of the rest). - Contributor allow-CI semantics for derived candidates: allow the head tip, then the candidate derived from it; record both.
- UI states: untested, testing, passed, failed, stale base, conflict; Merge disabled until green.
- Docs: change-requests “How approvals work”, workflows/runner “what
runs”,
change show; remove or qualify “merge, then push”. - Tests: gate candidate matching, tree verification, base-move invalidation, contributor policy, queue races.
Acceptance criteria
- A non-FF landing is refused unless the required jobs are green for a commit whose parents are the current base tip and the request head, and whose tree is the landing tree.
- An FF landing still needs only head checks and does not queue a duplicate run.
- Moving the base invalidates the candidate result but not the head approval; no re-approval is required.
- A manually pushed merge commit is accepted only if its tree equals
MergeTree(parents)and its candidate checks pass; otherwise the push is refused with a message naming the mismatch. - Workflow definitions used for required checks come from the landing tree, so base-side workflow changes apply.
- Candidate refs are server-only, excluded from branch listings, and do not survive indefinitely.
- Contributor candidates run only after the exact head tip has been allowed; the allow record names the head and the derived candidate.
- The request page and
vole change showsay whether the current landing tree has been tested, and the Merge control agrees with the gate.
Related: #11 (CI runner trust boundary), #20 (merge action), #45 (numbering).