fix(prep): refuse [Unreleased] text the compose would drop; mirror every cut-time gate in the PR body #1486
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit!1486
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/prep-unreleased-drop-cut-gate-mirror"
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?
What broke (measured)
Measured on 2026-10-02 with
rt prep --dry-run(one-shot mode) against a throwaway adopter-shaped repo. The binary was built from the v0.63.1 source (02fae6a); the original report was on v0.63.0.compose-verifywas run on the preview applied to the fixture.A. Silent content loss.
[Unreleased]text that is not under an exact### <Kind>heading disappears:[Unreleased]shapert compose-verify###None.sections### Notes+ bulletNone.sections### added+ bulletNone.sections## Added+ bulletNone.sections### Added,- kept, then a bare###+ bulletThe Cutter's
Sealthen empties[Unreleased], so the text is gone frommainas well as from the release. Mechanism:MergeSectionsskips lines that have no current kind. It also buckets kinds that are missing frommergeFoldOrderbut never emits them.B. The mirror said clean and the cut refused. Under
### Added, the section held- Reworked the importer.followed by a prose line. The PR description readChangelog density — clean, because it graded checks 7-9 only.rt compose-verify --version 0.2.0then exited 1 on check 5 (style).What changes
A.
prep.Composerefuses before the fold, so the seal never runs and nothing is written. Verbatim from the new binary:changelog.UncarriedLinesreports the lines. It sharesclassifyMergeRecordwithMergeSections, which is now the only place the merge's line grammar lives.changelog.UnreleasedStartLineturns positions within the body intoCHANGELOG.md:<n>.B. The PR-description block is now
gates.MirrorCutGates. It callsComposeVerify, the functionrt compose-verifycalls at the cut. It runs on the CHANGELOG bytes prep is about to commit (read after the seal and the hooks, before the push), with the same threshold parser and repo-identity resolver. The block lists register-check plus checks 1-9 by ranging overBodyCheckResult.Checks.gates.SectionDensityis removed.Decision tree
A: refuse, or report in the PR body?
rt prep(the lean taken here) is right when the loss cannot be undone from the release's side and the fix is always an edit onmain. Both hold. The seal deletes the text frommain, and every refused shape has a mechanical fix: move the line under an accepted heading, or delete it. Cost: while[Unreleased]stays malformed, every push:main prep exits 1 and the rolling PR is not refreshed. NOT MEASURED: what merging the stale rolling PR onto the newmaindoes.[Unreleased]should not block an otherwise fine fragment-only release, and reviewers reliably read the block. Against it: that PR's prepare commit has already sealed, so the text is deleted the moment it merges. The report would document the loss, not prevent it.### Notesinto some section) is right if the toolkit wants to accept free-form notes. It is not done here. C6 has no section for prose before the first heading, and picking a kind for### Noteswould publish text under a heading the author did not choose.A: where the detector lives
MergeSections(taken) is right when the detector must agree with the merge by construction.[Unreleased]line must appear in the rendered section) is right if drop paths beyond the merge are expected. It is weaker as the runtime check, because a line repeated in a fragment masks a drop. It is used as the test oracle instead:TestUncarriedLines_AgreesWithMergeSections.B: what the mirror grades
ComposeVerifyon the sealed CHANGELOG (taken) is right when the mirror must not drift from the authority. The old mirror listed checks 7-9. #931 made check 5 fail-loud, and nothing connected that change to the mirror. Calling the authority's own function means a future check 10 shows up without edits here.## [heading) renders as REFUSED with the precondition named. This carries #725's vacuity guard forward.B: the block sentinel
<!-- rt:density-verdict -->is kept although the block is now wider, so anything that greps for it keeps working. Rename it if nothing outside the repo is known to depend on it.Byte identity
TestCompose_acceptedUnreleasedIsByteIdenticalpins the composed section, for valid### Kindblocks and for an empty[Unreleased], to the bytes the pre-change binary sealed for the same input.rt prep --dry-runproduce identical sealed CHANGELOGs for valid blocks (33 lines), an empty[Unreleased](31), and an H2 after an H3 (35).Tests and mutation verification (AGENTS.md section 8, Axis B)
The end-to-end tests (
cmd/rt,rt prep --dry-runon a temp repo) were written first and were red on unfixed code:The control,
TestPrep_validUnreleasedStillComposes, is green before and after.Each mutation was applied on its own to the fixed tree and then reverted:
Composeskips the refusalTestCompose_refusesUnreleasedTextTheFoldWouldDrop,TestCompose_refusalWithoutLocationCountsWithinTheBody###) not reportedTestUncarriedLines4 cases,_AgreesWithMergeSections4 cases, E2E prose / H2 / bare-headingTestUncarriedLines,_AgreesWithMergeSections, E2E,TestCompose_refusalWithoutLocation…None.as contentTestMergeSections_noneIsSentinelNotContent,TestTransition_MergeDropsScaffoldedNone, plusTestUncarriedLines/unknown_kindUnreleasedStartLineoff by oneTestUnreleasedStartLine, E2E 5/5TestMirrorCutGates_EveryCutTimeRefusalIsNamed(checks 1, 2, 3, 5, 6, register),…CleanSectionRendersASayingSoBlock, E2E 2/2TestMirrorCutGates_UngradableInputIsNeverClean3/3,TestRollingPRBody_GradesTheSealedFileNotTheSectionTestMirrorCutGates_UngradableInputIsNeverClean3/3Under M6 the check-7 and check-8 cases stay green. That is correct: those are checks the old mirror already covered.
Full suite:
go test -count=1 ./...ingolang:1.25on an LF clone gives 29 packages ok and 0 failing.go vet ./...andgofmt -lare clean. Every added line in.gofiles is ASCII (section 11).Self-bootstrap (AGENTS.md section 2)
This changes compose machinery (
rt prep), so section 2 applies:@mainwhen this merges: no re-pin is needed. On livemain(02fae6a, 2026-10-02) all seven wrappers pin@main.check-self-bootstraptakes its floating-pin exit (#456), andbootstrap-rtbuildsrtfrom the workspace commit, so the next push:main prep already runs this code.rt repin vX.Y.Z-rc.Nafter this merges and before the next cut. Otherwise the rolling PR keeps being composed by the old prep.check-self-bootstrapwill not go red on this PR either way. Its guarded set (DefaultComposeScripts) is the reusable workflow YAMLs plusscripts/workflow-parse-check.sh, and this PR changes none of them.Adopter impact
For an adopter whose
[Unreleased]already holds text in a refused shape, the first prep after upgrading exits 1 and names each line. That is intended: the fix is an edit to CHANGELOG.md onmain, and the text is never lost.NOT CHECKED / out of scope
rt recover-pending-cutdrops the same shapes.FoldBackIntoUnreleasedalso merges a non-empty[Unreleased]throughMergeSections. A Go probe on this tree folded backNew prose after the prepare.plus### Notes/- A note.together with v0.2.0's### Added/- Shipped.. The result was only### Added/- Shipped., witherr=nil. Tracked in #1491. (#1487 / #1490 is a different recovery defect: entries shipping twice, not text dropped.)### Added…## Fixed…) is carried, not dropped: it lands inside the Added section as a literal## Fixedline. This was measured byte-identical between the old and new binaries. It is a misfiling, not a loss, so it is not refused here. Tracked in #1492, together with the misleading refusal reason for a###line inside a code fence (review note 1).reusable-changelog-body-check.ymlgrades the same section where an adopter wired it. Not measured: whether a bot-opened rolling PR triggers it on Forgejo. Where it does and the check is required, check 5 was already red on the PR, and the old block contradicted it.GITHUB_REPOSITORY/FORGEJO_REPOSITORY. A local prep without them grades every pin as a self-reference. That can produce a false REFUSED, never a false clean.Changelog
Two fragments, both referencing this PR:
changelog.d/1486-prep-unreleased-drop.fixed.mdandchangelog.d/1486-prep-cut-gate-mirror.fixed.md. Locally,rt fragment-check(content mode) passes them on kind, summary header, issue reference and density.Cut verification (AGENTS.md section 6)
On the first rolling PR after this merges, verify at source:
[Unreleased]onmainmakes prep exit 1 and leaves the rolling branch unrefreshed.GITHUB_REPOSITORYorFORGEJO_REPOSITORYin its environment, so check 3 does not grade every pin as a self-reference. Neither is set explicitly inreusable-release.yml. The workflow already relies on the runner-providedGITHUB_REPOSITORYin a run step (reusable-release.yml:967), so it is expected here too, but that is inferred, not measured.🤖 Generated with Claude Code
Review of head
e2fc22203343b24e9cea692b6fc0832e92afc22d. No blocking findings.Posted as COMMENT, not APPROVED. This review was written through the
claudeaccount, which is also the PR's author. Forgejo refuses self-approval across the whole instance (AGENTS.md section 8). A second reviewer still needs to approve.What I verified
Full suite. Ran on a fresh LF clone in
golang:1.25:go vet ./...is clean,gofmt -l cmd internalis empty, andgo test -count=1 ./...passes 29 of 29 packages. The base02fae6ais green on the four touched packages.Mutation check (Axis B). I re-ran two of the PR's claimed mutations myself. Both went red as claimed:
TestPrep_refusesUnreleasedContentItWouldDrop,TestCompose_refusesUnreleasedTextTheFoldWouldDropandTestCompose_refusalWithoutLocationCountsWithinTheBody.clBytes) failsTestPrep_rollingPRBodyMirrorsTheStyleCheck.The refusal does not over-refuse. I ran these
[Unreleased]bodies throughCompose:### Security+ bullet### Internal+ bullet### Upgrade+ bullet### Added, then## Fixed+ bulletSo all eight headings the error message calls accepted really do survive
sectionsFromMarkdownand the parser.The mirror uses the same inputs as
rt compose-verify. Both callgates.ComposeVerifywithparseBodyCheckThresholds()andresolveThisRepo(). The mirror then re-runsChangelogBodyCheckandRegisterCheckStdin(sectionContentByVersion(...))for the per-check detail. The sealed bytes are read after the seal, the hooks, the build-bake and the doc-ref steps, and before the commit and push, so the block grades the file the PR will carry.Sentinel. Nothing else in the tree (Go, YAML, shell or docs) greps for
rt:density-verdict. The rename suggested in the PR body is safe from this repo's side whenever you want it.Non-blocking notes
###line inside a fenced block under[Unreleased]is refused with<- not an accepted heading. For example,### Addedfollowed by a fenced block containing### not a heading. Refusing is correct: the merge would treat that line as a heading and drop it and the rest of the fence. But the author put that line in on purpose as literal text, so the reason may confuse them. A hint about fenced code in the reason, or in theconventions.mdparagraph, would help. Fine as a follow-up.## Fixedends up as body text under Added). The PR body already measures and scopes this out. It is worth an issue, because nothing grades it: check 6 only looks at###duplicates.recover-pending-cuthas the same drop class throughFoldBackIntoUnreleased. The PR body says a follow-up is in progress. Please link it here before merging so the gap is tracked.[Unreleased]is malformed. Every push:main prep exits 1 and the rolling PR goes stale. This is documented, and the decision tree's reasoning for refusing is sound: the seal deletes the text frommain, so reporting it in the PR body would only document the loss. Your own "NOT MEASURED" item is the one to watch at the first cut: what happens if the stale rolling PR is merged.Cut verification (section 6)
The two at-source checks listed in the PR body are the right ones. I'd add one more: confirm the reusable-release prep step has
GITHUB_REPOSITORYorFORGEJO_REPOSITORYin its environment. Without it, check 3 grades every pin as a self-reference and the block would show a false REFUSED.🤖 Generated with Claude Code
Thanks for the review. Disposition of each note, all without new commits. The head is still
e2fc22203343b24e9cea692b6fc0832e92afc22d; only the PR description changed (body-only, AGENTS.md section 6.2).###line refused with a misleading reason: tracked in #1492 as an acceptance criterion: say so in the reason, and indocs/conventions.md. I didn't fix it here, because the refusal itself is correct and a code change would re-run the whole gate set for a wording improvement.recover-pending-cutdrop class: filed as #1491, with the probe output. Correction to my own body: the in-progress follow-up I referred to turned out to be #1487 / #1490, which is a different recovery defect (entries shipping twice, not text dropped). The drop was untracked until #1491. The description now links #1491 and says this.[Unreleased]is malformed: no change. The stale-rolling-PR merge stays the NOT MEASURED item to watch at the first cut.Cut verification: added your third check (
GITHUB_REPOSITORY/FORGEJO_REPOSITORYin the prep step's environment) to the description. Neither variable is set explicitly inreusable-release.yml.reusable-release.yml:967already relies on the runner-providedGITHUB_REPOSITORYin a run step, so it is expected in the prep step too. That is inferred, not measured, and the description says so.🤖 Generated with Claude Code
HOLD: a new commit is coming; please do not merge on
e2fc22203343b24e9cea692b6fc0832e92afc22d.The required
go-ci / lint + build + testcheck is red on this head. CI was not fully green. The job log is not anonymously readable, so I reproduced the job's lint step:golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0(v2.14.0, no config in the repo, so default linters):02fae6a:0 issues.e2fc222:internal/changelog/merge.go:139:2: QF1002: could use tagged switch on u.Heading (staticcheck)That is
UncarriedLine.Reasonin this PR. The fix is behaviour-neutral: a tagged switch onu.Heading. It lands as a new commit, with no amend or force-push. I'll post the new head SHA when it is pushed.🤖 Generated with Claude Code
New commits pushed, approval review dismissed automatically according to repository settings
HOLD released. New head:
2e8f46fc8b977a92021673731af30dfa643342bdThe new commit is
2e8f46f(chore(changelog): use a tagged switch in UncarriedLine.Reason). It is a single-file, 3-line change ininternal/changelog/merge.go:switch { case u.Heading == "" … case u.Heading == u.Text … }becomesswitch u.Heading { case "" … case u.Text … }. Behaviour is unchanged, andTestUncarriedLine_Reasoncovers every branch.Verified locally on this head:
golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0(v2.14.0):0 issues.go build ./...andgo test -count=1 ./...(golang:1.25, LF clone): 29 packages ok, 0 failing.The push dismissed the earlier approval on
e2fc222, so this head needs a fresh one. Before submitting it, pin it to the head you read:rt review-submit-check --owner frankenbit --repo release-toolkit --pr 1486 --reviewed-head 2e8f46fc8b977a92021673731af30dfa643342bd(AGENTS.md section 6.2).🤖 Generated with Claude Code
Follow-up review of head
2e8f46fc8b977a92021673731af30dfa643342bd, the delta frome2fc222. No findings.Posted as COMMENT, not APPROVED, for the same reason as before: this account (
claude) is the PR's author, and Forgejo refuses self-approval. The approval one2fc222was dismissed when this commit was pushed, so this head needs a fresh one from a maintainer. Before submitting it, runrt review-submit-check ... --reviewed-head 2e8f46fc8b977a92021673731af30dfa643342bd(section 6.2).The new commit
2e8f46fThe commit is a 3-line change in
internal/changelog/merge.go.UncarriedLine.Reasonswitches fromswitch { case u.Heading == "" ...; case u.Heading == u.Text ... }toswitch u.Heading { case "": ...; case u.Text: ... }.No behaviour change. Go evaluates case expressions top to bottom and takes the first match. The branch order is the same, including the
Heading == "" && Text == ""overlap, where the first case still wins.TestUncarriedLine_Reasoncovers all three branches.Lint, reproduced independently with the official
golangci/golangci-lint:v2.14.0image on a fresh clone, using the PR's flags (--max-issues-per-linter=0 --max-same-issues=0, no repo config):02fae6a0 issues.e2fc2222e8f46f0 issues.This confirms the HOLD comment's diagnosis: the red
go-cicheck one2fc222came from this PR, and this commit clears it.Suite on
2e8f46f(golang:1.25, LF clone):go vetclean,gofmt -lempty,go test -count=1 ./...passes 29/29 packages.CI. The combined status on
2e8f46fissuccess, with 30 of 30 contexts green, includinggo-ci / lint + build + test. I re-ran lint and tests locally above instead of relying only on that report (section 8, Axis C).Dispositions of my earlier notes
I checked each one at its source:
###reason, and 2. H2 after H3: #1492 exists (kind/bug,priority/low). Its acceptance criteria cover both cases, require the byte-identity pin, and require mutation verification. Deferring is fine; neither case loses text.recover-pending-cutdrop class: #1491 exists, with the probe output and a criterion to check the second merge argument. That criterion is the right catch:UncarriedLinesis only exact for the first argument. The corrected description now links #1491 and separates it from #1487 / #1490.Cut verification: item 3 was added to the description and labelled as inferred, not measured. The cited line
reusable-release.yml:967(OWNER="${GITHUB_REPOSITORY%%/*}") is real at this head, and it relies on the runner-provided variable as described.The process follows section 6.2: the body-only correction was announced, then a HOLD before the new commit, then the HOLD was released with the new SHA. The commit was a new commit, not an amend or force-push.
Ready to merge once a maintainer approves
2e8f46f.🤖 Generated with Claude Code