fix(release): base the next beta on the shipped stable version

After a stable release, main stayed inside the old pre-mode cycle, so the next
beta numbered below the published stable (v0.73.0-beta.7 after v0.73.0) and the
dev checkout kept reporting the last beta.

- beta releases re-anchor a stale pre-mode cycle on the newest stable tag
- both channels refuse a version at or below the newest published stable
- stable promotion now back-merges release into main automatically (fail-soft
  on conflict) so the local dev version is the stable version
This commit is contained in:
gsxdsm
2026-07-24 22:59:19 -07:00
parent 1847d2bc2a
commit dba9746287
3 changed files with 334 additions and 3 deletions

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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`);