…lish
`classifyRelease` asked whether a `+rebuild.N` had superseded this tag only
after establishing that the release exists. A tag whose run failed before
`gh release create` therefore returned `publish` no matter what the catalog
named, and `release.yml` gates registration on `mode != 'complete'` — so
`publish` runs both phases. Re-running an interrupted `+rebuild.1` after
`+rebuild.2` had completed published a stale archive and then opened a catalog
pull request repointing the entry from `rebuild.2` back to `rebuild.1`,
dragging `released_at` backwards under a routine `chore(catalog): record …`
title.
The sequence was one re-run away on 2026-09-16: of the 47 Boot rebuilds cut
that day, four runs — boot-3.3.6, boot-3.3.7, boot-3.5.6 and boot-3.5.7, all
`+rebuild.1` — were cancelled at the Build step's 30-minute timeout, before any
release existed. Cutting a `+rebuild.2` for any of them instead of re-running
was all it would have taken.
Supersession is now decided before publication is considered, so the two
superseded rows of the decision table answer the same way. The check stays in
`classifyRelease` rather than moving to `applyEntry`: the catalog records the
rebuild last published, not the highest one, and that permissiveness is
deliberate — republishing a known-good `rebuild.1` over a bad `rebuild.2` must
remain expressible. What is wrong is not the write but a run making it by
accident, which is a fact about what the run owes.
The `complete` notice no longer claims `$TAG is published`, since the new row
is reached precisely when it is not.
ADR-0005 records the decision and the three options weighed; ADR-0003 keeps its
original text and gains forward pointers from the row and Negative bullet this
amends.
Closes #9
The gap
classifyReleaseasked whether a+rebuild.Nhad superseded this tag only after establishing that the release exists:A tag whose run failed before
gh release createtherefore returnedpublishno matter what the catalog named, andrelease.ymlgates registration onmode != 'complete'— sopublishruns both phases.Reachable sequence:
boot-4.1.1+rebuild.1is tagged; its run fails during build, so no release is createdboot-4.1.1+rebuild.2instead; it publishes and registers cleanlyrebuild.1jobreleaseExists === false→publish→rebuild.1is published, then the catalog pull request repoints the entry fromrebuild.2back torebuild.1, draggingreleased_atbackwardsPublication itself is harmless. The catalog write is the damaging half, and it lands under a routine
chore(catalog): record boot-4.1.1+rebuild.1title.Step 1 is not hypothetical. Of the 47 Boot rebuilds cut on 2026-09-16, four runs —
boot-3.3.6,boot-3.3.7,boot-3.5.6andboot-3.5.7, all+rebuild.1— were cancelled at the Build step's 30-minute timeout, before any release existed. Cutting a+rebuild.2for any of them instead of re-running was all it would have taken.The fix
Supersession is decided before publication is considered, so both superseded rows of the decision table answer the same way. Only one row changes:
publishcompleteEverything else is untouched —
registerstill proves the published bytes before indexing them, and the refusal for a catalog entry whose release is gone still fires first.Why
classifyReleaseand notapplyEntryapplyEntry's permissiveness about suffix ordering is deliberate: the catalog records the rebuild last published, not the highest one, so republishing a known-goodrebuild.1over a badrebuild.2must stay expressible. What is wrong is not the write — it is a run making it by accident. Ordering is a fact about what the run owes, so it belongs where the other two facts are already weighed. The design the issue flagged as intentional is left intact.Why no fourth mode
The modes name what a run still owes (ADR-0003), and a run the catalog has moved past owes nothing whichever phases it has left. The cost is recorded honestly in ADR-0005 § Negative: a superseded tag can no longer be published as an archived artifact. Nothing needs that today — the catalog is the only discovery path, and all 155 published versions are their own entry's tag — and the rejected "publish but do not register" option is what to add if it ever matters.
Also changed
completenotice no longer claims$TAG is published, since the new row is reached precisely when it is not. It now names only the tag consumers resolve to.applyEntry, and doing nothing).Verification
Two new table cases in
tests/unit/release-state.test.ts, both{ releaseExists: false, catalogTag: <newer rebuild> }— the rebuild-ordinal case from the issue, and the same rule at ordinal 0 (a base tag a rebuild replaced). Both fail against the old ordering.Closes #9
Summary by cubic
Closes #9 by classifying supersession before checking whether a release exists. Previously, rerunning a failed older rebuild could publish it and move the catalog backward; now it returns
completeand skips both phases, so superseded tags are no longer published.Documentation and verification
Written for commit 9330871. Summary will update on new commits.