diff --git a/scripts/__tests__/release-version-anchor.test.mjs b/scripts/__tests__/release-version-anchor.test.mjs new file mode 100644 index 0000000000..4cd6d9ddb2 --- /dev/null +++ b/scripts/__tests__/release-version-anchor.test.mjs @@ -0,0 +1,84 @@ +/* +FNXC:UpdateChannels 2026-07-24-09:40: +Guards the release version-anchor contract: +- after a stable ships, the next beta is based on THAT stable version; +- no release (either channel) may number at or below the newest published stable. +Regression context: v0.73.0 was cut on `release` while `main` stayed in the +0.72.0-anchored pre-mode cycle, so the next beta would have been v0.73.0-beta.7 +(below the shipped v0.73.0) and `pnpm dev` on main still reported the beta. +*/ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + compareReleaseVersions, + evaluateBetaCycleAnchor, + isVersionAheadOfStable, + latestStableVersionFromTags, +} from "../lib/release-version-anchor.mjs"; + +test("compareReleaseVersions ranks a prerelease below its stable", () => { + assert.ok(compareReleaseVersions("0.73.0-beta.6", "0.73.0") < 0); + assert.ok(compareReleaseVersions("0.73.0", "0.73.0-beta.6") > 0); + assert.equal(compareReleaseVersions("0.73.0", "0.73.0"), 0); +}); + +test("compareReleaseVersions compares numeric identifiers numerically", () => { + assert.ok(compareReleaseVersions("0.73.0-beta.9", "0.73.0-beta.10") < 0); + assert.ok(compareReleaseVersions("0.9.0", "0.73.0") < 0); + assert.ok(compareReleaseVersions("0.73.1", "0.73.0") > 0); +}); + +test("latestStableVersionFromTags ignores prerelease tags", () => { + const tags = [ + "v0.72.0", + "v0.73.0", + "v0.73.0-beta.0", + "v0.73.0-beta.6", + "v0.74.0-beta.0", + "not-a-tag", + ]; + assert.equal(latestStableVersionFromTags(tags), "0.73.0"); + assert.equal(latestStableVersionFromTags(tags.join("\n")), "0.73.0"); + assert.equal(latestStableVersionFromTags(["v0.73.0-beta.1"]), null); + assert.equal(latestStableVersionFromTags([]), null); +}); + +test("a beta cycle anchored below a shipped stable is re-anchored on that stable", () => { + // The exact v0.73.0 shape: pre.json still snapshots the 0.72.0 cycle base. + assert.deepEqual( + evaluateBetaCycleAnchor({ cycleBase: "0.72.0", latestStable: "0.73.0" }), + { stale: true, anchor: "0.73.0" }, + ); +}); + +test("a beta cycle already at or above the shipped stable is left alone", () => { + assert.deepEqual( + evaluateBetaCycleAnchor({ cycleBase: "0.73.0", latestStable: "0.73.0" }), + { stale: false, anchor: "0.73.0" }, + ); + assert.deepEqual( + evaluateBetaCycleAnchor({ cycleBase: "0.74.0", latestStable: "0.73.0" }), + { stale: false, anchor: "0.74.0" }, + ); +}); + +test("with no stable tag yet, no cycle is considered stale", () => { + assert.deepEqual( + evaluateBetaCycleAnchor({ cycleBase: "0.1.0", latestStable: null }), + { stale: false, anchor: "0.1.0" }, + ); +}); + +test("isVersionAheadOfStable rejects releases at or below the newest stable", () => { + // The bug: the next beta of a stale cycle numbers beneath the shipped stable. + assert.equal(isVersionAheadOfStable("0.73.0-beta.7", "0.73.0"), false); + assert.equal(isVersionAheadOfStable("0.73.0", "0.73.0"), false); + assert.equal(isVersionAheadOfStable("0.72.9", "0.73.0"), false); + // Both channels' healthy shapes stay allowed. + assert.equal(isVersionAheadOfStable("0.73.1-beta.0", "0.73.0"), true); + assert.equal(isVersionAheadOfStable("0.74.0-beta.0", "0.73.0"), true); + assert.equal(isVersionAheadOfStable("0.74.0", "0.73.0"), true); + assert.equal(isVersionAheadOfStable("0.1.0", null), true); +}); diff --git a/scripts/lib/release-version-anchor.mjs b/scripts/lib/release-version-anchor.mjs new file mode 100644 index 0000000000..f442ec350d --- /dev/null +++ b/scripts/lib/release-version-anchor.mjs @@ -0,0 +1,102 @@ +/* +FNXC:UpdateChannels 2026-07-24-09:40: +Version-anchor rules shared by `scripts/release.mjs` and its tests. + +Requirement: after a stable release ships, the NEXT beta must be based on the +stable version that just shipped, and no release may ever number at or below +the newest published stable. + +Regression context (v0.73.0): the stable was cut on the `release` branch while +`main` stayed inside the 0.72.0-anchored changesets pre-mode cycle. Because a +beta's version is derived from pre.json's `initialVersions` snapshot, the next +beta on main would have been v0.73.0-beta.7 — older than the published v0.73.0 — +and the dev checkout (`pnpm dev`, dashboard version badge) still reported the +last beta. +*/ + +/** + * Prerelease-aware semver comparison: returns <0, 0, >0. + * `1.2.3-beta.1` sorts BELOW `1.2.3`; numeric prerelease identifiers compare + * numerically (`beta.10` > `beta.9`). + */ +export function compareReleaseVersions(a, b) { + const [aCore = "", aPre = ""] = String(a).split("-", 2); + const [bCore = "", bPre = ""] = String(b).split("-", 2); + const ta = parseCore(aCore); + const tb = parseCore(bCore); + for (let i = 0; i < 3; i++) { + if (ta[i] !== tb[i]) return ta[i] < tb[i] ? -1 : 1; + } + if (aPre === bPre) return 0; + if (aPre === "") return 1; + if (bPre === "") return -1; + const pa = aPre.split("."); + const pb = bPre.split("."); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const xa = pa[i]; + const xb = pb[i]; + if (xa === undefined) return -1; + if (xb === undefined) return 1; + const na = /^\d+$/.test(xa) ? Number(xa) : null; + const nb = /^\d+$/.test(xb) ? Number(xb) : null; + if (na !== null && nb !== null) { + if (na !== nb) return na < nb ? -1 : 1; + } else if (xa !== xb) { + return xa < xb ? -1 : 1; + } + } + return 0; +} + +function parseCore(core) { + const m = String(core).match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) return [0, 0, 0]; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +/** + * Highest STABLE version among `v*` git tags. Prerelease tags are ignored on + * purpose — the beta track is anchored on shipped stables, not on other betas. + * + * @param {string[]|string} tags `git tag --list 'v*'` output (raw or split). + * @returns {string|null} e.g. "0.73.0", or null when no stable tag exists yet. + */ +export function latestStableVersionFromTags(tags) { + const lines = Array.isArray(tags) ? tags : String(tags).split("\n"); + let best = null; + for (const line of lines) { + const tag = String(line).trim(); + if (!tag.startsWith("v")) continue; + const version = tag.slice(1); + if (!/^\d+\.\d+\.\d+$/.test(version)) continue; + if (best === null || compareReleaseVersions(version, best) > 0) best = version; + } + return best; +} + +/** + * Decide whether a beta cycle is still anchored beneath a shipped stable. + * + * `cycleBase` is pre.json's `initialVersions["@runfusion/fusion"]` when a + * pre-mode cycle is open, else the current workspace version. When stale, the + * caller exits pre-mode, rewrites the fixed-group package versions to `anchor`, + * and re-enters pre-mode so `changeset version` derives X.Y.Z-beta.N from the + * shipped stable. Pending changesets are untouched, so their bump type still + * decides patch-vs-minor of that stable. + */ +export function evaluateBetaCycleAnchor({ cycleBase, latestStable }) { + if (!latestStable) return { stale: false, anchor: cycleBase ?? null }; + if (!cycleBase) return { stale: true, anchor: latestStable }; + const stale = compareReleaseVersions(cycleBase, latestStable) < 0; + return { stale, anchor: stale ? latestStable : cycleBase }; +} + +/** + * Backstop applied to both channels: a release must be strictly newer than the + * newest published stable. Catches hand-edited pre.json, a back-merge resolved + * the wrong way, and a stale version typed at the override prompt. + */ +export function isVersionAheadOfStable(version, latestStable) { + if (!latestStable) return true; + return compareReleaseVersions(version, latestStable) > 0; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 550873a0bf..a84f4e74fd 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -48,6 +48,11 @@ import { parseChangesetFile } from "./lib/changeset-schema.mjs"; import { distillReleaseNotes } from "./lib/distill-release-notes.mjs"; import { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs"; import { selectChannelChangesets } from "./lib/channel-changeset-scope.mjs"; +import { + evaluateBetaCycleAnchor, + isVersionAheadOfStable, + latestStableVersionFromTags, +} from "./lib/release-version-anchor.mjs"; import { archivePointerLine, CHANGELOG_ARCHIVE_CUTOFF, @@ -376,6 +381,63 @@ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/* + * FNXC:UpdateChannels 2026-07-24-09:40: + * Requirement: after a stable release ships, the NEXT beta must be based on the + * stable version that just shipped — never a beta below it. v0.73.0 exposed the + * gap: the stable was cut on `release` while `main` stayed in the 0.72.0-based + * pre-mode cycle, so the next beta would have been v0.73.0-beta.7 (older than + * the published v0.73.0) and `pnpm dev` on main still reported the last beta. + * The helpers below let the beta path detect that stale cycle and rebase it + * onto the shipped stable, and let the stable path prove main picked the + * version up (see the automatic back-merge after promotion). + */ + +/** Highest published STABLE version from local `v*` git tags (prereleases excluded). */ +function latestStableTagVersion() { + const out = run("git tag --list 'v*'", { capture: true, allowFail: true }).stdout; + return latestStableVersionFromTags(out); +} + +/** Package names in the changesets "fixed" group — they all share one version. */ +function readFixedGroupPackageNames() { + try { + const config = JSON.parse(readFileSync(join(".changeset", "config.json"), "utf8")); + return (config.fixed || []).flat(); + } catch { + return []; + } +} + +/** + * Point every fixed-group package.json at `version` (plus the workspace root). + * Used to re-anchor a stale beta cycle on the shipped stable before + * `changeset pre enter`, which snapshots these versions into pre.json's + * `initialVersions` and derives every X.Y.Z-beta.N from them. + * + * Returns the list of rewritten paths so a dry-run can restore them. + */ +function rewriteFixedGroupVersions(version) { + const rewritten = []; + for (const name of readFixedGroupPackageNames()) { + const dir = findPackageDir(name); + if (!dir) continue; + const pkgPath = join(dir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.version === version) continue; + pkg.version = version; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + rewritten.push(pkgPath); + } + const rootPkg = JSON.parse(readFileSync("package.json", "utf8")); + if (rootPkg.version !== version) { + rootPkg.version = version; + writeFileSync("package.json", JSON.stringify(rootPkg, null, 2) + "\n"); + rewritten.push("package.json"); + } + return rewritten; +} + /** * Pack @runfusion/fusion and runfusion.ai, install them into a clean temp dir * with plain `npm` (mimicking the `npx runfusion.ai` install path), and invoke @@ -667,7 +729,36 @@ if (!IS_BETA && run("git rev-parse --abbrev-ref HEAD", { capture: true }).stdout } else { ok("Promotion worktree removed."); } - info("Reminder: back-merge 'release' into 'main' from this checkout (commands were printed above)."); + /* + * FNXC:UpdateChannels 2026-07-24-09:40: + * Requirement: once a stable ships, the local dev checkout must report the + * stable version, and the next beta must be based on it. A printed reminder + * was not enough — v0.73.0 shipped while `main` stayed at 0.73.0-beta.6 + * (dashboard/`pnpm dev` kept showing the beta, and the next beta would have + * numbered beneath the stable). The parent (still on `main`) now performs + * the back-merge itself. Fail-soft: a conflicting merge is aborted and the + * manual commands are printed, since the resolution needs a human. + */ + info("Back-merging the release branch into 'main' so the dev checkout carries the stable version…"); + const backMerge = run( + `git merge ${RELEASE_BRANCH} -m "chore(release): back-merge from ${RELEASE_BRANCH}"`, + { capture: true, allowFail: true }, + ); + if (backMerge.status !== 0) { + run("git merge --abort", { capture: true, allowFail: true }); + warn( + "Back-merge conflicted and was aborted — resolve it by hand:\n" + + ` git merge ${RELEASE_BRANCH}\n` + + " # keep the release branch's package.json versions and its deleted .changeset/*.md\n" + + " # (keep a changeset only if it was re-edited on main for an UNRELEASED fix)\n" + + " git commit && git push origin main", + ); + } else { + const mainVersion = JSON.parse(readFileSync("packages/cli/package.json", "utf8")).version; + ok(`'main' back-merged; local dev version is now v${mainVersion}.`); + const pushed = run("git push origin main", { capture: true, allowFail: true }); + if (pushed.status !== 0) warn("Could not push 'main'; push the back-merge manually: git push origin main"); + } } else { warn(`Stable release in the promotion worktree exited with status ${child.status ?? "unknown"}.`); warn(`Worktree kept for inspection: ${promoteDir}`); @@ -718,9 +809,39 @@ if (remoteBranchExists) { * Dry-runs revert whichever pre-mode mutation they made before exiting. */ let preModeMutation = "none"; // "entered" | "exited" | "none" +let rebasedVersionPaths = []; const preJsonExists = () => existsSync(PRE_JSON_PATH) && JSON.parse(readFileSync(PRE_JSON_PATH, "utf8")).mode === "pre"; +const LATEST_STABLE_VERSION = latestStableTagVersion(); if (IS_BETA) { - if (!preJsonExists()) { + /* + * FNXC:UpdateChannels 2026-07-24-09:40: + * A beta cycle is anchored at pre.json's `initialVersions` (snapshotted by + * `pre enter`). If a stable shipped since that snapshot, the anchor is stale + * and every further beta would number BELOW the published stable. Re-anchor: + * exit the stale cycle, set the fixed group to the shipped stable, re-enter. + * The pending changesets are untouched, so the bump type still decides + * whether the next beta is a patch or minor of that stable. + */ + const preState = preJsonExists() ? JSON.parse(readFileSync(PRE_JSON_PATH, "utf8")) : null; + const cycleBase = + preState?.initialVersions?.["@runfusion/fusion"] ?? + JSON.parse(readFileSync("packages/cli/package.json", "utf8")).version; + const { stale: staleCycle, anchor } = evaluateBetaCycleAnchor({ + cycleBase, + latestStable: LATEST_STABLE_VERSION, + }); + + if (staleCycle) { + warn( + `Beta cycle is anchored at ${cycleBase}, but stable v${LATEST_STABLE_VERSION} has shipped. ` + + `Re-anchoring the beta track on v${anchor}.`, + ); + if (preState) run("pnpm changeset pre exit"); + rebasedVersionPaths = rewriteFixedGroupVersions(anchor); + run("pnpm changeset pre enter beta"); + preModeMutation = "entered"; + ok(`Beta cycle re-anchored on v${anchor} (${rebasedVersionPaths.length} package.json rewritten).`); + } else if (!preState) { info("Entering changesets pre-mode (beta)…"); run("pnpm changeset pre enter beta"); preModeMutation = "entered"; @@ -743,6 +864,10 @@ function revertDryRunPreModeMutation() { } else if (preModeMutation === "exited") { run(`git checkout -- ${PRE_JSON_PATH}`); } + // A re-anchored cycle also rewrote tracked package.json versions; restore them. + for (const path of rebasedVersionPaths) { + run(`git checkout -- ${path}`, { allowFail: true }); + } } const changesetSummaries = readChangesetSummaries(); @@ -798,6 +923,23 @@ console.log(` Proposed version: ${color(32, proposedVersion)}`); console.log(` Bumped packages : ${releases.map((r) => r.name).join(", ")}`); console.log(""); +/* + * FNXC:UpdateChannels 2026-07-24-09:40: + * Backstop for the re-anchoring above: a release must never number at or below + * the newest published stable, in either channel. This catches hand-edited + * pre.json, a resolved-the-wrong-way back-merge, and an operator typing a stale + * version at the override prompt. + */ +if (!isVersionAheadOfStable(proposedVersion, LATEST_STABLE_VERSION)) { + fail( + `Proposed ${CHANNEL} version v${proposedVersion} is not newer than the published stable v${LATEST_STABLE_VERSION}.\n` + + (IS_BETA + ? ` 'main' is behind the stable release. Back-merge first:\n` + + ` git merge ${RELEASE_BRANCH} -m "chore(release): back-merge v${LATEST_STABLE_VERSION} from ${RELEASE_BRANCH}"` + : ` The '${RELEASE_BRANCH}' branch is behind the v${LATEST_STABLE_VERSION} tag.`), + ); +} + let chosenVersion = proposedVersion; if (shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: INTERACTIVE })) { while (true) { @@ -813,6 +955,9 @@ if (shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: IN } if (chosenVersion !== proposedVersion) { + if (!isVersionAheadOfStable(chosenVersion, LATEST_STABLE_VERSION)) { + fail(`Overridden version v${chosenVersion} is not newer than the published stable v${LATEST_STABLE_VERSION}.`); + } warn(`Overriding changeset-proposed version: ${proposedVersion} → ${chosenVersion}`); } @@ -1051,7 +1196,7 @@ if (githubReleaseStatus === "created") { */ if (!IS_BETA) { console.log(""); - info("Next step — back-merge the release branch into main:"); + info("Next step — back-merge the release branch into main (the promoting checkout does this automatically; run it by hand only if that merge conflicted or you released outside a promotion):"); console.log(` git checkout main && git pull origin main`); console.log(` git merge ${RELEASE_BRANCH} -m "chore(release): back-merge v${version} from ${RELEASE_BRANCH}"`); console.log(` git push origin main`);