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:
84
scripts/__tests__/release-version-anchor.test.mjs
Normal file
84
scripts/__tests__/release-version-anchor.test.mjs
Normal 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);
|
||||
});
|
||||
102
scripts/lib/release-version-anchor.mjs
Normal file
102
scripts/lib/release-version-anchor.mjs
Normal 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;
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user