fix: scope beta release notes to that beta's changesets; stable keeps full-cycle rollup

Pre-mode preserves consumed changeset .md files, so every beta's distilled notes and GitHub prerelease body aggregated the entire cycle since the last stable (v0.73.0-beta.4 shipped the full 0.72.0→0.73.0 aggregate). Betas now distill only changesets not yet recorded in pre.json's consumed ledger, and fail loudly when a beta would ship nothing new. Stable promotion still feeds the full preserved set, keeping its notes an explicit rollup of every beta in the cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 12:18:24 -07:00
parent c4292b25ef
commit ff165ecb5a
4 changed files with 171 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Beta release notes now list only that beta's changes; stable notes roll up the whole beta cycle.
category: fix
dev: `scripts/release.mjs` scopes distillation input via `selectChannelChangesets` against pre.json's consumed-changesets ledger; stable keeps the full preserved set.

View File

@@ -0,0 +1,71 @@
/*
FNXC:Changelog 2026-07-23-10:40:
Guards the channel-scoped release-notes contract:
- a beta's notes cover ONLY changesets new since the previous beta (pre.json's `changesets` array is the already-released ledger);
- a stable release's notes roll up EVERY changeset preserved across the beta cycle.
Regression context: v0.73.0-beta.4's GitHub prerelease shipped the entire 0.72.0→0.73.0 aggregate because pre-mode preserves consumed .md files and the release script fed all of them to distillation on every beta.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
changesetNameFromFile,
selectChannelChangesets,
} from "../lib/channel-changeset-scope.mjs";
const summaries = [
{ file: "fix-alpha.md", bump: "patch", summary: "Fix alpha" },
{ file: "feat-bravo.md", bump: "minor", summary: "Add bravo" },
{ file: "fix-charlie.md", bump: "patch", summary: "Fix charlie" },
];
test("changesetNameFromFile strips only the .md extension", () => {
assert.equal(changesetNameFromFile("fix-alpha.md"), "fix-alpha");
assert.equal(changesetNameFromFile("fn-8123.md.md"), "fn-8123.md");
assert.equal(changesetNameFromFile("no-extension"), "no-extension");
});
test("beta excludes changesets already released in prior betas", () => {
const { selected, alreadyReleased } = selectChannelChangesets(
"beta",
summaries,
["fix-alpha", "fix-charlie"],
);
assert.deepEqual(selected.map((s) => s.file), ["feat-bravo.md"]);
assert.deepEqual(
alreadyReleased.map((s) => s.file),
["fix-alpha.md", "fix-charlie.md"],
);
});
test("first beta of a cycle (empty pre ledger) selects everything", () => {
const { selected, alreadyReleased } = selectChannelChangesets("beta", summaries, []);
assert.deepEqual(selected, summaries);
assert.deepEqual(alreadyReleased, []);
});
test("beta with nothing new selects an empty set (release script must fail)", () => {
const { selected, alreadyReleased } = selectChannelChangesets(
"beta",
summaries,
["fix-alpha", "feat-bravo", "fix-charlie"],
);
assert.deepEqual(selected, []);
assert.equal(alreadyReleased.length, 3);
});
test("stable always rolls up the full cycle regardless of the pre ledger", () => {
const { selected, alreadyReleased } = selectChannelChangesets(
"stable",
summaries,
["fix-alpha", "feat-bravo", "fix-charlie"],
);
assert.deepEqual(selected, summaries);
assert.deepEqual(alreadyReleased, []);
});
test("selection preserves entry objects untouched (bump/summary pass through)", () => {
const { selected } = selectChannelChangesets("beta", summaries, ["fix-alpha"]);
assert.deepEqual(selected[0], { file: "feat-bravo.md", bump: "minor", summary: "Add bravo" });
});

View File

@@ -0,0 +1,49 @@
/*
FNXC:Changelog 2026-07-23-10:40:
Beta releases must show notes for ONLY the changesets new in that beta, not the whole pre cycle.
Changesets pre-mode preserves every consumed .md file on disk (recording consumed names in pre.json's `changesets` array), so a naive read of `.changeset/*.md` re-aggregates the entire cycle into every beta's distilled notes and GitHub prerelease body — v0.73.0-beta.4 shipped the full 0.72.0→0.73.0 aggregate instead of its own handful of fixes.
This module scopes the note-feeding changeset set per channel:
- beta: exclude names already recorded in pre.json (already released in a prior beta of this cycle);
- stable: keep the FULL set — the stable release's notes are intentionally a rollup of every change across all betas in the cycle.
Pure and side-effect free so it is unit-testable outside the release script.
*/
/**
* Strip the `.md` extension from a changeset filename to get the changeset
* name as recorded in pre.json's `changesets` array.
*/
export function changesetNameFromFile(file) {
return file.replace(/\.md$/, "");
}
/**
* Select which pending changesets feed release notes for the given channel.
*
* @param {"beta"|"stable"} channel
* @param {Array<{file: string}>} summaries - entries from readChangesetSummaries();
* only `.file` is inspected, extra fields pass through untouched.
* @param {string[]} preReleasedNames - pre.json `changesets` array (names of
* changesets consumed by prior betas in this pre cycle). Pass [] when there
* is no pre.json (first beta of a cycle) or on the stable channel.
* @returns {{ selected: Array, alreadyReleased: Array }}
*/
export function selectChannelChangesets(channel, summaries, preReleasedNames) {
if (channel !== "beta") {
// FNXC:Changelog 2026-07-23-10:40:
// Stable = full-cycle rollup. Every preserved changeset from every beta
// (plus any landed after the last beta) is included so the stable
// changelog and GitHub Release aggregate the whole cycle.
return { selected: summaries, alreadyReleased: [] };
}
const released = new Set(preReleasedNames);
const selected = [];
const alreadyReleased = [];
for (const summary of summaries) {
if (released.has(changesetNameFromFile(summary.file))) {
alreadyReleased.push(summary);
} else {
selected.push(summary);
}
}
return { selected, alreadyReleased };
}

View File

@@ -47,6 +47,7 @@ import { extractVersionNotes, replaceVersionSection } from "./lib/extract-versio
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 {
archivePointerLine,
CHANGELOG_ARCHIVE_CUTOFF,
@@ -748,8 +749,42 @@ const changesetSummaries = readChangesetSummaries();
if (changesetSummaries.length === 0) {
fail("No pending changesets in .changeset/. Run `pnpm changeset` first.");
}
ok(`${changesetSummaries.length} pending changeset(s):`);
for (const cs of changesetSummaries) {
/*
* FNXC:Changelog 2026-07-23-10:40:
* Scope release notes to the channel. Pre-mode preserves every consumed
* changeset .md on disk, so on beta.N the directory holds the WHOLE cycle —
* feeding all of it to distillation made every beta's notes an aggregate of
* everything since the last stable (v0.73.0-beta.4 shipped the full cycle
* instead of its own fixes). Betas distill only changesets NOT yet recorded
* in pre.json's `changesets` array (i.e. new since the previous beta).
* Stable keeps the full set on purpose: its notes are the rollup of every
* change across all betas in the cycle.
*/
const preReleasedNames = IS_BETA && preJsonExists()
? (JSON.parse(readFileSync(PRE_JSON_PATH, "utf8")).changesets ?? [])
: [];
const { selected: noteChangesets, alreadyReleased } = selectChannelChangesets(
CHANNEL,
changesetSummaries,
preReleasedNames,
);
if (IS_BETA && noteChangesets.length === 0) {
fail(
`All ${changesetSummaries.length} pending changeset(s) were already released in a prior beta of this cycle. ` +
"Nothing new to release — land a changeset first, or run `pnpm release --channel stable` to promote.",
);
}
if (IS_BETA && alreadyReleased.length > 0) {
info(`${alreadyReleased.length} changeset(s) already released in earlier betas of this cycle (kept for the stable rollup; excluded from this beta's notes).`);
}
// pre.json survives `pre exit` (mode flips to "exit"); its presence on the
// stable channel means this release promotes a beta cycle.
if (!IS_BETA && existsSync(PRE_JSON_PATH)) {
info(`Stable notes will roll up all ${changesetSummaries.length} changeset(s) accumulated across the beta cycle.`);
}
ok(`${noteChangesets.length} changeset(s) new in this ${CHANNEL} release:`);
for (const cs of noteChangesets) {
console.log(` ${color(33, `[${cs.bump}]`)} ${cs.summary} ${color(90, `(${cs.file})`)}`);
}
@@ -790,7 +825,7 @@ if (DRY_RUN) {
* deterministic if no model is reachable) so operators can review the post
* without authorizing a real publish.
*/
const dryEntries = changesetSummaries.map(({ file }) => {
const dryEntries = noteChangesets.map(({ file }) => {
const raw = readFileSync(join(".changeset", file), "utf8");
return parseChangesetFile(raw).parsed;
}).filter(Boolean);
@@ -844,8 +879,13 @@ if (!(await confirm(`Proceed with ${CHANNEL} release v${chosenVersion} (build, p
* Capture and parse structured changeset entries BEFORE `changeset version`
* runs — versioning consumes and deletes the .changeset/*.md files.
* The captured entries feed the post-version distillation step.
*
* FNXC:Changelog 2026-07-23-10:40:
* `noteChangesets` is channel-scoped (see selection above): a beta captures
* only changesets new since the previous beta; a stable capture is the full
* cross-beta rollup.
*/
const capturedEntries = changesetSummaries.map(({ file }) => {
const capturedEntries = noteChangesets.map(({ file }) => {
const raw = readFileSync(join(".changeset", file), "utf8");
return parseChangesetFile(raw).parsed;
}).filter(Boolean);