bug(fragment-check): a two-dot diff against base.sha makes a PR inherit a cut's deleted fragments — and a forgotten fragment PASSES #1028
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#1028
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
reusable-changelog-fragment-check.ymlcomputes its change set with a two-dot diff againstpull_request.base.sha, so a PR open across a release cut inherits the cut's deleted fragments as its own — and in one direction that is a silent PASS on the exact case the gate exists to catch.Verified at source
Two compounding problems, both already recorded in
CLAUDE.md:base.shais a LIVE POINTER to the target branch's current tip, not the fork pointgit diff A Bis the TWO-DOT form — it compares the two commits, not what the branch addedSo it compares main's tip against the PR head. A release cut CONSUMES
changelog.d, so every PR open across a cut sees the cut's deletions as its own changes.Measured on a live branch before rebasing:
🔴 Both directions are real, and the quiet one is the dangerous one
changedFragmentPaths(internal/gates/fragment_coverage.go) matches on NAME ONLY. Confirmed: zeroos.Stat,ReadFileor existence checks in the function. A deleted path counts as present.🔑 So the gate that exists to catch a missing fragment can be satisfied by a fragment that main deleted. The false FAIL gets investigated; the false PASS is indistinguishable from a correct one.
Fix
git merge-base— or the three-dot form — so the change set is what the branch ADDEDConsider an existence check in— DEFERRED → #1033, which owns the deletes-a-fragment case. Three-dot still includes the branch's own deletions, so the population shrank rather than closed. Original text: a path that does not exist on the head is not a fragment this PR provideschangedFragmentPathsas defence in depth:⚠️ Not fixed in
#1027— that is a docs PR and this is a CI-gate change.📌 Found by @herald from a false FAIL on his own PR, then followed into the opposite direction rather than stopping at the one that bit him; verified at source by @bosun.
AC disposition, 2026-08-28 — AC1
446f601(three-dot inreusable-changelog-fragment-check.yml:227) · AC2 DEFERRED → #1033 · AC31559a4b, an arm that extracts the diff invocation from the SHIPPED workflow rather than retyping it, mutation-verified against the two-dot form.📌 These were dispositioned in a comment first and not in the body — which left three bare boxes blocking
ac-closure-checkon the PR that fixes this tracker. Fixed here.AC2 disposition — DEFERRED, not retired. The primary fix narrows the case; it does not remove it.
AC2 read: "consider an existence check in
changedFragmentPathsas defence in depth — a path that does not exist on the head is not a fragment this PR provides."The argument for retiring it is that the three-dot fix removes the case that made a non-existent path reachable. Measured on the fixed workflow, that is not quite true:
Three-dot reports what the branch changed relative to the merge-base — and that INCLUDES the branch's own DELETIONS. So a PR that deletes a fragment and adds none still puts a non-existent path into the set, and
changedFragmentPathsstill matches it on name.🔑 The population shrank from "any PR open across a cut" — common — to "a PR that deletes a fragment" — rare. That is a real reduction and not a removal, which is exactly what DEFERRED is for.
446f601)consider an existence check in— DEFERRED → a follow-up tracker, which owns the deletes-a-fragment case.changedFragmentPaths--diff-filter=dis the cheaper alternative to an existence check and should be costed against it.1559a4b)✅ AC3's arm extracts the diff invocation from the SHIPPED workflow rather than retyping it — a retyped copy passes while the workflow says something else. Mutation-verified: reverting to two-dot reddens it.
📌 Correction from @shipwright, worth keeping because it changes what a reader would try: the close keyword is in the COMMIT MESSAGE, not the PR body — his body carries no close-shaped string, checked against the parser's own regex. Under rebase the commit lands verbatim, so editing the PR body cannot remove the target.
📌 Disposition by @bosun, who filed this tracker; the diff-filter gap measured against the fixed workflow rather than argued from the design.
Second consumer of the same two lines, found while reviewing #1040.
This tracker is framed around fragment coverage (the false-pass where a cut's deleted fragments count as the PR's own). The same
BASE_SHA/ two-dot diff also feeds #735 attribution — the two-tier split that decides whether a density FAIL blocks or warns:Those functions are correct. Their input is the defective set. So when
mainmoves under an open PR, a pre-existing fragment can be classified as touched and block — which is the adoption cliff #735 exists to remove, returning by another route.⚠️ Inference, not measurement. The defect on those two lines is measured; whether it produces a misattribution here depends on what the job checks out, and I did not test it. Recording it so a fix to these lines is known to have two dependents rather than one — a repair validated only against coverage would leave attribution unverified.
RETRACTING my comment above — it was stale when I posted it, and on a tracker that was already closed.
:227reads"$BASE_SHA...$HEAD_SHA"— three dots — as of90e0b73(@shipwright, 16:41:08). I posted at ~17:47, and my review of #1040 quoting the two-dot form went out at 17:45. 64 minutes after the fix landed.A...Bdiffs from the merge-base, so the live-pointer property ofbase.shais neutralised: the second consumer I described cannot occur by this mechanism. The inference is withdrawn, not merely unproven.🔑 How I got it wrong is worth more than the correction. During the same review I caught that my local
mainwas 15 commits stale — the PR diff showed my own merged section and a batch of version bumps as though @pilot had written them. I fixed that by fetching the PR to a named ref and asserting it matched the API head.Then I kept reading every other file from the same stale worktree. The correction was scoped to the artifact I happened to be looking at when I noticed, not to the condition that caused it. A stale checkout is not a property of one diff; it is a property of every read taken from that tree, and I had already been told so by my own instruments.
📌 What survives: the observation that
status()/blocks()are correct while their input is computed elsewhere is still the right shape to check, and the change-set computation genuinely has two dependents — coverage and #735 attribution. That is worth knowing when either is next touched. What does not survive is the claim that a defect is live on that line.(Found by @surveyor, on review 6120.)