Context
Found while fixing a regression the GPT-5.6 review engine raised on the ADR-0003 implementation (PR for issue #6, commit bd65732).
That fix closes the register path: re-running an interrupted +rebuild.1 after +rebuild.2 had completed both phases no longer opens a catalog pull request walking consumers backwards. The same hazard remains on the publish path, and is left as it was found — recorded under ADR-0003 § Consequences → Negative.
The gap
classifyRelease (scripts/lib/release-state.ts) checks supersession only after establishing that the release exists:
if (!state.releaseExists) {
// ... refuse when the catalog names this tag
return 'publish'
}
if (registered)
return 'complete'
if (state.catalogTag !== null && isSupersededBy(state.tag, state.catalogTag))
return 'complete'
return 'register'
A tag whose release does not exist returns publish regardless of what the catalog names. release.yml gates registration on mode != 'complete', so publish runs both phases — publication and the catalog write.
Reachable sequence:
boot-4.1.1+rebuild.1 is tagged; its run fails during build, so no release is ever created
- The operator cuts
boot-4.1.1+rebuild.2 instead; it publishes and registers cleanly
- Someone re-runs the failed
rebuild.1 job (or re-pushes the tag)
releaseExists === false → publish → rebuild.1 is published, then update-catalog.ts repoints the entry from rebuild.2 back to rebuild.1
applyEntry (scripts/lib/catalog-update.ts:47-60) permits this by design — it records "the rebuild last published, not the highest one" and deliberately does not enforce suffix ordering. Step 4 also drags released_at backwards.
Publication itself is harmless: rebuild.1 is a tag of its own and no published asset is replaced. The catalog write is the damaging half.
Not a regression
The guard this replaced keyed on the release existing:
- name: Refuse to overwrite an existing release
run: |
if gh release view "$TAG" >/dev/null 2>&1; then ... exit 1; fi
With no release present it never fired either, so this path behaved the same way before ADR-0003. It is pre-existing, not introduced by that change.
The decision to make
Not obviously a defect repair — it turns on a question ADR-0003 did not need to answer: should a superseded tag still be publishable at all?
- Extend supersession to
publish — a tag the catalog has moved past returns complete whether or not its release exists. Simplest, and consistent with "the modes name what a run still owes". Costs the ability to publish a back-filled older rebuild as an archived artifact.
- Publish but do not register — a fourth mode that runs phase 1 and skips phase 2. The tag becomes downloadable without disturbing the index. More workflow surface, and a mode whose name has to explain itself.
- Refuse — treat it as an operator error and fail with a message naming the newer rebuild. Loudest, but makes a re-run of a stale failed job an error rather than a no-op, which cuts against the idempotence ADR-0003 chose.
Option 1 folds into the rule already implemented and needs no new mode. Option 2 is the only one that preserves publishing a superseded tag, if that turns out to matter.
Mitigations today
- The catalog write lands as a pull request, not a direct push — a human merge gate stands between this and consumers. The PR title (
chore(catalog): record boot-4.1.1+rebuild.1) looks routine, so the gate is weaker than it sounds.
- The sequence needs a failed-before-publication tag and a later superseding rebuild and a re-run of the stale job.
Acceptance
classifyRelease handles the superseded-and-unpublished case per whichever option is chosen
- A unit test in
tests/unit/release-state.test.ts covers { releaseExists: false, catalogTag: <newer rebuild> }
- ADR-0003's decision table loses its
publish* row and the matching Negative bullet, or a superseding ADR records the change
Related
Context
Found while fixing a regression the GPT-5.6 review engine raised on the ADR-0003 implementation (PR for issue #6, commit
bd65732).That fix closes the
registerpath: re-running an interrupted+rebuild.1after+rebuild.2had completed both phases no longer opens a catalog pull request walking consumers backwards. The same hazard remains on thepublishpath, and is left as it was found — recorded under ADR-0003 § Consequences → Negative.The gap
classifyRelease(scripts/lib/release-state.ts) checks supersession only after establishing that the release exists:A tag whose release does not exist returns
publishregardless of what the catalog names.release.ymlgates registration onmode != 'complete', sopublishruns both phases — publication and the catalog write.Reachable sequence:
boot-4.1.1+rebuild.1is tagged; its run fails during build, so no release is ever createdboot-4.1.1+rebuild.2instead; it publishes and registers cleanlyrebuild.1job (or re-pushes the tag)releaseExists === false→publish→rebuild.1is published, thenupdate-catalog.tsrepoints the entry fromrebuild.2back torebuild.1applyEntry(scripts/lib/catalog-update.ts:47-60) permits this by design — it records "the rebuild last published, not the highest one" and deliberately does not enforce suffix ordering. Step 4 also dragsreleased_atbackwards.Publication itself is harmless:
rebuild.1is a tag of its own and no published asset is replaced. The catalog write is the damaging half.Not a regression
The guard this replaced keyed on the release existing:
With no release present it never fired either, so this path behaved the same way before ADR-0003. It is pre-existing, not introduced by that change.
The decision to make
Not obviously a defect repair — it turns on a question ADR-0003 did not need to answer: should a superseded tag still be publishable at all?
publish— a tag the catalog has moved past returnscompletewhether or not its release exists. Simplest, and consistent with "the modes name what a run still owes". Costs the ability to publish a back-filled older rebuild as an archived artifact.Option 1 folds into the rule already implemented and needs no new mode. Option 2 is the only one that preserves publishing a superseded tag, if that turns out to matter.
Mitigations today
chore(catalog): record boot-4.1.1+rebuild.1) looks routine, so the gate is weaker than it sounds.Acceptance
classifyReleasehandles the superseded-and-unpublished case per whichever option is chosentests/unit/release-state.test.tscovers{ releaseExists: false, catalogTag: <newer rebuild> }publish*row and the matching Negative bullet, or a superseding ADR records the changeRelated
.please/docs/decisions/0003-idempotent-release-recovery.md) — decision table row 5, and § Consequences → Negativescripts/lib/release-state.ts—classifyRelease,isSupersededByscripts/lib/catalog-update.ts:47-60—applyEntry, the permissive repoint this relies on