* ci(#1104): sync next package.json version to the last published release next rested on a -dev stream per ADR-660 (1.3.1-dev.0) — a never-published placeholder that leaked to source/dev installs. Make every release type write its exact published version back to next: - finalize/hotfix (push main): auto-backmerge sets next's version to main's released version, folded into the existing back-merge PR (+ pinned setup-node). - rc (no main push): the rc job opens + admin-merges a sync PR after publish. Shared, fail-closed scripts/sync-next-version.cjs stamps package.json + the runtime manifests via the npm version hook and refuses any non-release version. Amends ADR-660 (supersedes the -dev stream decision). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(#1104): harden next-version sync against post-publish failure modes Review hardening (Codex + code-review gates) on the #1104 sync helper and its workflow callers: - release.yml rc Sync step: continue-on-error so a post-publish sync hiccup cannot fail an already-published release (npm immutability would block re-run). - auto-backmerge.yml inline sync: set -euo pipefail + validate VERSION before any shell use (closes a ${VERSION}-in-commit-message injection vector); git add -u instead of -A. - sync-next-version.cjs: reuse an existing open PR instead of failing gh pr create on rc re-runs; regex-parse the PR number and fail loud; discriminate the git diff --cached --quiet exit code (only status 1 == has-diff, else rethrow); git add -u to avoid sweeping runner artifacts into next; tolerate already-merged on admin merge. - tests: +2 (existing-PR reuse, non-diff rethrow); 14/14 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
11 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.)
Amendment (2026-06-12, #1104): next tracks the last published release
Supersedes the §2 / "Resolved by maintainer" choice to rest next on a -dev stream.
The -dev floor (e.g. 1.3.1-dev.0) was never published to npm, yet it became the
source-of-truth version on the default branch and leaked to the real world via source/dev
installs that report package.json's version — a version no release ever bore. To eliminate
phantom versions, next now rests at the last published release and is synced
automatically by the release pipeline for every release type:
- finalize / hotfix (these push
main): the existingmain → nextback-merge (.github/workflows/auto-backmerge.yml) setsnext's version tomain's released version, folded into the same back-merge PR. - rc (publishes a pre-release to the
release/<version>branch +@next; does not pushmain): thercjob in.github/workflows/release.ymlopens and admin-merges achore: sync next package versionPR after a confirmed publish.
Both paths share scripts/sync-next-version.cjs, which sets package.json and stamps the
runtime manifests (plugin.json, gemini-extension.json) via the version lifecycle hook,
and refuses any non-release version string (fail-closed — a -dev/placeholder can never be
written to next again). Open question 3 (the -dev floor increment) is therefore moot: there
is no -dev floor.