Skip to content

fix(release): classify a superseded tag as complete before it can publish - #248

Merged
amondnet merged 1 commit into
mainfrom
amondnet/issue-9-supersession-publish-path
Sep 16, 2026
Merged

amondnet merged 1 commit into
mainfrom
amondnet/issue-9-supersession-publish-path

Conversation

@amondnet

@amondnet amondnet commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

The gap

classifyRelease asked whether a +rebuild.N had superseded this tag only after establishing that the release exists:

if (!state.releaseExists) {
  // ... refuse when the catalog names this tag
  return 'publish'          // ← supersession never consulted
}

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.

Reachable sequence:

  1. boot-4.1.1+rebuild.1 is tagged; its run fails during build, so no release is created
  2. The operator cuts boot-4.1.1+rebuild.2 instead; it publishes and registers cleanly
  3. Someone re-runs the failed rebuild.1 job
  4. releaseExists === falsepublishrebuild.1 is published, then the catalog pull request repoints the entry from rebuild.2 back to rebuild.1, dragging released_at backwards

Publication itself is harmless. The catalog write is the damaging half, and it lands under a routine chore(catalog): record boot-4.1.1+rebuild.1 title.

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.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.

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:

Release exists Catalog entry Mode (before) Mode (after)
no a newer rebuild of the pair publish complete

Everything else is untouched — register still proves the published bytes before indexing them, and the refusal for a catalog entry whose release is gone still fires first.

Why classifyRelease and not applyEntry

applyEntry's permissiveness about suffix ordering is deliberate: the catalog records the rebuild last published, not the highest one, so republishing a known-good rebuild.1 over a bad rebuild.2 must 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

  • The complete notice 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.
  • ADR-0005 records the decision and weighs all four options (including blocking in applyEntry, and doing nothing).
  • ADR-0003 keeps its original text — org convention is that past ADRs are immutable — and gains forward pointers from the decision-table footnote and the matching Negative bullet so the stale row cannot be read without seeing it was amended.
  • ARCHITECTURE.md and the release-pipeline skill pick up the second governing ADR and the operator-facing consequence.

Verification

bun test tests/unit      383 pass, 0 fail
bunx tsc --noEmit -p .   clean
bun run lint             0 warnings

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 complete and skips both phases, so superseded tags are no longer published.

Documentation and verification

  • Updates the workflow notice to report the tag consumers currently resolve to.
  • Adds ADR-0005 and cross-references the supersession rule in the release documentation.
  • Adds coverage for unpublished rebuild and base tags; tests, type checking, and lint pass.

Written for commit 9330871. Summary will update on new commits.

…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
@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the new classification closes the stale catalog-repointing path without disrupting the other release states.

Summary

This PR closes the stale-release recovery path that could repoint the catalog to an older rebuild.

  • Classifies a tag as complete whenever the catalog has moved to a newer rebuild, even if the older tag was never published.
  • Preserves the refusal when the catalog names the current tag but its GitHub release is missing.
  • Updates workflow messaging so complete no longer implies that the current tag was published.
  • Adds decision-table regression coverage and documents the amended release behavior in ADR-0005 and operator guidance.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Observe release and catalog state] --> B{Catalog names this tag?}
  B -->|Yes| C{Release exists?}
  C -->|No| D[Refuse: repair catalog]
  C -->|Yes| E[complete]
  B -->|No| F{Catalog has newer rebuild?}
  F -->|Yes| E
  F -->|No| G{Release exists?}
  G -->|Yes| H[register]
  G -->|No| I[publish]
Loading

Reviews (1) · Last reviewed commit: "fix(release): classify a superseded tag ..."

@amondnet
amondnet merged commit 920c135 into main Sep 16, 2026
9 checks passed
amondnet added a commit that referenced this pull request Sep 17, 2026
#248 landed ADR-0005 on main (supersession on the publish path) while this
branch was open, so both sides claimed 0005. Renumber this one and follow the
references through README.md, the knowledge note and the index.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publishing a superseded tag still walks catalog.json backwards

1 participant