changelog density is checked at assembly, but authored in the fragment — check it where it is written #47
Labels
No labels
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
status/deferred
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/purser#47
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 prose is authored in the fragment; the gate reads the assembled block
#45wireschangelog-body-checkonpull_requestforCHANGELOG.md. That is the right gate onthe wrong surface for the defect it was filed against, and the measurement says so.
Where the defect actually enters
The prose is written at the fragment and only appears in the version block at assembly time —
on a prep PR, after the author has moved on, and (pending purser#41 half one) on the one PR class
that may get no CI at all.
Why a
CHANGELOG.mdgate cannot cover the sprintMeasured over the last 18 purser PRs (@surveyor's finding, verified independently):
Zero of twelve. purser documents in fragments, so a
CHANGELOG.mdgate is structurally blind toordinary PRs.
What this tracker is for
#41's AC says the changelog is checked at PR time. The earliest point that is true of is thefragment, not the assembled block. This asks whether the density/readability checks can run on
changelog.d/**at PR time — catching the 36-word sentence in the PR that writes it, from itsauthor, while they still have the context to fix it.
Open questions, in order
reusable-changelog-body-checkread fragments at all? It targets the newest## [vX.Y.Z]section; a fragment has no version header. This may need a toolkit change ratherthan consumer wiring — release-toolkit issue, not a purser one.
assembled adopter-facing block. A fragment is a sentence or two and may legitimately read
differently.
fragment-checkalready have a place to hang this? It runs onchangelog.d/**andvalidates kinds. Extending it may be cheaper than a new workflow.
⚠️ Question 3 may answer 1 and 2 (@surveyor, offered as a hypothesis rather than a finding —
she has not read the reusable). If
fragment-checkalready parses fragment files, hanging thedensity check there is likely cheaper than teaching the body checker to read something with no
version header — and it would make this consumer wiring rather than a release-toolkit change.
Read
reusable-changelog-fragment-check.ymlbefore spending effort on question 1.Not urgent
#45is landing and is cheap and correct. This is the follow-up that would make#41's AC true atthe point the AC actually names. Filed rather than grown into
#45— @surveyor raised it asexplicitly out of scope for that PR and I agree.
🔺 Re-prioritised — this is the REMEDY for
#41, not a follow-up to itI filed and labelled this as the deeper-but-not-urgent fix, on the reasoning that
#45plus half onecovered the sprint's real exposure. @bosun's disposition on
#41shows that was wrong, and hisframing is the reason:
That routes around the gap rather than fighting it, and it is a better remedy than anything that
tries to gate the bot push directly — which half one has now measured as receiving no
pull_requestCI at all.What that makes this issue
The defect
#41was filed for —#25's two 36-word sentences reaching a cut uncaught — is closedby this issue, not by
#45.#45would have caught them only at assembly, on the prep PR, whichwe have now measured gets nothing.
priority/lowon the only remaining remedy was my error, and it is the kind that leaves the real fixlooking optional next to work that is already done.
Design, measured rather than proposed — and a second source a fragment-only gate would miss
Operator asked whether heavy prose can be caught before a cut and shown prominently on the rolling PR. Answer: yes, and most of the machinery already exists.
Why it is not caught today
Prose is authored in
changelog.d/; prose is checked inCHANGELOG.md. The two never meet until the cut, which is why the refusal lands at cut time.The linter already accepts this
changelog-body-check.sh [PATH]takes a path (defaults toCHANGELOG.md), and_find_target_header_line()falls back to## [Unreleased]when no versioned section exists — described in its own comments as the "still-worth-gating pre-release-prep authoring state."So the gate is: compose the PR's fragments under an
## [Unreleased]heading and run the existing linter on the result. No new checker, no new thresholds, same 9 checks and the same 30-word ceiling that refused the cut.🔴 The second source, which a fragment-only gate would not cover
release-prep.shcomposes a section from two inputs, not one:Conventional-commit subjects become changelog bullets whenever no fragment carries the matching ref. So a long commit subject lands in the changelog having passed through no fragment at all, and a gate on
changelog.d/**would never see it.Both sources need checking, or the gate has a hole in exactly the shape of the one it closes. Commit subjects are single lines, so in practice only the sentence-length check applies to them — but that is the check that refused this cut.
Prominence on the rolling PR
A commit status cannot be attached. Measured today (#41): the rolling prep PR is pushed by
forgejo-actionsand receives zero runs of any workflow — not a failure, no event at all. Nothing can go red there.What can work: the prep job itself runs (it is what opens the PR), so it can lint the composed body and post the verdict as a comment on the rolling PR. That is visible on the page where the merge decision gets made, and it does not depend on
pull_requesttriggering.⚠️ And this exact failure has a precedent in the toolkit's own history —
release-toolkit#440: the check "reported PASS on the composed body until manual extraction revealed 3 FAILs (2 sentences >30w, 3 paragraphs >100w) against the real v0.28.0 section", on a rolling PR. The vacuous-pass bug there is fixed; the "nothing checks it at PR time" gap is this tracker.Scope
changelog.d/**— compose under## [Unreleased], run the existing linterThis closes #41's remaining unticked AC — not by gating the prep PR, but by making it incapable of carrying unchecked prose.
Operator request 2026-08-06; mechanism verified against
release-toolkit@v0.35.0sources.Operator's design is better than mine, and the insertion point already exists
The idea: run the checks whenever the rolling PR is created or updated, write the outcome into the PR description, and let the cut consult that instead of discovering it. Move the surprise forward to somewhere visible.
This needs no new trigger, no new event, and no CI at all — because the prep job already composes the exact text and already writes the body.
The existing code, which is three lines apart
PR_BODY_RAW_TMPat:800is the section that will be cut. It is a real file, on disk, at the moment the rolling PR is written — andchangelog-body-check.shtakes a path.So the whole feature is: lint
$PR_BODY_RAW_TMPbetween:800and:801, and prepend the verdict toPR_BODY. It rides the PATCH that already happens on every prep run, in both the create and the update path.This also sidesteps the zero-CI problem entirely. We are not trying to make a check fire on a bot-pushed PR — the prep job is already running, and it is the thing writing the page.
One clause I would change, and it is the load-bearing one
The visibility half is exactly right. The trust-the-recorded-result half I would not do, for two reasons:
It saves nothing.
changelog-body-check.shis a fast shell script over one section. Re-running it at cut time costs milliseconds.And it buys a stale state claim. A verdict written into a PR body is bound to the content it was computed on. If anything changes between the last prep run and the cut, the recorded PASS describes text that is no longer what is being released — and a gate reading it would report green on unchecked prose. That is the same class as an approval bound to a superseded SHA, which cost this repo three review cycles today.
Keep the cut-time gate exactly as it is. Its job changes from discovering the problem to confirming a verdict the rolling PR has been showing all along. Then the cut is never a surprise, and the gate is still the thing that decides.
If the recorded verdict is ever to be trusted at cut time, it must carry a hash of the composed section and the cut must compare it. But then you have recomputed the section to hash it, and you may as well have re-linted it — which is why re-running is the simpler correct answer.
Revised scope
$PR_BODY_RAW_TMPat:800, prepend the verdict toPR_BODY— fires on every rolling-PR create and updatechangelog.d/**still worth having — it catches the defect at the PR that writes it, one step earlier stillBoth surfaces are worth having and they catch different moments. The
changelog.d/**gate catches the author writing it; the rolling-PR description catches whatever reached the assembled section, including the conventional-commit bullets no fragment gate can see.Design by the operator; insertion point verified against
release-toolkit@v0.35.0release-prep.sh.Correction — the implementation does NOT belong here. It is
release-toolkit#621.Operator caught the misplacement. My two design comments above describe changes to
release-prep.sh,changelog-body-check.shand the reusable workflows — all toolkit code. purser only consumes the pin.And the tracker already exists:
release-toolkit#621— "the changelog density gate watches CHANGELOG.md — the one layer where density cannot be durably fixed",kind/bugpriority/highsize/M, unassigned. I filed it on 2026-07-31 with the evidence assembled by Herald, and did not recall it while designing the same thing from scratch today.It already covers the fragment-layer gate, the wrong-axis
LENGTH_WARN_CHARSsignal, and check 7's per-paragraph under-reporting. The operator's rolling-PR-description design is added there as#621's comment 94302.What stays on #47
This tracker is the purser-side consumer, and its remaining work is small:
#621ships the fragment gatefragment-check.yml's prose sibling (or extend itspaths) per whatever shape#621landsNothing here can be built before
#621lands, so #47 is blocked on it rather than on anything of its own — the same relationship #41 has to this tracker.Worth naming
The design work above is not wasted and is not duplicated — it is on #621 where it acts. But the sequence is the point: the operator asked "is this possible", I designed it, and only his second question — "does that not belong to release-toolkit?" — surfaced a tracker I had written myself five weeks earlier.
The prompt that would have found it was a question about repo placement, not about prior art. Which is exactly why search before you build keeps failing to fire: nothing about designing a feature feels like a moment to go looking for whether it is already filed.
Placement correction: operator.