recover-pending-cut restores fragments AND folds their entries back, so the re-prepare ships each fragment entry twice #1487

Closed
opened 2026-10-02 09:55:43 +02:00 by claude · 2 comments
Member

rt recover-pending-cut does two things with a stranded section, and they overlap:

  1. it restores the fragment files the prepare consumed, byte-exact from prepare^ (restoreFragmentsFromPrepareParent, added for #1166 in #1182);
  2. it folds the whole stranded section back under ## [Unreleased] (changelog.FoldBackIntoUnreleased).

So every fragment entry comes back with two sources: the restored file, and its rendered line under [Unreleased]. The next rt prep merges the two in internal/prep/prep.go mergeUnreleased → changelog.MergeSections (internal/changelog/merge.go), which concatenates per kind without dedup. Each fragment entry ships twice.

Measured

Strand a real prepare ([Unreleased] prose plus two fragments), recover, re-prepare. On main @ 02fae6a:

entry                                   re-composed   sealed
#980  (fragment, summary-only)          2             2
#981  (fragment, summary + body)        2             2
#1001 (hand-written [Unreleased] prose) 1             1

The re-prepared ### Fixed reads:

- **release**: the bake builds its pipeline rt in a container (#980)


- **release**: the bake builds its pipeline rt in a container (#980)

Nothing catches it

rt changelog-body-check on that doubled section: all nine checks PASS, rc=0 (measured). Its check 6 grades duplicate version sections and duplicate ### headers, not duplicate entries. TestRecoverPendingCutFoldThenReprepare composes exactly this section but never counts bullets, so it stays green.

Why it matters

Recovery (B) is the documented remedy for a stranded cut. Today the remedy produces a rolling PR whose notes double every fragment entry, and every gate passes it. Once the cut tags, the doubled notes are in a published release.

Acceptance criteria

  • After recovery (B), each entry of the stranded section has exactly ONE source, the restored fragment or [Unreleased], and the re-prepared section renders it once (both re-composed and sealed). — DONE @ #1490 (7bfd0ba, reviewed as 7bfd0ba). TestRecoverPendingCutReprepareRendersEachEntryOnce counts every entry at 1 re-composed and 1 sealed, and asserts the re-prepared section equals the stranded one.
  • [Unreleased] prose that the prepare composed into the section survives recovery exactly once. Fixing the duplicate by restoring fragments instead of folding would silently drop it. — DONE @ #1490 (7bfd0ba, reviewed as 7bfd0ba). The same test counts the hand-written #1001 entry at 1. A restore-only mutant renders it 0 times, and the test goes red (mutation M2).
  • Where recovery cannot tell which section lines a restored fragment will render again (the section was edited after the prepare, or rendered by another rt version), it refuses with rc=2 and writes nothing, rather than guessing. — DONE @ #1490 (7bfd0ba, reviewed as 7bfd0ba). ErrCoveredContentMissing exits 2. TestRecoverPendingCutRefusesASectionEditedAfterPrepare asserts the changelog is unchanged and no fragment is written.
  • An arm that strands a real prepare, recovers, re-prepares and asserts per-entry counts. It is mutation-verified: red on main. — DONE @ #1490 (7bfd0ba, reviewed as 7bfd0ba). That arm is TestRecoverPendingCutReprepareRendersEachEntryOnce. It is red on 02fae6a and again on de49186 (#980 and #981 at 2 and 2), and green at 7bfd0ba. The reviewer reproduced it independently in review 7173.

Not in scope

changelog-body-check has no duplicate-entry check. That is a defence-in-depth gap worth its own tracker; this issue fixes the producer.

`rt recover-pending-cut` does two things with a stranded section, and they overlap: 1. it restores the fragment files the prepare consumed, byte-exact from `prepare^` (`restoreFragmentsFromPrepareParent`, added for #1166 in #1182); 2. it folds the **whole** stranded section back under `## [Unreleased]` (`changelog.FoldBackIntoUnreleased`). So every fragment entry comes back with **two sources**: the restored file, and its rendered line under `[Unreleased]`. The next `rt prep` merges the two in `internal/prep/prep.go` `mergeUnreleased` → `changelog.MergeSections` (`internal/changelog/merge.go`), which concatenates per kind **without dedup**. Each fragment entry ships twice. ## Measured Strand a real prepare (`[Unreleased]` prose plus two fragments), recover, re-prepare. On `main` @ `02fae6a`: ``` entry re-composed sealed #980 (fragment, summary-only) 2 2 #981 (fragment, summary + body) 2 2 #1001 (hand-written [Unreleased] prose) 1 1 ``` The re-prepared `### Fixed` reads: ``` - **release**: the bake builds its pipeline rt in a container (#980) - **release**: the bake builds its pipeline rt in a container (#980) ``` ## Nothing catches it `rt changelog-body-check` on that doubled section: **all nine checks PASS, rc=0** (measured). Its check 6 grades duplicate *version sections* and duplicate `###` *headers*, not duplicate entries. `TestRecoverPendingCutFoldThenReprepare` composes exactly this section but never counts bullets, so it stays green. ## Why it matters Recovery (B) is the documented remedy for a stranded cut. Today the remedy produces a rolling PR whose notes double every fragment entry, and every gate passes it. Once the cut tags, the doubled notes are in a published release. ## Acceptance criteria - [x] After recovery (B), each entry of the stranded section has exactly ONE source, the restored fragment or `[Unreleased]`, and the re-prepared section renders it once (both re-composed and sealed). — **DONE** @ `#1490` (`7bfd0ba`, reviewed as `7bfd0ba`). `TestRecoverPendingCutReprepareRendersEachEntryOnce` counts every entry at 1 re-composed and 1 sealed, and asserts the re-prepared section equals the stranded one. - [x] `[Unreleased]` prose that the prepare composed into the section survives recovery exactly once. Fixing the duplicate by restoring fragments *instead of* folding would silently drop it. — **DONE** @ `#1490` (`7bfd0ba`, reviewed as `7bfd0ba`). The same test counts the hand-written #1001 entry at 1. A restore-only mutant renders it 0 times, and the test goes red (mutation M2). - [x] Where recovery cannot tell which section lines a restored fragment will render again (the section was edited after the prepare, or rendered by another `rt` version), it refuses with rc=2 and writes nothing, rather than guessing. — **DONE** @ `#1490` (`7bfd0ba`, reviewed as `7bfd0ba`). `ErrCoveredContentMissing` exits 2. `TestRecoverPendingCutRefusesASectionEditedAfterPrepare` asserts the changelog is unchanged and no fragment is written. - [x] An arm that strands a real prepare, recovers, re-prepares and asserts per-entry counts. It is mutation-verified: red on `main`. — **DONE** @ `#1490` (`7bfd0ba`, reviewed as `7bfd0ba`). That arm is `TestRecoverPendingCutReprepareRendersEachEntryOnce`. It is red on `02fae6a` and again on `de49186` (#980 and #981 at 2 and 2), and green at `7bfd0ba`. The reviewer reproduced it independently in review 7173. ## Not in scope `changelog-body-check` has no duplicate-entry check. That is a defence-in-depth gap worth its own tracker; this issue fixes the producer.
Author
Member

Landed in #1490. Shutting this tracker by hand, because #1490 referenced it with Refs.

  • Landed: 7bfd0bafe9b165c624bd0c0abb97c7fea16b5b57, fast-forward merged 2026-10-02 14:23:08 with head_commit_id pinned.
    • So the landed commits are exactly the reviewed ones: 01976b5, daf62c8, 7bfd0ba.
    • The head branch is deleted.
  • Approval: alex, review 7174, bound to 7bfd0ba.
    • The verify-at-source review is 7173, also bound to 7bfd0ba. Its verdict is APPROVE, posted as a COMMENT because it came from the PR author's account.
  • Checks:
    • All 30 PR contexts were green on 7bfd0ba.
    • On main, the push contexts were still running when this was written; check-self-bootstrap (push) is green.
    • go-ci / record reviewed vs landed commit (push) failed after 3s. The same context fails the same way on the two previous landed commits, 2060ed1 and de49186. That matches open #1495 (fix in #1496) rather than this change. Its log is not readable from here.
  • All four acceptance criteria are ticked above.
  • Not filed: changelog-body-check still has no duplicate-entry check (see "Not in scope" above).
**Landed in #1490. Shutting this tracker by hand**, because #1490 referenced it with `Refs`. - **Landed:** `7bfd0bafe9b165c624bd0c0abb97c7fea16b5b57`, fast-forward merged 2026-10-02 14:23:08 with `head_commit_id` pinned. - So the landed commits are exactly the reviewed ones: `01976b5`, `daf62c8`, `7bfd0ba`. - The head branch is deleted. - **Approval:** alex, review 7174, bound to `7bfd0ba`. - The verify-at-source review is 7173, also bound to `7bfd0ba`. Its verdict is APPROVE, posted as a COMMENT because it came from the PR author's account. - **Checks:** - All 30 PR contexts were green on `7bfd0ba`. - On `main`, the push contexts were still running when this was written; `check-self-bootstrap (push)` is green. - `go-ci / record reviewed vs landed commit (push)` failed after 3s. The same context fails the same way on the two previous landed commits, `2060ed1` and `de49186`. That matches open #1495 (fix in #1496) rather than this change. Its log is not readable from here. - All four acceptance criteria are ticked above. - **Not filed:** `changelog-body-check` still has no duplicate-entry check (see "Not in scope" above).
Author
Member

The duplicate-entry gap in changelog-body-check (see "Not in scope" above) is now filed as #1509.

The duplicate-entry gap in `changelog-body-check` (see "Not in scope" above) is now filed as #1509.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#1487
No description provided.