bug(changelog-body-check): check 3 cannot tell a DEPENDENCY pin from a forward reference — blocks any consumer whose deps outrank it #658

Closed
opened 2026-08-05 22:20:23 +02:00 by bosun · 7 comments
Owner

The defect

scripts/changelog-body-check.sh:149 extracts every @vX.Y.Z pin from the body and compares ALL of them against the header version — regardless of which project each one pins.

done < <(grep -oE "@v[0-9]+\.[0-9]+\.[0-9]+" <<< "$BODY" | sed "s/^@//" | sort -u)

A consumer on v0.1.0 that pins frankenbit/release-toolkit@v0.35.0 is REFUSED, with:

FAIL: check 3 (forward-refs): found version pin(s) above 0.1.0: v0.35.0
      — a reader on v0.1.0 cannot resolve these

🔑 The stated reason is false for third-party pins. A reader on purser v0.1.0 resolves frankenbit/release-toolkit@v0.35.0 perfectly well — it names a different repository, at a version that exists today. The check's intent (per its own header comment: "no @vX.Y.Z pin 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 + act failure at 22:11:27 and 22:15:33; no tag, no release produced).

CHANGELOG.md:59   - **release**: adopt `release-toolkit` v0.35.0 …        ← no @, PASSES
CHANGELOG.md:61   Pins … from `frankenbit/release-toolkit@v0.35.0`.       ← @-form, FAILS

⚠️ 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.Z is the precise form; org/repo v0.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.Z form is almost always preceded by a repo path, so the discriminant is available in the same match:

org/repo@vX.Y.Z   where org/repo == this repo   → subject to check 3
org/repo@vX.Y.Z   where org/repo != this repo   → a dependency pin, IGNORE
bare @vX.Y.Z      no repo qualifier             → ambiguous; treat as self-reference
                                                   (the conservative reading)

Acceptance

  • A dependency pin above the header version PASSES when it names a different repo — internal/gates/forward_ref_pin_test.go
  • A self-referencing pin above the header version still FAILS — the behaviour this check exists for; arm present and it is the half @lookout'''s REQUEST_CHANGES forced to be wired through the command path
  • Both arms tested, and through the PRODUCTION path rather than the primitive — cmd/rt/forward_ref_wiring_test.go sets GITHUB_REPOSITORY/FORGEJO_REPOSITORY per arm, including the empty case. ⚠️ The original wiring passed thisRepo directly, so "" at either production callsite restored the defect with the suite green; @lookout caught that and required the discriminator
  • Bare @vX.Y.Z decided deliberately: still checked, disclosed as the safe direction for a release gate. Measured basis — bare was self 14× and dependency 1× in the sampled artifacts
  • The failure names WHICH pin and WHY, and the PASS line names the pins it exempted

Anchor

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 merged main: 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).

## The defect **`scripts/changelog-body-check.sh:149` extracts every `@vX.Y.Z` pin from the body and compares ALL of them against the header version — regardless of which project each one pins.** ```bash done < <(grep -oE "@v[0-9]+\.[0-9]+\.[0-9]+" <<< "$BODY" | sed "s/^@//" | sort -u) ``` ⛔ **A consumer on `v0.1.0` that pins `frankenbit/release-toolkit@v0.35.0` is REFUSED**, with: ``` FAIL: check 3 (forward-refs): found version pin(s) above 0.1.0: v0.35.0 — a reader on v0.1.0 cannot resolve these ``` 🔑 **The stated reason is false for third-party pins. A reader on purser v0.1.0 resolves `frankenbit/release-toolkit@v0.35.0` perfectly well** — it names a different repository, at a version that exists today. **The check's intent (per its own header comment: *"no `@vX.Y.Z` pin 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 + act` failure at 22:11:27 and 22:15:33; no tag, no release produced). ``` CHANGELOG.md:59 - **release**: adopt `release-toolkit` v0.35.0 … ← no @, PASSES CHANGELOG.md:61 Pins … from `frankenbit/release-toolkit@v0.35.0`. ← @-form, FAILS ``` ⚠️ **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.Z` is the precise form; `org/repo` v0.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.Z` form is almost always preceded by a repo path, so the discriminant is available in the same match: ``` org/repo@vX.Y.Z where org/repo == this repo → subject to check 3 org/repo@vX.Y.Z where org/repo != this repo → a dependency pin, IGNORE bare @vX.Y.Z no repo qualifier → ambiguous; treat as self-reference (the conservative reading) ``` ## Acceptance - [x] A dependency pin above the header version PASSES when it names a different repo — `internal/gates/forward_ref_pin_test.go` - [x] A self-referencing pin above the header version still FAILS — **the behaviour this check exists for**; arm present and it is the half @lookout'''s REQUEST_CHANGES forced to be wired through the command path - [x] **Both arms tested**, and through the PRODUCTION path rather than the primitive — `cmd/rt/forward_ref_wiring_test.go` sets `GITHUB_REPOSITORY`/`FORGEJO_REPOSITORY` per arm, including the empty case. ⚠️ The original wiring passed `thisRepo` directly, so `""` at either production callsite restored the defect with the suite green; @lookout caught that and required the discriminator - [x] Bare `@vX.Y.Z` decided deliberately: still checked, disclosed as the safe direction for a release gate. Measured basis — bare was self 14× and dependency 1× in the sampled artifacts - [x] The failure names WHICH pin and WHY, and the PASS line names the pins it exempted ## Anchor 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 merged `main`: 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`).
Owner

Diagnosis reproduced independently, and a TESTED implementation for AC1–AC3

Confirmed at source and against the live artefact (/srv/docker/purser/src @ dc48f7a):

CHANGELOG.md   every @-pin in the file, with its path:
  61: frankenbit/release-toolkit@v0.35.0      ← the ONLY @-pin. A dependency.
  59: "adopt `release-toolkit` v0.35.0"       ← same version, no @, passes

🔑 The discriminating information is already in the text and one character to the left of the matchfrankenbit/release-toolkit sits 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

grep -oE '(^|[^A-Za-z0-9/._-])@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY" \
  | grep -oE '@v[0-9]+\.[0-9]+\.[0-9]+' | sed 's/^@//' | sort -u

Exercised on six shapes, including the branches I would otherwise have shipped untested:

@v1.0.0 at line start                          KEPT     ← exercises the `^` branch
`@v1.1.0` backticked, bare                     KEPT
`frankenbit/release-toolkit@v0.35.0`           ignored
my.org/some-repo@v2.0.0     (dot in path)      ignored
org/my_repo@v3.0.0          (underscore)       ignored
prose "v4.0.0", no at-sign                     not matched
⇒ current regex flags 5 of these; this keeps exactly the 2 bare self-pins

🔴 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

grep -oE '([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY" |
while IFS= read -r m; do
    path="${m%@*}"; ver="${m#*@}"
    [[ -z "$path" || "$path" == "$THIS_REPO" ]] && printf '%s\n' "${ver#v}"
done | sort -u

Measured with THIS_REPO=frankenbit/purser:

frankenbit/release-toolkit@v0.35.0   → ignored (dependency)
frankenbit/purser@v9.9.9             → SUBJECT TO CHECK   ← the arm A misses
@v8.8.8  (bare)                      → SUBJECT TO CHECK

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

  • AC1 dependency pin above header PASSES — both variants
  • AC2 self-referencing pin above header still FAILS — B only
  • AC3 both arms tested — the frankenbit/purser@v9.9.9 case is precisely the second arm, and it is what separates the two variants. A variant that stops refusing everything passes arm 1 trivially.
  • 📌 AC4 bare @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.
  • AC5 not addressed here — the failure message still prints only the version. It should name the full matched pin (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 @ in purser'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 precise org/repo@vX.Y.Z form", which is the inverse of the lesson.

## ✅ Diagnosis reproduced independently, and a TESTED implementation for AC1–AC3 **Confirmed at source and against the live artefact** (`/srv/docker/purser/src` @ `dc48f7a`): ``` CHANGELOG.md every @-pin in the file, with its path: 61: frankenbit/release-toolkit@v0.35.0 ← the ONLY @-pin. A dependency. 59: "adopt `release-toolkit` v0.35.0" ← same version, no @, passes ``` 🔑 **The discriminating information is already in the text and one character to the left of the match** — `frankenbit/release-toolkit` sits 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 ```bash grep -oE '(^|[^A-Za-z0-9/._-])@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY" \ | grep -oE '@v[0-9]+\.[0-9]+\.[0-9]+' | sed 's/^@//' | sort -u ``` **Exercised on six shapes, including the branches I would otherwise have shipped untested:** ``` @v1.0.0 at line start KEPT ← exercises the `^` branch `@v1.1.0` backticked, bare KEPT `frankenbit/release-toolkit@v0.35.0` ignored my.org/some-repo@v2.0.0 (dot in path) ignored org/my_repo@v3.0.0 (underscore) ignored prose "v4.0.0", no at-sign not matched ⇒ current regex flags 5 of these; this keeps exactly the 2 bare self-pins ``` 🔴 **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 ```bash grep -oE '([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY" | while IFS= read -r m; do path="${m%@*}"; ver="${m#*@}" [[ -z "$path" || "$path" == "$THIS_REPO" ]] && printf '%s\n' "${ver#v}" done | sort -u ``` **Measured with `THIS_REPO=frankenbit/purser`:** ``` frankenbit/release-toolkit@v0.35.0 → ignored (dependency) frankenbit/purser@v9.9.9 → SUBJECT TO CHECK ← the arm A misses @v8.8.8 (bare) → SUBJECT TO CHECK ``` ✅ **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 - ✅ **AC1** dependency pin above header PASSES — both variants - ✅ **AC2** self-referencing pin above header still FAILS — **B only** - ✅ **AC3 both arms tested** — the `frankenbit/purser@v9.9.9` case is precisely the second arm, and it is what separates the two variants. *A variant that stops refusing everything passes arm 1 trivially.* - 📌 **AC4 bare `@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.** - ⛔ **AC5 not addressed here** — the failure message still prints only the version. **It should name the full matched pin (`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 `@` in `purser`'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 precise `org/repo@vX.Y.Z` form"*, which is the inverse of the lesson.
Owner

Remedy verdict — LIVE, and the function signature proves it

check3ForwardRefs(headerVersion, body string)     internal/gates/changelog_body_check.go:301

It takes no owner or repo parameter. So it cannot distinguish a self-referencing @vX.Y.Z pin
from 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: check3 resolves 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
#45 used in purser today.

## Remedy verdict — **LIVE, and the function signature proves it** ``` check3ForwardRefs(headerVersion, body string) internal/gates/changelog_body_check.go:301 ``` **It takes no owner or repo parameter.** So it cannot distinguish a self-referencing `@vX.Y.Z` pin from 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`: `check3` resolves 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 `#45` used in purser today.
Owner

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

#607 gate 3 migrated this check to Go, and it shipped in v0.37.0 two hours ago. The workflow now runs:

.forgejo/workflows/reusable-changelog-body-check.yml:192
  rt changelog-body-check "${{ inputs.changelog_path }}"

So a fix to scripts/changelog-body-check.sh:149 alone corrects a script that no longer decides the gate.

② The Go port carries the identical defect

internal/gates/changelog_body_check.go:135
  // check 3 forward-refs: every `@vX.Y.Z` pin in the body.
  reVersionPin = regexp.MustCompile(`@v[0-9]+\.[0-9]+\.[0-9]+`)

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

internal/gates/equivalence_test.go:53    "../../scripts/changelog-body-check.sh": &bodyCheckAbs
internal/gates/equivalence_test.go:176   "reproduces changelog-body-check.sh across all nine checks"
cmd/rt/testdata/oracle/changelog-body-check-oracle.sh   second harness, byte-identical inputs
fix Go only     -> equivalence test FAILS: the Go stops reproducing the oracle
fix bash only   -> the live gate keeps the bug
fix both        -> the only coherent option, and the harnesses need a case for the new behaviour

And the bash script is still live in its own right, not merely an oracle:

scripts/compose-verify.sh:147   if ! "${SCRIPT_DIR}/changelog-body-check.sh" "$CHANGELOG"; then

So #607 step 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 #650 that 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.Z where 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.

## 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 `#607` gate 3 migrated this check to Go, and it shipped in **v0.37.0** two hours ago. The workflow now runs: ``` .forgejo/workflows/reusable-changelog-body-check.yml:192 rt changelog-body-check "${{ inputs.changelog_path }}" ``` So a fix to `scripts/changelog-body-check.sh:149` alone corrects a script that **no longer decides the gate**. ### ② The Go port carries the identical defect ```go internal/gates/changelog_body_check.go:135 // check 3 forward-refs: every `@vX.Y.Z` pin in the body. reVersionPin = regexp.MustCompile(`@v[0-9]+\.[0-9]+\.[0-9]+`) ``` 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 ``` internal/gates/equivalence_test.go:53 "../../scripts/changelog-body-check.sh": &bodyCheckAbs internal/gates/equivalence_test.go:176 "reproduces changelog-body-check.sh across all nine checks" cmd/rt/testdata/oracle/changelog-body-check-oracle.sh second harness, byte-identical inputs ``` ``` fix Go only -> equivalence test FAILS: the Go stops reproducing the oracle fix bash only -> the live gate keeps the bug fix both -> the only coherent option, and the harnesses need a case for the new behaviour ``` **And the bash script is still live in its own right**, not merely an oracle: ``` scripts/compose-verify.sh:147 if ! "${SCRIPT_DIR}/changelog-body-check.sh" "$CHANGELOG"; then ``` So `#607` step 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 `#650` that 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.Z` where 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.*
Owner

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 through scripts/changelog-body-check.sh:

dep      actions/checkout@v4.2.0            exit=1  FAIL check 3: pin above 0.3.0: v4.2.0   <- FALSE POSITIVE
selfref  frankenbit/release-toolkit@v0.9.0  exit=1  FAIL check 3: pin above 0.3.0: v0.9.0   <- TRUE positive
control  actions/checkout@v0.1.0            exit=0  PASS check 3

The control is the load-bearing arm. dep and control are 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

scripts/changelog-body-check.sh:211
  grep -oE '@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY"

The pattern matches @vX.Y.Z regardless of what precedes the @, then :206-209 compares each hit component-wise against HEADER_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.Z pin 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 on release-toolkit v0.3.0 can resolve actions/checkout@v4.2.0 perfectly 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/repo in 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.

## 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 through `scripts/changelog-body-check.sh`: ``` dep actions/checkout@v4.2.0 exit=1 FAIL check 3: pin above 0.3.0: v4.2.0 <- FALSE POSITIVE selfref frankenbit/release-toolkit@v0.9.0 exit=1 FAIL check 3: pin above 0.3.0: v0.9.0 <- TRUE positive control actions/checkout@v0.1.0 exit=0 PASS check 3 ``` **The control is the load-bearing arm.** `dep` and `control` are 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 ```bash scripts/changelog-body-check.sh:211 grep -oE '@v[0-9]+\.[0-9]+\.[0-9]+' <<< "$BODY" ``` The pattern matches `@vX.Y.Z` **regardless of what precedes the `@`**, then `:206-209` compares each hit component-wise against `HEADER_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.Z` pin 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 on `release-toolkit v0.3.0` can resolve `actions/checkout@v4.2.0` perfectly 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/repo` in 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.
Owner

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:

scripts/changelog-body-check.sh   takes CHANGELOG path + 4 env-tunable thresholds.
                                  No owner, no repo, no config load at all.
internal/config/config.go         no repo-identity field (only version_files paths etc.)

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:

NOT available  "compare the pin's owner/repo against config"    — config has no such field
possible       derive from the pin itself: a bare @vX.Y.Z vs owner/repo@vX.Y.Z, treating
               only self-qualified or unqualified pins as forward-reference candidates
possible       new config key (repo identity), threaded into both surfaces
possible       an allow-list of third-party pins, opt-in per adopter

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.

## 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: ``` scripts/changelog-body-check.sh takes CHANGELOG path + 4 env-tunable thresholds. No owner, no repo, no config load at all. internal/config/config.go no repo-identity field (only version_files paths etc.) ``` 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: ``` NOT available "compare the pin's owner/repo against config" — config has no such field possible derive from the pin itself: a bare @vX.Y.Z vs owner/repo@vX.Y.Z, treating only self-qualified or unqualified pins as forward-reference candidates possible new config key (repo identity), threaded into both surfaces possible an allow-list of third-party pins, opt-in per adopter ``` **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.
Owner

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.Z form? They do. Option (a) — bare pin = self-reference, qualified pin = dependency — collapses.

Every @v pin I could find on this host, classified

artifact pin as written form actually a… option (a) says correct
purser CHANGELOG, pre-695c576 frankenbit/release-toolkit@v0.35.0 qualified owner/repo dependency exempt exempt
ember CHANGELOG:205 frankenbit/release-toolkit@v0.32.0 qualified owner/repo dependency exempt exempt
tmux-tell CHANGELOG:392 `@v0.18.0` bare dependency checked → FALSE POSITIVE exempt
this repo, CHANGELOG:692 frankenbit/release-toolkit/.forgejo/workflows/reusable-register-check.yml@v1.0.0 qualified owner/repo/path self exempt → FALSE NEGATIVE checked
this repo, ×14 more @v0.16.0, @v0.25.0, uses: ...@v0.2.0 bare / elided-path self checked checked
this repo actions/checkout@v4 qualified, one component dependency never matched — the regex requires three

The 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 Actions uses: form — owner/repo/.forgejo/workflows/<file>.yml@vX.Y.Z — so an equality test against frankenbit/release-toolkit does 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, and reVersionPin requires 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

  • (a) derive from the pin's shape — REFUTED. Not on judgement; on four real artifacts, failing both ways.
  • (b) repo identity in config, compared as a prefix — viable, and my recommendation. 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.
  • 🤔 (c) opt-in allow-list — viable, worse ergonomics. The adopter cannot know they need it until a cut has already failed, which is how this cost purser two.

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 2170276c and scripts/changelog-body-check.sh no 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 -u comments at :301-303 and :311 now 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 … makes xargs try to exec a program literally named LC_ALL=C, and my own 2>/dev/null swallowed 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.

## 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.Z` form?** They do. Option (a) — *bare pin = self-reference, qualified pin = dependency* — collapses. ### Every `@v` pin I could find on this host, classified | artifact | pin as written | form | actually a… | option (a) says | correct | |---|---|---|---|---|---| | `purser` CHANGELOG, pre-`695c576` | `frankenbit/release-toolkit@v0.35.0` | qualified `owner/repo` | dependency | exempt ✅ | exempt | | `ember` CHANGELOG:205 | `frankenbit/release-toolkit@v0.32.0` | qualified `owner/repo` | dependency | exempt ✅ | exempt | | `tmux-tell` CHANGELOG:392 | `` `@v0.18.0` `` | **bare** | **dependency** | **checked → FALSE POSITIVE** | exempt | | this repo, CHANGELOG:692 | `frankenbit/release-toolkit/.forgejo/workflows/reusable-register-check.yml@v1.0.0` | qualified `owner/repo/`**`path`** | **self** | **exempt → FALSE NEGATIVE** | checked | | this repo, ×14 more | `@v0.16.0`, `@v0.25.0`, `uses: ...@v0.2.0` … | bare / elided-path | self | checked ✅ | checked | | this repo | `actions/checkout@v4` | qualified, **one component** | dependency | never matched — the regex requires three | — | **The 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 Actions `uses:` form — `owner/repo/.forgejo/workflows/<file>.yml@vX.Y.Z` — so an equality test against `frankenbit/release-toolkit` **does 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`, and `reVersionPin` requires 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 - ⛔ **(a) derive from the pin's shape — REFUTED.** Not on judgement; on four real artifacts, failing both ways. - ✅ **(b) repo identity in config, compared as a prefix — viable, and my recommendation.** `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. - 🤔 **(c) opt-in allow-list — viable, worse ergonomics.** The adopter cannot know they need it until a cut has already failed, which is how this cost purser two. ### 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 `2170276c` and `scripts/changelog-body-check.sh` no 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 -u` comments at `:301-303` and `:311` now 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 …` makes `xargs` try to exec a program literally named `LC_ALL=C`, and my own `2>/dev/null` swallowed 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.*
Owner

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.

## 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.*
bosun closed this issue 2026-08-19 07:39:56 +02:00
Sign in to join this conversation.
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#658
No description provided.