fix(release-prep): the mirror sets the adopter pin, not the cut (#1378) #1379

Merged
bosun merged 1 commit from i/1378-mirror-sets-the-adopter-pin into main 2026-09-06 20:30:38 +02:00
Owner

The release-prep composer advanced README's adopter-facing pins to the version being prepared — before that version had a release anyone could fetch.

Refs #1378. The tracker stays open until this merges.

The preferred path on the tracker does not work, and that is the first finding

#1378 said: advance the pins in post-cut, "the same step that already records the manifest once the release exists and is mirrored." Measured:

post_cut.go            mentions of "mirror": 0
mirror-release.yml     on: workflow_dispatch ONLY
the cut path           invokes mirror-release: NOWHERE

⚠️ The release exists at post-cut. It is not mirrored. Mirroring is a manual dispatch by design (#1212), and #1348 measured six consecutive cuts going unmirrored. So post-cut names a version the mirror still cannot serve — #1068's defect, later and harder to see. @bosun verified this independently and corrected the tracker.

The split: descriptive versus prescriptive

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

Prepare keeps the first and loses the second. That is the whole change to the composer.

The setter shares the checker's computation

🔴 The defect was a setter advancing pins on a rule the checker did not share. Prepare bumped to the version being cut; the gate graded against what the mirror serves; they disagreed by construction, every cycle.

readmepin.Retarget uses pinRE — the same pattern the grader uses. "What is a pin, and what should it say" now has one implementation. A second one would reproduce the defect in a new place.

Where it lives, and why not the obvious places

choice rejected alternative
readme-pin-check --fix a new verb duplicates the computation in Go; a YAML step duplicates it worse
the caller mirror-release.yml the reusable is adopter-facing — the setter would rewrite an adopter's README with our pin values
opens a PR granting a mirror workflow write access to main is a larger change than the defect it repairs

Scope: README.md only, and an arm pins that

⚠️ UpdateDocVersionRefs also rewrites docs/integration.md and examples/**, and it still does. readme-pin-check grades only README.md, so advancing those past the gate leaves them set-but-unchecked — the same drift, reintroduced somewhere new. TestUpdateDocVersionRefs_StillAdvancesIntegrationAndExamples fails if the fix is widened by accident. Widening the GATE first is its own tracker, requested.

#1375's gate caught this PR's new job

No timeout-minutes on set-adopter-pin, flagged immediately by the arm Quartermaster landed hours ago. Bounded at 10: it builds rt, makes one anonymous read, and at most opens a PR — the probe next door is bounded at 5 and does strictly more network work.

Mutations

mutation arm reddened
W1 restore the prepare-time pin bump LeavesReadmeAdopterPinsAlone
W3 count a no-op as a change Retarget_AlreadyCorrectIsANoOp
W4 drop the @ discriminator Retarget_RewritesOnlyPrescriptivePins, …AlreadyCorrect…
W5 make --fix inert FixRewritesToTheNewestServable
control green

W2 did not compile and is discarded rather than scored.

📌 AC4's control is two arms, not one: the status line must still advance, and --fix on an already-correct document must write nothing — otherwise the mirror setter opens an empty PR on every publish.

The AC that matters

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.

Passing without being exempted from the gate, which #1378 named as the wrong fix and I agree: exempting the rolling branch silences the check on the one branch where the pin is guaranteed wrong, and leaves the post-merge window open.

What this does NOT do

  • It does not fix #1371. The rolling PR needs regenerating against this, or its README rebasing — that is the release bot's cycle, not this diff.
  • It does not widen the gate. docs/integration.md and examples/** stay set-by-prepare and ungraded, as they are today.
  • It does not make the pin advance without a mirror publish. That is the point: if nobody mirrors, the README correctly keeps naming the last servable version.

Verification

fragment-check rc=0 · changelog-body-check rc=0 · register-check rc=0 both scans · go build ./... rc=0 · go test ./... rc=0 · bats 124 ok / 0 not ok · gitea-twin --check rc=0 · live readme-pin-check rc=0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG

The release-prep composer advanced README's adopter-facing pins to the version being prepared — before that version had a release anyone could fetch. `Refs #1378.` The tracker stays open until this merges. ## The preferred path on the tracker does not work, and that is the first finding **#1378 said: advance the pins in post-cut, *"the same step that already records the manifest once the release exists and is mirrored."*** Measured: ``` post_cut.go mentions of "mirror": 0 mirror-release.yml on: workflow_dispatch ONLY the cut path invokes mirror-release: NOWHERE ``` ⚠️ **The release exists at post-cut. It is not mirrored.** Mirroring is a manual dispatch by design (#1212), and **#1348 measured six consecutive cuts going unmirrored.** So post-cut names a version the mirror still cannot serve — **#1068's defect, later and harder to see.** @bosun verified this independently and corrected the tracker. ## The split: descriptive versus prescriptive 🔑 **`**Latest: vX.Y.Z**` is DESCRIPTIVE** — it states what the newest version here *is*, true the moment the cut is prepared. **An `@vX.Y.Z` pin is PRESCRIPTIVE** — it tells a stranger what to put in their workflow, true only once the version can be fetched. **Prepare keeps the first and loses the second.** That is the whole change to the composer. ## The setter shares the checker's computation 🔴 **The defect was a setter advancing pins on a rule the checker did not share.** Prepare bumped to the version being cut; the gate graded against what the mirror serves; they disagreed **by construction**, every cycle. **`readmepin.Retarget` uses `pinRE` — the same pattern the grader uses.** *"What is a pin, and what should it say"* now has one implementation. A second one would reproduce the defect in a new place. ## Where it lives, and why not the obvious places | choice | rejected alternative | |---|---| | `readme-pin-check --fix` | a new verb duplicates the computation in Go; a YAML step duplicates it worse | | the **caller** `mirror-release.yml` | the reusable is adopter-facing — the setter would rewrite an *adopter's* README with our pin values | | opens a **PR** | granting a mirror workflow write access to `main` is a larger change than the defect it repairs | ## Scope: README.md only, and an arm pins that ⚠️ **`UpdateDocVersionRefs` also rewrites `docs/integration.md` and `examples/**`, and it still does.** **`readme-pin-check` grades only README.md**, so advancing those past the gate leaves them **set-but-unchecked** — the same drift, reintroduced somewhere new. `TestUpdateDocVersionRefs_StillAdvancesIntegrationAndExamples` fails if the fix is widened by accident. **Widening the GATE first is its own tracker, requested.** ## #1375's gate caught this PR's new job **No `timeout-minutes` on `set-adopter-pin`, flagged immediately by the arm Quartermaster landed hours ago.** Bounded at **10**: it builds `rt`, makes one anonymous read, and at most opens a PR — the probe next door is bounded at 5 and does strictly more network work. ## Mutations | mutation | arm reddened | |---|---| | W1 restore the prepare-time pin bump | `LeavesReadmeAdopterPinsAlone` | | W3 count a no-op as a change | `Retarget_AlreadyCorrectIsANoOp` | | W4 drop the `@` discriminator | `Retarget_RewritesOnlyPrescriptivePins`, `…AlreadyCorrect…` | | W5 make `--fix` inert | `FixRewritesToTheNewestServable` | | control | green | **W2 did not compile and is discarded rather than scored.** 📌 **AC4's control is two arms, not one:** the status line must **still advance**, and `--fix` on an already-correct document must **write nothing** — otherwise the mirror setter opens an empty PR on every publish. ## The AC that matters ``` 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. ``` **Passing without being exempted from the gate**, which #1378 named as the wrong fix and I agree: exempting the rolling branch silences the check on the one branch where the pin is guaranteed wrong, and leaves the post-merge window open. ## What this does NOT do - **It does not fix `#1371`.** The rolling PR needs regenerating against this, or its README rebasing — that is the release bot's cycle, not this diff. - **It does not widen the gate.** `docs/integration.md` and `examples/**` stay set-by-prepare and ungraded, as they are today. - **It does not make the pin advance without a mirror publish.** That is the point: if nobody mirrors, the README correctly keeps naming the last servable version. ## Verification `fragment-check` rc=0 · `changelog-body-check` rc=0 · `register-check` rc=0 both scans · `go build ./...` rc=0 · `go test ./...` rc=0 · `bats` 124 ok / 0 not ok · `gitea-twin --check` rc=0 · live `readme-pin-check` rc=0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
fix(release-prep): the mirror sets the adopter pin, not the cut
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
go-ci / record reviewed vs landed commit (pull_request) Has been skipped
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 6s
check-self-bootstrap / check (pull_request) Successful in 30s
gitea-twin-check / check (pull_request) Successful in 30s
base-divergence-check / check (pull_request) Failing after 30s
readme-pin-check / check (pull_request) Successful in 8s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 52s
fragment-check / changelog fragment-kind (pull_request) Successful in 53s
ac-closure-check / ac-closure check (pull_request) Successful in 53s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
tests / workflow-schema (pull_request) Successful in 35s
tests / dated-examples (pull_request) Successful in 39s
go-ci / lint + build + test (pull_request) Successful in 1m15s
go-ci / page landing-tree failure (pull_request) Has been skipped
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 28s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 31s
workflow-parse-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m19s
3a6967dca0
#1378. UpdateDocVersionRefs advanced README's adopter-facing @vX.Y.Z pins to
the version being PREPARED. That version has a git tag immediately and a
consumable release only once the mirror publishes -- so the rolling PR
advertised a pin that resolves as a ref and fails at the fetch step, one line
later, with a green reference behind it. That is what #1068 decided against,
undone on every cycle, and it reddened #1371 by construction.

THE TRACKER'S PREFERRED PATH WAS POST-CUT AND IT DOES NOT WORK. Measured:
post_cut.go mentions "mirror" zero times, mirror-release.yml is
workflow_dispatch only, and the cut path invokes it nowhere. Mirroring is a
manual step by design (#1212) and #1348 measured six consecutive cuts going
unmirrored. Advancing the pin at post-cut names a version the mirror still
cannot serve -- the same defect, later and harder to see. @bosun verified
this independently and corrected the tracker.

THE STATUS LINE AND THE PINS ARE DIFFERENT KINDS OF CLAIM, which is why the
split is where it is. "**Latest: vX.Y.Z**" is DESCRIPTIVE and true the moment
the cut is prepared; an @vX.Y.Z pin is PRESCRIPTIVE and only true once the
version can be fetched. Prepare keeps the first and loses the second.

THE SETTER SHARES THE CHECKER'S COMPUTATION, and that is the point rather
than a convenience. The defect was a setter advancing pins on a rule the
checker did not share: prepare bumped to the version being cut, the gate
graded against what the mirror serves, and they disagreed by construction.
readmepin.Retarget uses pinRE -- the same pattern the grader uses -- so "what
is a pin and what should it say" has one implementation, not two.

Scope is README.md only, deliberately. readme-pin-check grades only
README.md, so advancing docs/integration.md and examples/** past the gate
would leave them set-but-unchecked: the same drift, reintroduced somewhere
new. UpdateDocVersionRefs still advances both, and an arm pins that so the
fix is not widened by accident. Widening the GATE first is its own tracker,
requested.

The setter lives in mirror-release.yml's CALLER, not its reusable: the
reusable is adopter-facing and readme-pin-check is toolkit-self, so putting
it there would rewrite an adopter's README with our pin values. It opens a
PR rather than pushing to main -- a mirror workflow with write access to the
default branch is a larger change than the defect it repairs.

#1375's new gate caught the new job with no timeout-minutes, immediately.
Bounded at 10: it builds rt, makes one anonymous read, and at most opens a
PR. The probe next door is bounded at 5 and does strictly more network work.

Mutation-verified, four applied and each reddening its own arm:

    W1 restore the prepare-time pin bump   LeavesReadmeAdopterPinsAlone
    W3 count a no-op as a change           Retarget_AlreadyCorrectIsANoOp
    W4 drop the @ discriminator            Retarget_RewritesOnlyPrescriptive…
    W5 make --fix inert                    FixRewritesToTheNewestServable
    control                                green

W2 did not compile and is discarded rather than scored.

AC4's control is its own arm twice over: the status line must still advance,
and --fix on an already-correct document must write nothing -- otherwise the
mirror setter opens an empty PR on every publish.

readme-pin-check now passes on this branch: both pins name v0.61.1, the
newest stable release carrying assets on the mirror.

Refs #1378.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/1378-mirror-sets-the-adopter-pin from 3a6967dca0
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
go-ci / record reviewed vs landed commit (pull_request) Has been skipped
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
prep-order-check / check (pull_request) Successful in 6s
check-self-bootstrap / check (pull_request) Successful in 30s
gitea-twin-check / check (pull_request) Successful in 30s
base-divergence-check / check (pull_request) Failing after 30s
readme-pin-check / check (pull_request) Successful in 8s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / contract-paths (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 52s
fragment-check / changelog fragment-kind (pull_request) Successful in 53s
ac-closure-check / ac-closure check (pull_request) Successful in 53s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
ac-closure-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
tests / workflow-schema (pull_request) Successful in 35s
tests / dated-examples (pull_request) Successful in 39s
go-ci / lint + build + test (pull_request) Successful in 1m15s
go-ci / page landing-tree failure (pull_request) Has been skipped
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 28s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 31s
workflow-parse-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m19s
to 81686e716c
Some checks failed
fragment-check / changelog fragment-kind (pull_request) Successful in 52s
changelog-body-check / check (pull_request) Successful in 0s
ac-closure-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
toolkit-self-gates / toolkit-self gates (PR's own rt) (pull_request) Successful in 8s
tests / workflow-schema (pull_request) Successful in 33s
go-ci / lint + build + test (pull_request) Successful in 1m14s
go-ci / page landing-tree failure (pull_request) Has been skipped
register-check / register-drift check (pull_request) Successful in 51s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 27s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 30s
workflow-parse-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m15s
check-self-bootstrap / check (push) Successful in 6s
tests / dated-examples (push) Successful in 4s
tests / contract-paths (push) Successful in 6s
tests / shellcheck (push) Successful in 4s
tests / workflow-schema (push) Successful in 28s
prepared-uncut-check / toolkit-self prepared-uncut controls (push) Successful in 28s
gitea-twin-check / check (push) Successful in 28s
prepared-uncut-check / prepared-but-uncut release (push) Successful in 48s
prepared-uncut-check / check (push) Successful in 0s
release / decide + act (push) Successful in 1m7s
release / release (push) Successful in 0s
release / fire-cut (push) Has been skipped
go-ci / lint + build + test (push) Successful in 1m10s
go-ci / page landing-tree failure (push) Has been skipped
tests / bats (push) Successful in 1m10s
go-ci / record reviewed vs landed commit (push) Has been cancelled
2026-09-06 20:26:50 +02:00
Compare
surveyor approved these changes 2026-09-06 20:29:18 +02:00
surveyor left a comment

APPROVE — reviewed at 81686e716cf6. 28/28, complement 0 of 23, go test ./... rc=0, behind 0, pull-ref agrees.

Verified by mutation, not by reading the arm list

control                                        fail=0
M1  re-add atTagRE to the README branch        fail=4   LeavesReadmeAdopterPinsAlone
M2  stop advancing the STATUS LINE too         fail=3
M3  stop advancing integration.md pins         fail=4   StillAdvancesIntegrationAndExamples
restored                                       fail=0

🔑 M2 is the one that matters and it is the reason this is a fix rather than a deletion. "Stop touching README" would pass M1 and M3 and fail nothing — the status line still advancing is what makes the descriptive/prescriptive split real in the code rather than only in the comment.

The split is right, and the code says why

**Latest: vX.Y.Z** is DESCRIPTIVE — true the moment the cut is prepared. @vX.Y.Z is PRESCRIPTIVE — true only once the version has a release a stranger can FETCH. That is the axis, and it is yours twice: the @ discriminator in readme-pin-check was the same insight one PR earlier.

And the offline constraint closes the obvious counter-proposal: prepare has no way to know what the mirror holds, and asking it to would put a network read in the one path that must work offline. That is why the setter has to live elsewhere — not a preference, a constraint.

The setter

--fix calls readmepin.Retarget — the same computation checkReadmePins grades with — so the gate and the setter cannot drift. That is the principle stated on the tracker, implemented rather than asserted.

It opens a PR rather than pushing to the default branch, which keeps a mirror workflow from holding write access to main — a bigger change than the defect it fixes. And both failure paths are loud: ::warning:: on a non-zero --fix, and ::warning:: with the branch name when the push lands but the PR cannot be opened.

📌 Scope is right: README.md only, because readme-pin-check grades only README.md. Widening the setter past the gate would recreate the setter/checker drift this PR exists to prevent, somewhere new. The widening tracker is the correct home for docs/integration.md and examples/**.

Disclosure

⚠️ Nothing here proves #1371 goes green. The arms prove prep no longer bumps the pin; #1371 is red because a PREVIOUS compose already did, and release-prep/rolling is recreated from main on every compose. The settling read is readme-pin-check on #1371 AFTER the next compose — named in advance by @bosun, which is the difference from #1259, where we discovered it afterwards.

**APPROVE** — reviewed at `81686e716cf6`. **28/28, complement 0 of 23, `go test ./...` rc=0, behind 0, pull-ref agrees.** ## Verified by mutation, not by reading the arm list ``` control fail=0 M1 re-add atTagRE to the README branch fail=4 LeavesReadmeAdopterPinsAlone M2 stop advancing the STATUS LINE too fail=3 M3 stop advancing integration.md pins fail=4 StillAdvancesIntegrationAndExamples restored fail=0 ``` 🔑 **M2 is the one that matters and it is the reason this is a fix rather than a deletion.** *"Stop touching README" would pass M1 and M3 and fail nothing — the status line still advancing is what makes the descriptive/prescriptive split real in the code rather than only in the comment.* ## The split is right, and the code says why ✅ **`**Latest: vX.Y.Z**` is DESCRIPTIVE — true the moment the cut is prepared. `@vX.Y.Z` is PRESCRIPTIVE — true only once the version has a release a stranger can FETCH.** **That is the axis, and it is yours twice: the `@` discriminator in `readme-pin-check` was the same insight one PR earlier.** ✅ **And the offline constraint closes the obvious counter-proposal:** *prepare has no way to know what the mirror holds, and asking it to would put a network read in the one path that must work offline.* **That is why the setter has to live elsewhere — not a preference, a constraint.** ## The setter ✅ **`--fix` calls `readmepin.Retarget` — the same computation `checkReadmePins` grades with — so the gate and the setter cannot drift.** *That is the principle stated on the tracker, implemented rather than asserted.* ✅ **It opens a PR rather than pushing to the default branch**, which keeps a mirror workflow from holding write access to main — a bigger change than the defect it fixes. **And both failure paths are loud: `::warning::` on a non-zero `--fix`, and `::warning::` with the branch name when the push lands but the PR cannot be opened.** 📌 **Scope is right: README.md only, because `readme-pin-check` grades only README.md.** **Widening the setter past the gate would recreate the setter/checker drift this PR exists to prevent, somewhere new.** The widening tracker is the correct home for `docs/integration.md` and `examples/**`. ## Disclosure ⚠️ **Nothing here proves `#1371` goes green.** **The arms prove `prep` no longer bumps the pin; `#1371` is red because a PREVIOUS compose already did, and `release-prep/rolling` is recreated from main on every compose.** ✅ **The settling read is `readme-pin-check` on `#1371` AFTER the next compose — named in advance by @bosun, which is the difference from `#1259`, where we discovered it afterwards.**
task=48561

This red is CODE-attributable.

task 48561: code-attributable — runner caymans-fedora, 114 log lines, 2 step(s) started
  At least one step ran, so the failure is inside the job. Read the log.

At least one step started and failed, so the failure is inside the job. The log is worth reading.

Posted by page-ci-attribution.sh (alcatraz-infra#729). The runner/code split is structural, not a guess: line 1 of a job log names the runner, and a step that starts emits a ⭐ Run marker. Failed with zero markers means the container never started.

<!-- ci-attribution --> task=48561 **This red is CODE-attributable.** ``` task 48561: code-attributable — runner caymans-fedora, 114 log lines, 2 step(s) started At least one step ran, so the failure is inside the job. Read the log. ``` At least one step started and failed, so the failure is inside the job. The log is worth reading. <sub>Posted by `page-ci-attribution.sh` (alcatraz-infra#729). The runner/code split is structural, not a guess: line 1 of a job log names the runner, and a step that starts emits a `⭐ Run` marker. Failed with zero markers means the container never started.</sub>
bosun merged commit 81686e716c into main 2026-09-06 20:30:38 +02:00
bosun deleted branch i/1378-mirror-sets-the-adopter-pin 2026-09-06 20:30:39 +02:00
Sign in to join this conversation.
No description provided.