* chore(#930): remove self-masking next dist-tag repoint from release finalize The "Clean up next dist-tag" step silently failed under OIDC trusted publishing (which can't write dist-tags) while unconditionally reporting success via || true + an echo. It also violated the release model by trying to repoint @next→stable; @next is managed exclusively by the rc job's --tag next publish. Closes #930 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update ADR-660 to reflect removal of next dist-tag repoint The finalize job no longer runs `npm dist-tag add … next`; update the ADR-660 description of step 4 to match the new behavior — @next is managed exclusively by the rc job. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
9.5 KiB
ADR 660: Release from the head of next; immutable release tags; @next dist-tag as the RC surface [Proposed]
- Status: Proposed
- Date: 2026-06-03
Context
The release pipeline (.github/workflows/release.yml) is a three-mode workflow_dispatch
(create / rc / finalize) built around a persistent, long-lived release/<version>
branch:
createcutsrelease/<version>fromnextand commits a version bump.rcchecks out that same branch (ref: release/<version>), bumps to-rc.N, tagsv<version>-rc.N, and publishes to the@nextnpm dist-tag.finalizechecks out that same branch, bumps to the final version, tagsv<version>, publishes to@latest, opens a PR back tomain, andauto-backmerge.ymllater mergesmain→next.
This has a structural defect. No job ever brings post-create work from next into the
release branch — there is no git merge/rebase/cherry-pick from next anywhere in
release.yml. So the moment RC testing surfaces a bug:
- The fix is (correctly) committed to
next— our trunk. - The
release/<version>branch does not receive it. finalizetherefore ships the rc-cut tree, missing every RC fix.
To compensate, we have been hand-moving the v<version> tag forward to the head of
next each cycle. This is the "dance every release." It is two documented antipatterns
stacked:
- Freezing a release branch you never backport into. Trunk-based development requires fixes to flow trunk → release branch (fix on trunk, cherry-pick down), never "fix on trunk and leave the release branch behind" (trunkbaseddevelopment.com/branch-for-release). GitFlow's own author now steers continuous-delivery projects away from this model (nvie.com).
- A movable release tag. Git's manual ("On Re-tagging") and SemVer both forbid it — "Once a versioned package has been released, the contents of that version MUST NOT be modified" (semver.org). Moving a published tag breaks our SSH signatures, already-fetched clones, caches, and the GitHub Release; GitHub shipped Immutable Releases (GA 2025) specifically to stop this.
We have meaningful existing investment we want to keep: the homegrown changeset/CHANGELOG
fragment system (scripts/changeset/*.cjs, changeset-required.yml), the curated
release-notes formatter (scripts/release-notes/format-github-release-notes.cjs), the
inter-stage smoke-test gates, provenance publishing, and the main(@latest) / next(integration)
auto-backmergetopology — which is already the correct "main holds releases, next is integration" shape.
Decision
Stop persisting/freezing the release branch. Always release from the current head of next,
create each release git tag exactly once, and treat the @next npm dist-tag — not a git branch
or a movable tag — as the RC surface. Concretely:
-
The release point is always
next's head at invocation time.rcandfinalizederive their tree from the currentorigin/nextHEAD rather than reusing a stalerelease/<version>branch. Implementation: recreate (or hard-reset) an ephemeralrelease/<version>branch fromorigin/nextHEAD at the start of eachrc/finalizerun. The final version-bump commit lands on this short-lived branch and reachesmainvia the release PR; the branch is a scratch staging area, not a frozen snapshot — so it always contains every RC fix. -
nextcarries a-devprerelease version (the dev stream). Between releases,next'spackage.jsonno longer rests at the last-released number — it carriesX.Y.Z-dev.Nfor the anticipated next version, so the trunk self-identifies as unreleased. Default floor after releasingA.B.Cis the next patch,A.B.(C+1)-dev.0(precedence-safe: greater thanA.B.C, and it never overstates the eventual release, whichfinalizemay set higher).@nextdist-tag publishes carry this-devsnapshot identity;rcoverrides it with the chosen-rc.N;finalizesets the final number. Afterfinalize+ themain→nextbackmerge, a post-release step bumpsnextto the new-devfloor. -
Release tags are immutable, created once, by
finalizeonly. No tag is ever pre-created as a placeholder or force-moved.finalizemintsv<version>on the final commit and pushes it once. (This also removes the manual step that currently breaksfinalize, whose tag-existence guard hard-errors on any pre-existingv<version>tag.) RC tagsv<version>-rc.Nremain — each N is unique and never moved, so they are already immutable and serve the GitHub prerelease. -
RC = the
@nextdist-tag, full stop. Testers runnpm i -g @opengsd/gsd-core@next. Because eachrcrun is cut fromnextHEAD, every rc.N already includes all prior fixes. No long-lived branch, no tag movement.finalizepromotes the released version to@latest(@nextremains the prerelease channel managed exclusively by thercjob;finalizedoes not repoint it). -
Everything else stays: custom changesets + CHANGELOG render, release-notes formatter, smoke-test gates, provenance,
main/next,auto-backmerge(main→next).
In short: the immutable v<version> tag that finalize creates — landing on main via the
release→main PR — is the "historical marker for the release" we wanted. The intuition was
right; only the movable placeholder mechanic was wrong.
Alternatives considered
- Adopt
release-please. Auto-updating Release PR offnextwould also kill the freeze, and tags are immutable. Rejected for now: it generates CHANGELOG from conventional commits, displacing our custom changeset-fragment system; its prerelease→stable transition has known open bugs (googleapis/release-please #2515, #2447). Migration cost > the defect it fixes. - Adopt
@changesets/cli. Closest to our homegrown system and has a mature auto-updating Version PR. Rejected for now: would replace working in-house tooling, and itspremode has real footguns (thepre.json-not-staged bug silently publishes stable under thercdist-tag — changesets #1150). - Adopt
semantic-release. Lowest ceremony, nativenext→mainchannel promotion. Rejected: "auto-release on every conventional commit" removes the deliberate "decide to cut a release" gate we want, and again displaces our changeset/changelog system. - Keep the persistent branch but cherry-pick RC fixes into it. The textbook trunk-based
approach. Rejected as primary: for a single active version it is pure bookkeeping
overhead, and "forgot to cherry-pick" is exactly the regression trap the literature warns
about. Re-cutting from
nextHEAD gets the same result with zero manual cherry-picks.
Consequences
Positive
- The "dance" is gone: RC fixes are included by construction; no manual tag moves; no frozen branch to reconcile.
- Tags become trustworthy and signature-valid — one commit, one immutable tag, per release.
- We keep all existing investment (changesets, formatter, smoke gates, backmerge) — small,
low-risk diff to
release.yml, no new third-party release dependency.
Negative / costs
release.ymlchanges required:rc/finalizemust recreate/reset the release branch fromorigin/nextat start; remove any reliance on a pre-existing tag.createbecomes near-vestigial (its only job — seed the branch + bump — folds intorc/finalizere-cutting fromnext). Decide whether to deletecreateor keep it as an optional "open the release branch early" convenience.- One-version assumption is now explicit: this model does not support maintaining multiple
live majors (LTS). If that need ever arises, revisit (long-lived
release/x.y+ cherry-pick is the escape hatch).
Rollout
- File this ADR first (maintainer decision): land the proposing issue + ADR PR before any release action, so the model is documented before it is first exercised.
- 1.3.0 (first manual run): ship it as the first manual application of this model —
recreate
release/1.3.0fromnextHEAD (6bd7ceb2), delete the hand-movedv1.3.0tag sofinalizemints it fresh, then runfinalize(dry-run first). This validates the model by hand before we codify it. Immediately after, bumpnextto its first-devfloor (1.3.1-dev.0). - Codify (1.4.0+): update
release.ymlper the Decision (re-cut fromnext,-devstream, post-release-devbump); updatedocs/branching.md; delete or repurposecreate.
Resolved by maintainer (2026-06-03)
- Approach: re-cut from
next's head; keep the in-house tooling (no third-party release tool). nextversion: move to a-devstream (Decision §2), not resting at last-released.- Sequencing: file this ADR first, then ship 1.3.0 as the first manual run.
Open questions (remaining)
- Delete the
createaction, or keep it as an optional early-branch convenience? (Recommend: delete; re-cut fromnextmakes it redundant.) - Keep immutable
v<version>-rc.Ngit tags, or rely on the@nextdist-tag alone for RCs? (Recommend: keep the rc tags — harmless, immutable, and they anchor the GitHub prerelease.) -devfloor increment: next-patch (A.B.(C+1)-dev.0, the precedence-safe default above) or next-minor (A.(B+1).0-dev.0)? (Recommend: next-patch floor.)