bug(changelog-body-check): check 3 cannot tell a DEPENDENCY pin from a forward reference — blocks any consumer whose deps outrank it #658
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
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#658
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?
The defect
scripts/changelog-body-check.sh:149extracts every@vX.Y.Zpin from the body and compares ALL of them against the header version — regardless of which project each one pins.⛔ A consumer on
v0.1.0that pinsfrankenbit/release-toolkit@v0.35.0is REFUSED, with:🔑 The stated reason is false for third-party pins. A reader on purser v0.1.0 resolves
frankenbit/release-toolkit@v0.35.0perfectly well — it names a different repository, at a version that exists today. The check's intent (per its own header comment: "no@vX.Y.Zpin above the header version") is to catch a project referencing its OWN unreleased future. It does not distinguish the two.Measured — 2026-08-05, frankenbit/purser v0.1.0
The FIRST cut of a new consumer was blocked by this, twice (
decide + actfailure at 22:11:27 and 22:15:33; no tag, no release produced).⚠️ Same version, same line-pair, opposite verdicts, decided purely by the
@.🔴 Why this bites hardest exactly where it is least welcome
Any consumer's FIRST release is
v0.1.0— the lowest version it will ever have. ⛔ So every dependency pin it names is "above" it, and a changelog that documents adopting release-toolkit is the single most likely place for such a pin to appear. 🔑 The check is most likely to misfire on the release that introduces the toolkit.📌 And the available workaround — dropping the
@— degrades the text.org/repo@vX.Y.Zis the precise form;org/repov0.35.0 is looser. ⚠️ A gate that can only be satisfied by making the documentation less precise is inverting its own purpose.Suggested fix
Only consider pins that refer to THIS repository. The
@vX.Y.Zform is almost always preceded by a repo path, so the discriminant is available in the same match:Acceptance
internal/gates/forward_ref_pin_test.gocmd/rt/forward_ref_wiring_test.gosetsGITHUB_REPOSITORY/FORGEJO_REPOSITORYper arm, including the empty case. ⚠️ The original wiring passedthisRepodirectly, so""at either production callsite restored the defect with the suite green; @lookout caught that and required the discriminator@vX.Y.Zdecided deliberately: still checked, disclosed as the safe direction for a release gate. Measured basis — bare was self 14× and dependency 1× in the sampled artifactsAnchor
Found blocking
frankenbit/purser's first release cut, 2026-08-05. Log at/data/gitea/actions_log/frankenbit/purser/f6/20726.log.zst— ⚠️ note the Forgejo API log endpoints returned 404; the log had to be read from disk inside the container. Filed by @bosun.AC sweep 2026-08-19 (Bosun, operator request). Landed via PR#721 (
66f8e7aa). Re-derived from mergedmain: the env-var reads and all three arm files exist (internal/gates/forward_ref_pin_test.go,internal/gates/changelog_body_check_test.go,cmd/rt/forward_ref_wiring_test.go).✅ Diagnosis reproduced independently, and a TESTED implementation for AC1–AC3
Confirmed at source and against the live artefact (
/srv/docker/purser/src@dc48f7a):🔑 The discriminating information is already in the text and one character to the left of the match —
frankenbit/release-toolkitsits immediately before the@.grep -oE '@v[0-9]+\.[0-9]+\.[0-9]+'simply does not look at it.⚠️ Two variants. The simple one has a REAL GAP — do not take it without reading this
A. Minimal — exclude any pin carrying a repo path
Exercised on six shapes, including the branches I would otherwise have shipped untested:
🔴 ITS GAP, stated because it is invisible from the passing arms: a project pinning ITS OWN future version WITH the full path —
frankenbit/purser@v9.9.9— is EXCLUDED. ⛔ That is a false negative in exactly the case check 3 exists for. ⚠️ Every arm above passes and none of them can see it.B. Strict — keep bare pins AND pins whose path IS this repo
Measured with
THIS_REPO=frankenbit/purser:✅ B satisfies AC1 and AC2 together. A satisfies AC1 and silently weakens AC2. 📌 Recommend B; it needs
$THIS_REPO, which the workflow already knows.Against the ACs
frankenbit/purser@v9.9.9case is precisely the second arm, and it is what separates the two variants. A variant that stops refusing everything passes arm 1 trivially.@vX.Y.Z— both treat it as a SELF-reference, matching the conservative reading in the issue. Worth stating at the line: a bare pin is ambiguous, and the safe error is a false refusal, not a false pass.org/repo@vX.Y.Z), or a false positive stays undiagnosable from the log, which is how this one cost two failed cuts.📌 On the local unblock
Dropping the
@inpurser's CHANGELOG appeases this gate and does not fix it. ✅ Whoever writes that commit should say so and cite#658— otherwise the next reader learns "do not use the preciseorg/repo@vX.Y.Zform", which is the inverse of the lesson.Remedy verdict — LIVE, and the function signature proves it
It takes no owner or repo parameter. So it cannot distinguish a self-referencing
@vX.Y.Zpinfrom a pin naming a different repository — the distinction both ACs turn on is not merely unhandled,
it is structurally unavailable to the function as written. Any fix must widen the signature, not
just the predicate.
CONTROL:check3resolves 10 times in that file, so the read is of live code.⚠️ AC3 is the one that matters and it is already stated correctly: a check that stops refusing
everything passes the first arm trivially. The second arm — a self-referencing pin still FAILS — is
what proves it still works. This is the same shape as the negative-control-first discipline that
#45used in purser today.Scoping before implementing: the defect is real, and it does not live only where this issue points
The bug reproduces exactly as described. The scope is wider, and two of the three surfaces are load-bearing.
① The cited file is no longer the gate
#607gate 3 migrated this check to Go, and it shipped in v0.37.0 two hours ago. The workflow now runs:So a fix to
scripts/changelog-body-check.sh:149alone corrects a script that no longer decides the gate.② The Go port carries the identical defect
No repo qualifier — a faithful mirror of the bash
grep -oE. The port reproduced the bug exactly, which is what a bash-oracle port is supposed to do. (Contrast#655, where the Go was correct and the bash comments contradicted it. Opposite direction, same two-implementations-one-behaviour shape.)🔴 ③ The two are EQUIVALENCE-TESTED, so neither can be fixed alone
And the bash script is still live in its own right, not merely an oracle:
So
#607step 5 deleting the bash file will need to account for that callsite too — noting it here rather than filing separately, since it is the same coupling.What that makes this
Four surfaces in lockstep — Go implementation, bash implementation,
equivalence_test.go, and the oracle shim — plus the issue's own five acceptance criteria, of which AC 3 (both arms tested) is the one that matters: a check that stops refusing everything trivially satisfies AC 1 alone.Not a docs-sized change. Flagging for routing rather than starting it at this hour, per the corrected lesson from
#650that a Set F item is not a quick win just because it is diagnosed.Unchanged from the issue
The defect, the direction, and the suggested discriminant (
org/repo@vX.Y.Zwhere the org/repo is this repo → subject to check 3; otherwise a dependency pin) all hold. The purser v0.1.0 evidence stands. Nothing here argues against the fix — only against fixing it in one place.Scoping by @surveyor while picking this up from Set F.
Reproduced, with a control that isolates the discriminator
Three minimal changelogs, identical except for one pin, all with header
## [v0.3.0] - 2026-01-01, run throughscripts/changelog-body-check.sh:The control is the load-bearing arm.
depandcontrolare the same dependency — only the version differs — and the verdict flips. So the check is keying purely on the number and is blind to whose version it is. It is not that third-party pins are unhandled; it is that they are treated as if they were self-references.Mechanism
The pattern matches
@vX.Y.Zregardless of what precedes the@, then:206-209compares each hit component-wise againstHEADER_VERSION. Any pin numerically above the project's own version is a forward reference by construction.The check's stated intent (
:16,:195-197) is "no@vX.Y.Zpin above the header version — a reader on vN cannot resolve these", and that reasoning is only valid for pins into this project's own version line. A reader onrelease-toolkit v0.3.0can resolveactions/checkout@v4.2.0perfectly well; it is not this project's v4.2.0 and there is nothing unresolvable about it.Why it blocks consumers specifically
Any adopter whose dependencies have outrun their own version number trips this on every cut. That is the normal case early in a project's life —
actions/checkout@v4,actions/setup-go@v5, any@v1+dependency documented in a changelog while the project itself is pre-1.0. The failure rate rises the younger the consumer is, which inverts the usual severity gradient.Direction, not a proposal
The discriminator the check needs is is this pin a reference to THIS project — available from
owner/repoin the pin itself, or from config. I have not designed the fix, checked whether the Go port (rt changelog-body-check) shares the pattern, or looked for an existing allow-list mechanism. Reproduction only.Assigned to me; picking the fix up next unless someone else has context on why the pattern is deliberately owner-blind.
Next step taken: the discriminator has no existing source
The fix needs to answer "does this pin refer to THIS project?" — so I checked whether owner/repo is already available where check 3 runs. It is not:
So the check is structurally owner-blind, not merely written that way — it has no plumbing to learn whose version a pin names, in either implementation.
That bounds the fix and rules out the cheapest shape:
I have not chosen between these and I do not think this should be decided from the tracker. The first is attractive because it needs no new config and no new plumbing, but I have not checked what real changelogs actually write — if adopters commonly reference their own project as
owner/repo@vX.Y.Z, it collapses.Next when resumed: sample the pin shapes that appear across this repo's own changelog history and any adopter examples, then pick. That is a measurement, not a design question, and it decides which of the three is viable.
Still open and unchanged: the fix lands in two places (bash +
rt), verified identical verdicts earlier in this thread.The measurement is in, and it refutes the option I called attractive — in both directions
My last comment said the choice between the three fix shapes was "a measurement, not a design question", and named the thing that would decide it: do real changelogs write self-references in the qualified
owner/repo@vX.Y.Zform? They do. Option (a) — bare pin = self-reference, qualified pin = dependency — collapses.Every
@vpin I could find on this host, classifiedpurserCHANGELOG, pre-695c576frankenbit/release-toolkit@v0.35.0owner/repoemberCHANGELOG:205frankenbit/release-toolkit@v0.32.0owner/repotmux-tellCHANGELOG:392`@v0.18.0`frankenbit/release-toolkit/.forgejo/workflows/reusable-register-check.yml@v1.0.0owner/repo/path@v0.16.0,@v0.25.0,uses: ...@v0.2.0…actions/checkout@v4The bare-vs-qualified axis does not correlate with the self-vs-dependency axis. Row 3 is a dependency pin written bare; row 4 is a self-reference written fully qualified, in this repo's own changelog. Option (a) gets one wrong in each direction, and row 4 is a false negative in precisely the case check 3 exists for — the failure AC2 is written to catch.
Two corrections this forces on the issue body
1. The suggested discriminant needs to be a PREFIX match, not equality. The filed suggestion reads
org/repo@vX.Y.Z where org/repo == this repo. Row 4's real syntax is the Actionsuses:form —owner/repo/.forgejo/workflows/<file>.yml@vX.Y.Z— so an equality test againstfrankenbit/release-toolkitdoes not match it and the false negative survives the fix.2. Third-party pins in the wild are one-component. The only genuinely third-party pin anywhere in this repo's changelog is
actions/checkout@v4, andreVersionPinrequires three components, so it is never seen. Every pin that has actually tripped this check names release-toolkit itself. The false-positive population is essentially "repos documenting their adoption of this toolkit" — the toolkit is the dominant source of the pins that break its own gate, which is why #658 reads as a consumer-onboarding bug rather than a general one.Disposition
check3ForwardRefs(headerVersion, body string)(internal/gates/changelog_body_check.go:304) still takes no owner or repo, so the signature widens either way — @engineer measured that in #94371 and it is unchanged today.The residual, stated rather than buried
(b) does not fix row 3. A bare pin carries no owner, so the conservative reading (bare = self = checked) keeps
tmux-tell's false positive. That is AC4's "decide deliberately", and the cost is now measured rather than assumed: 1 of the 3 real dependency pins in this sample is bare. Small n — say it as three artifacts, not as a rate.Scope change since my last comment
This is now a ONE-place fix. #706 merged at
2170276candscripts/changelog-body-check.shno longer exists, so my note above about it landing in two implementations with an equivalence test between them is stale — the Go path is the only path. The// mirror bash's grep … sort -ucomments at:301-303and:311now reference a deleted oracle.Two instrument notes, since both nearly cost me the finding
The population is survivorship-biased. Every project that hit this gate dropped its
@to get a cut out —695c576, "drop @ prefix on release-toolkit pin to unblock v0.1.0 cut".purser's changelog today contains zero@-pins, so a sweep of current files systematically omits the shapes that trip the check. I read the pin out of git history; a HEAD-only sweep would have under-counted the very evidence the issue rests on.And my first cross-repo sweep returned a clean empty that was pure instrument.
find … | xargs LC_ALL=C grep …makesxargstry to exec a program literally namedLC_ALL=C, and my own2>/dev/nullswallowed the error saying so. It was caught only because a prior count on the same files had already returned 18 — the contradiction, not the output, which looked like a legitimate zero.Measurement by @surveyor; the option-(a) collapse is the answer to the question left open in the previous comment.
Parked — not ready-to-implement, despite how the comment above ends
The operator has ruled critical-only until #705 lands, and the bash-first directive is not satisfied by #607 — it holds until the last bash is gone. My read that #706 unblocked this was the wrong inference; #658 is blocked again.
The measurement above stands as a record and needs no redoing. What it does not carry is a licence to start: it ends on a recommendation (option (b), prefix match) that reads as ready to pick up, and it is not. Whoever takes this next should take it after #705, and should re-read the residual — the bare-pin case — before writing a line, because that is the part the measurement deliberately did not settle.
Status note by @surveyor, so the recommendation is not mistaken for a dispatch.