bug(release-prep): the composer bumps README adopter pins to a version with no consumable release #1378

Closed
opened 2026-09-06 20:09:57 +02:00 by bosun · 2 comments
Owner

The release-prep composer bumps README's adopter-facing pins to the version being prepared, before that version has a release anyone can fetch — so the rolling release PR is red by construction and a stranger copying the README from it gets a green checkout and a failed fetch.

Measured on #1371, the current rolling PR

README.md:132   main    "@v0.61.1 from the mirror today"
                rolling "@v0.62.0 from the mirror today"
README.md:235   main    uses: …/reusable-release.yml@v0.61.1
                rolling uses: …/reusable-release.yml@v0.62.0

rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \
                    --target-api-url https://gitea.com/api/v1
  rc=1
  BREAKS A STRANGER -- README.md:132 and README.md:235
  "v0.62.0 has no consumable release on the mirror. The git tag still resolves, so this
   reference is ACCEPTED and the checkout SUCCEEDS -- the failure lands at the fetch step,
   one line later, with a green reference behind it."
  Newest stable release carrying assets on FrankenBit/release-toolkit: v0.61.1

Why this is a regression against a decided policy

#1068 decided the README names the newest tag the mirror can actually serve, precisely because a tag that resolves without a release produces a green checkout and a late, misattributed failure. The composer bumps every version token at PREPARE time, which advances those two lines past what the mirror holds and undoes that decision on every cycle.

⚠️ It is not merely cosmetic on a throwaway branch: the rolling PR merges into main at cut time, so between the merge and the mirror publishing the release object, main's README is a broken instruction.

Preferred path

Advance the adopter-facing pins in the POST-CUT bookkeeping, not in prepare — the same step that already records the manifest once the release exists and is mirrored. VERSION, the CHANGELOG heading and the internal uses: pins can move at prepare time as they do now; only the two lines that tell a stranger what to pin need the release to exist first.

The alternative — exempting the rolling branch from readme-pin-check — is worse: it silences the gate on the one branch where the pin is guaranteed to be wrong, and it leaves the post-merge window open.

AC

  • The composer does not advance an adopter-facing pin to a version with no consumable release — #1379 merged at 81686e71. UpdateDocVersionRefs keeps the DESCRIPTIVE **Latest: vX.Y.Z** line and drops the PRESCRIPTIVE @vX.Y.Z rewrite. Mutation run independently: reverting the guard reddens TestUpdateDocVersionRefs_LeavesReadmeAdopterPinsAlone against a green control.
  • The setter and readme-pin-check share ONE computation of newest servable releasereadmepin.Retarget uses pinRE, the grader's own pattern, and --fix calls readmepin.NewestServable / Grade. "What is a pin, and what should it say" has one implementation, so the gate and the setter cannot drift.
  • --fix lands through a bot PR, not a direct push from mirror-release.yml to mainset-adopter-pin pushes a branch and opens a PR (mirror-release.yml:154,161), and emits ::warning:: naming the branch if the PR call fails rather than losing the change silently. A mirror workflow does not get write access to the default branch.
  • readme-pin-check passes on the rolling PR without being exempted from it — THE SETTLING READ, and it is the recompose rather than the merge. release-prep/rolling regenerated at a2382cf2 and now carries @v0.61.1 on both lines where it carried @v0.62.0. Run with the workflow's own arguments:
rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \
                    --target-api-url https://gitea.com/api/v1
  rc=0
  PASS: all 2 prescriptive pin(s) in README.md name v0.61.1, the newest stable
        release carrying assets on FrankenBit/release-toolkit.
  • An arm covering a prepared-but-uncut version, reddening on its own mutation — TestUpdateDocVersionRefs_LeavesReadmeAdopterPinsAlone, TestRetarget_RewritesOnlyPrescriptivePins, TestCheckReadmePins_FixRewritesToTheNewestServable. @surveyor's independent run: control fail=0 · M1 fail=4 · M2 fail=3 · M3 fail=4 · restored fail=0.
  • A control: a pin that IS consumable still passes, so the fix cannot be "never bump" — two controls, and the second is the one nobody would write from this tracker's text. TestUpdateDocVersionRefs_StillAdvancesTheDescriptiveStatusLine"stop touching README" would pass the other arms and fail nothing. And TestRetarget_AlreadyCorrectIsANoOp / TestCheckReadmePins_FixOnACorrectDocumentWritesNothing, without which the mirror setter opens an empty PR on every publish.

#1068 (the policy this undoes), #1348 (mirror release objects), #1371 (the PR it is red on).

Anchor

Surfaced by the operator noticing CI red on both open PRs, 2026-09-06; reproduced by Bosun running readme-pin-check against the rolling branch with the workflow's own arguments.

The release-prep composer bumps README's adopter-facing pins to the version being prepared, before that version has a release anyone can fetch — so the rolling release PR is red by construction and a stranger copying the README from it gets a green checkout and a failed fetch. ## Measured on #1371, the current rolling PR ``` README.md:132 main "@v0.61.1 from the mirror today" rolling "@v0.62.0 from the mirror today" README.md:235 main uses: …/reusable-release.yml@v0.61.1 rolling uses: …/reusable-release.yml@v0.62.0 rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \ --target-api-url https://gitea.com/api/v1 rc=1 BREAKS A STRANGER -- README.md:132 and README.md:235 "v0.62.0 has no consumable release on the mirror. The git tag still resolves, so this reference is ACCEPTED and the checkout SUCCEEDS -- the failure lands at the fetch step, one line later, with a green reference behind it." Newest stable release carrying assets on FrankenBit/release-toolkit: v0.61.1 ``` ## Why this is a regression against a decided policy `#1068` decided the README names **the newest tag the mirror can actually serve**, precisely because a tag that resolves without a release produces a green checkout and a late, misattributed failure. The composer bumps every version token at PREPARE time, which advances those two lines past what the mirror holds and undoes that decision on every cycle. ⚠️ It is not merely cosmetic on a throwaway branch: the rolling PR merges into main at cut time, so between the merge and the mirror publishing the release object, main's README is a broken instruction. ## Preferred path Advance the adopter-facing pins in the POST-CUT bookkeeping, not in prepare — the same step that already records the manifest once the release exists and is mirrored. `VERSION`, the CHANGELOG heading and the internal `uses:` pins can move at prepare time as they do now; only the two lines that tell a stranger what to pin need the release to exist first. The alternative — exempting the rolling branch from `readme-pin-check` — is worse: it silences the gate on the one branch where the pin is guaranteed to be wrong, and it leaves the post-merge window open. ## AC - [x] The composer does not advance an adopter-facing pin to a version with no consumable release — **`#1379` merged at `81686e71`.** `UpdateDocVersionRefs` keeps the DESCRIPTIVE `**Latest: vX.Y.Z**` line and drops the PRESCRIPTIVE `@vX.Y.Z` rewrite. Mutation run independently: reverting the guard reddens `TestUpdateDocVersionRefs_LeavesReadmeAdopterPinsAlone` against a green control. - [x] The setter and `readme-pin-check` share ONE computation of *newest servable release* — **`readmepin.Retarget` uses `pinRE`, the grader's own pattern, and `--fix` calls `readmepin.NewestServable` / `Grade`.** *"What is a pin, and what should it say"* has one implementation, so the gate and the setter cannot drift. - [x] `--fix` lands through a bot PR, not a direct push from `mirror-release.yml` to `main` — `set-adopter-pin` pushes a branch and opens a PR (`mirror-release.yml:154,161`), and emits `::warning::` naming the branch if the PR call fails rather than losing the change silently. **A mirror workflow does not get write access to the default branch.** - [x] `readme-pin-check` passes on the rolling PR without being exempted from it — **THE SETTLING READ, and it is the recompose rather than the merge.** `release-prep/rolling` regenerated at `a2382cf2` and now carries `@v0.61.1` on both lines where it carried `@v0.62.0`. Run with the workflow's own arguments: ``` rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \ --target-api-url https://gitea.com/api/v1 rc=0 PASS: all 2 prescriptive pin(s) in README.md name v0.61.1, the newest stable release carrying assets on FrankenBit/release-toolkit. ``` - [x] An arm covering a prepared-but-uncut version, reddening on its own mutation — `TestUpdateDocVersionRefs_LeavesReadmeAdopterPinsAlone`, `TestRetarget_RewritesOnlyPrescriptivePins`, `TestCheckReadmePins_FixRewritesToTheNewestServable`. @surveyor's independent run: `control fail=0 · M1 fail=4 · M2 fail=3 · M3 fail=4 · restored fail=0`. - [x] A control: a pin that IS consumable still passes, so the fix cannot be "never bump" — **two controls, and the second is the one nobody would write from this tracker's text.** `TestUpdateDocVersionRefs_StillAdvancesTheDescriptiveStatusLine` — *"stop touching README"* would pass the other arms and fail nothing. And `TestRetarget_AlreadyCorrectIsANoOp` / `TestCheckReadmePins_FixOnACorrectDocumentWritesNothing`, without which the mirror setter opens an empty PR on every publish. ## Related `#1068` (the policy this undoes), `#1348` (mirror release objects), `#1371` (the PR it is red on). ## Anchor Surfaced by the operator noticing CI red on both open PRs, 2026-09-06; reproduced by Bosun running `readme-pin-check` against the rolling branch with the workflow's own arguments.
Author
Owner

🔴 Correcting the "Preferred path" section above — it is wrong, and @shipwright measured why before building it.

What I wrote: advance the adopter-facing pins in the POST-CUT bookkeeping — the same step that already records the manifest once the release exists and is mirrored.

The release exists at that point. It is NOT mirrored. Verified independently:

cmd/rt/post_cut.go             occurrences of "mirror":  0
.forgejo/workflows/mirror-release.yml   triggers:  workflow_dispatch ONLY
goreleaser.yml                 its single mention of mirror-release is in a COMMENT
the cut path invokes the mirror:  NOWHERE

⚠️ Mirroring is a manual dispatch, deliberately. So a post-cut bump names a version the mirror cannot serve — this tracker's own defect, moved a few minutes later and made harder to see. #1348 exists because six cuts went unmirrored before anyone noticed, which is the size of that window.

The corrected path

The value the README should carry is already computed by readme-pin-check: the newest stable release carrying assets on the mirror. The gate that grades the pin and the thing that sets it must share one implementation, or they drift by construction.

prep      leaves README's adopter @vX.Y.Z pins ALONE
          (VERSION, the CHANGELOG heading and internal uses: still move — unchanged)
mirror    after mirror-release.yml publishes, `readme-pin-check --fix` sets the pins
          -> setter and checker are the same computation and cannot disagree

⚠️ A --fix that writes files needs somewhere to land: a bot PR, the way the release bot already lands things — NOT direct push from mirror-release.yml to the default branch. A mirror workflow with write access to main is a bigger change than the defect it repairs.

Scope: README.md only, for a reason rather than for size

UpdateDocVersionRefs rewrites @vX.Y.Z in README.md, docs/integration.md and examples/**, and a stranger copying an example hits the identical failure — but readme-pin-check grades only README.md. Widening the setter past the gate leaves the other two set-but-unchecked, which is the setter/checker drift this design exists to prevent, reintroduced somewhere new.

So: README.md here; widen the GATE in its own tracker; the setter follows the gate.

Measurement and the corrected shape are @shipwright's; he asked before building rather than shipping a fix that relocated the defect.

🔴 **Correcting the "Preferred path" section above — it is wrong, and @shipwright measured why before building it.** **What I wrote:** *advance the adopter-facing pins in the POST-CUT bookkeeping — the same step that already records the manifest once the release exists and is mirrored.* **The release exists at that point. It is NOT mirrored. Verified independently:** ``` cmd/rt/post_cut.go occurrences of "mirror": 0 .forgejo/workflows/mirror-release.yml triggers: workflow_dispatch ONLY goreleaser.yml its single mention of mirror-release is in a COMMENT the cut path invokes the mirror: NOWHERE ``` ⚠️ **Mirroring is a manual dispatch, deliberately.** So a post-cut bump names a version the mirror cannot serve — **this tracker's own defect, moved a few minutes later and made harder to see.** `#1348` exists because six cuts went unmirrored before anyone noticed, which is the size of that window. ## The corrected path **The value the README should carry is already computed by `readme-pin-check`: *the newest stable release carrying assets on the mirror*.** The gate that grades the pin and the thing that sets it must share **one** implementation, or they drift by construction. ``` prep leaves README's adopter @vX.Y.Z pins ALONE (VERSION, the CHANGELOG heading and internal uses: still move — unchanged) mirror after mirror-release.yml publishes, `readme-pin-check --fix` sets the pins -> setter and checker are the same computation and cannot disagree ``` ⚠️ **A `--fix` that writes files needs somewhere to land: a bot PR, the way the release bot already lands things — NOT direct push from `mirror-release.yml` to the default branch.** *A mirror workflow with write access to `main` is a bigger change than the defect it repairs.* ## Scope: README.md only, for a reason rather than for size `UpdateDocVersionRefs` rewrites `@vX.Y.Z` in `README.md`, `docs/integration.md` **and** `examples/**`, and a stranger copying an example hits the identical failure — **but `readme-pin-check` grades only `README.md`.** Widening the setter past the gate leaves the other two set-but-unchecked, which is the setter/checker drift this design exists to prevent, reintroduced somewhere new. ✅ **So: `README.md` here; widen the GATE in its own tracker; the setter follows the gate.** Measurement and the corrected shape are @shipwright's; he asked before building rather than shipping a fix that relocated the defect.
Author
Owner

All six ACs ticked and the settling read is in. Closing.

🔑 The verification is the RECOMPOSE, not the merge — named in advance this time rather than discovered afterwards. The unit arms prove prep no longer bumps the pin; #1371 was red because a previous compose already had, and release-prep/rolling is regenerated from main on every compose. So:

release-prep/rolling regenerated at a2382cf2 after #1379 landed
  README.md:132  `@v0.61.1` from the mirror today      (was @v0.62.0)
  README.md:235  …reusable-release.yml@v0.61.1         (was @v0.62.0)

rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \
                    --target-api-url https://gitea.com/api/v1
  rc=0
  PASS: all 2 prescriptive pin(s) in README.md name v0.61.1, the newest stable
        release carrying assets on FrankenBit/release-toolkit.

Run with the workflow's own arguments, on the regenerated branch, no exemption anywhere.

What the fix turned out to be

📌 I filed this as a scoping question — "adopter-facing pins" — and @shipwright found the actual axis:

**Latest: vX.Y.Z** is DESCRIPTIVE — it states what the newest version here IS, true the moment the cut is prepared. @vX.Y.Z is PRESCRIPTIVE — it tells a stranger what to put in their workflow, true only once the version can be FETCHED.

That is why the status line should still advance at prepare while the pin must not, and it is a distinction rather than a rule. The @ is the whole discriminator, and Retarget reuses the grader's own pinRE so the setter and the checker cannot disagree about what a pin is.

And the argument that closes off the obvious counter-proposal is his too: prepare must work without a network, so it CANNOT know what the mirror holds. Teaching it to check is not a design preference to weigh — it is unavailable.

The control worth naming

StillAdvancesTheDescriptiveStatusLine is the arm nobody would have written from this tracker's text, and "stop touching README" would have passed every other arm and failed nothing. FixOnACorrectDocumentWritesNothing is the second — without it the mirror setter opens an empty PR on every publish.

Not covered, and it has its own home

readme-pin-check grades only README.md, so the @vX.Y.Z pins that prepare still advances in docs/integration.md and examples/** are set by one rule and checked by none. Widen the GATE first, then point the setter at what it covers — the other order reintroduces set-but-unchecked somewhere new, which is the defect this tracker existed to remove. Filed separately.

Implementation and both design insights are @shipwright's; #1379 reviewed and mutation-verified by @surveyor; the recompose read is mine.

✅ **All six ACs ticked and the settling read is in. Closing.** 🔑 **The verification is the RECOMPOSE, not the merge — named in advance this time rather than discovered afterwards.** The unit arms prove `prep` no longer bumps the pin; `#1371` was red because a *previous* compose already had, and `release-prep/rolling` is regenerated from main on every compose. So: ``` release-prep/rolling regenerated at a2382cf2 after #1379 landed README.md:132 `@v0.61.1` from the mirror today (was @v0.62.0) README.md:235 …reusable-release.yml@v0.61.1 (was @v0.62.0) rt readme-pin-check --target-owner FrankenBit --target-repo release-toolkit \ --target-api-url https://gitea.com/api/v1 rc=0 PASS: all 2 prescriptive pin(s) in README.md name v0.61.1, the newest stable release carrying assets on FrankenBit/release-toolkit. ``` Run with the workflow's own arguments, on the regenerated branch, no exemption anywhere. ## What the fix turned out to be 📌 **I filed this as a scoping question — *"adopter-facing pins"* — and @shipwright found the actual axis:** > **`**Latest: vX.Y.Z**` is DESCRIPTIVE** — it states what the newest version here IS, true the moment the cut is prepared. **`@vX.Y.Z` is PRESCRIPTIVE** — it tells a stranger what to put in their workflow, true only once the version can be FETCHED. **That is why the status line should still advance at prepare while the pin must not**, and it is a distinction rather than a rule. The `@` is the whole discriminator, and `Retarget` reuses the grader's own `pinRE` so the setter and the checker cannot disagree about what a pin is. ✅ **And the argument that closes off the obvious counter-proposal is his too: prepare must work without a network, so it CANNOT know what the mirror holds.** Teaching it to check is not a design preference to weigh — it is unavailable. ## The control worth naming **`StillAdvancesTheDescriptiveStatusLine` is the arm nobody would have written from this tracker's text**, and *"stop touching README"* would have passed every other arm and failed nothing. **`FixOnACorrectDocumentWritesNothing` is the second** — without it the mirror setter opens an empty PR on every publish. ## Not covered, and it has its own home `readme-pin-check` grades only `README.md`, so the `@vX.Y.Z` pins that prepare still advances in `docs/integration.md` and `examples/**` are set by one rule and checked by none. **Widen the GATE first, then point the setter at what it covers** — the other order reintroduces set-but-unchecked somewhere new, which is the defect this tracker existed to remove. Filed separately. Implementation and both design insights are @shipwright's; `#1379` reviewed and mutation-verified by @surveyor; the recompose read is mine.
bosun closed this issue 2026-09-06 20:36:35 +02:00
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#1378
No description provided.