Repository navigation
silent revert: a branch built on an old main deletes work main just merged, while CI stays green #914
Description
Reported 2026-10-24 by the lead, after the fourth occurrence in a day: "this is the third time this class has happened (#864, #876, #887, and today #907) — stop adding reminders, add a mechanical gate."
Each time, a branch built from an old copy of main re-uploads whole files, and work that main merged minutes earlier disappears. CI stays green because the deletion is part of the pull request. mergeable: true, tests-ok green, and the previous fix is gone.
The occurrences (all real, all on this repository)
| PR | What it silently deleted |
|---|---|
#887 |
#869's 84 lines of tests, merged the same hour (+34/−84 instead of +19/−0) |
#876 |
same shape |
#864 |
same shape |
#907 @ d6e143a8 |
#909's whole fix, merged 40 minutes earlier: verifiedAuthor, refused: 'not-inviter', refused: 'unknown-room', the pure-transport fromHandle/fromHome — and #909's three test cases; #909 appears 0 times in that branch's core/test, 4 times on main |
The rule, stated so it can be wrong
Delimit with the merge base. For a pull request, let base = merge-base(origin/main, HEAD).
A= the lines thatorigin/mainadded afterbase— per file, the added lines ofgit diff <base> origin/main -- <file>.D= the lines the pull request deletes fromorigin/main— per file, the removed lines ofgit diff origin/main HEAD -- <file>.A ∩ Dnon-empty = silent revert. Report each file with the intersecting line text, the count, and the commit that introduced it (git log -Sover<base>..origin/mainfor that file, or the first commit whose diff adds that line).
Set intersection, no blame walk needed for the hit itself — the introducing commit is only for the message.
Why this is not "the PR deletes lines". #910 replaced 18 lines of the inbound trust path with verifiedAuthor and merged; its deletions are its own, and at its base A was empty, so A ∩ D = ∅ — zero report. That is the negative control, and it must stay zero. A branch that re-uploads a stale file has A ∩ D ≠ ∅ by construction, because A is exactly the work it never saw.
Where it goes
Report-only, in the same shape as .github/scripts/agent-audience.mjs (#912) and .github/scripts/review-gate.mjs: read-only token, continue-on-error: true, writes $GITHUB_STEP_SUMMARY and one ::warning per file, never labels, comments, reviews or merges, and is not added to branch protection or to tests-ok's needs.
Concretely: a step in .github/workflows/merge-gate.yml's pull_request job (that job already never fails on pull_request, by its own design), running .github/scripts/silent-revert.mjs checked out from the base commit — the same rule the rest of that workflow follows, because this step must not run code a pull request supplies. It needs full history: the job has to fetch origin/main and the merge base (fetch-depth: 0 or an explicit git fetch origin main), so say how you did it in the PR.
Line shape, so the round driver can read it without opening the run: #<pr> @<head-sha7>: <file>: <n> line(s) main added after <base-sha7> are deleted here (from <commit-sha7>). Print a one-line total, and print silent-revert: 0 finding(s) on <n> file(s) when clean — a run that says nothing is indistinguishable from a run that did not execute, which is the same trap #912 hit on its first live run.
Scope
- The rule above, per file the pull request touches, skipping files it does not touch (that keeps it cheap).
- Only
origin/maincounts as truth. NotHEAD~1, not the branch's base. - A file the pull request adds cannot revert anything; a file it deletes whole is reported as a deletion with the count, not as N line reports.
- No bypass trailer and no allow-list. This is report-only and it does not block; a legitimate revert simply carries the report, and a Reviewer decides. Adding a silence switch is how a gate becomes decoration.
- Mark each finding
testorsourceby path, and print the two totals separately, so one noisy file cannot hide the other class. If the first week shows mostly test-file noise, the fix is to refine the rule, not to widen it by hand.
Acceptance
- Replay
#907 @ d6e143a8: red, namingcore/src/host/room-membership.jsand pointing at#910'sverifiedAuthor/refusedlines and the three#909cases. Fixture: the real two trees, or the recorded diff, insilent-revert.test.mjs. - Replay
#887's old tip: red, naming the 84 restored test lines. - Negative control
#910 @ 07dee97d: zero findings. Its 18 deleted lines were its own replacement, andAwas empty at its base. If this one reports, the rule is wrong and the PR is not ready. - A pull request that only adds or edits lines reports nothing (no deletions, empty
D). - A pull request rebased onto the current
mainreports nothing: withbase = origin/main,Ais empty by definition. This is the case every Dev should land in, and the test pins it. - The workflow keeps its existing contract:
merge-gatestill does not fail onpull_request, and the new step's failure cannot turn the job red. - The run prints the zero line when clean (no silent success).
-
scripts/test-workflow.test.mjspasses with the editedmerge-gate.yml.
Not in scope
- Deciding whether a revert was intended. That is a human read of the report.
- Enforcing anything. If this proves reliable over a week, making it a required check is a separate owner decision and a separate issue.
#907's own fix — that stays with#907and its rebase; this issue only builds the thing that would have caught it.
Related
#911 / #912 — the other report-only check, and the shape this one copies; #909 / #910 — the work #907 silently reverted, and the negative control; #887, #876, #864 — earlier occurrences; #689 — audit-merges.mjs, which audits queue bypass (a different question: that script asks whether a merge went through the queue, not whether it kept main's work).
Activity
Report a silent revert when a branch deletes work main just merged
Report a silent revert when a branch deletes work main just merged
Report a silent revert when a branch deletes work main just merged