From ff165ecb5a4fcd3857087fb9f6eac631d3be97cf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 12:18:24 -0700 Subject: [PATCH] fix: scope beta release notes to that beta's changesets; stable keeps full-cycle rollup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/beta-notes-scoped-per-beta.md | 7 ++ .../channel-changeset-scope.test.mjs | 71 +++++++++++++++++++ scripts/lib/channel-changeset-scope.mjs | 49 +++++++++++++ scripts/release.mjs | 48 +++++++++++-- 4 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 .changeset/beta-notes-scoped-per-beta.md create mode 100644 scripts/__tests__/channel-changeset-scope.test.mjs create mode 100644 scripts/lib/channel-changeset-scope.mjs diff --git a/.changeset/beta-notes-scoped-per-beta.md b/.changeset/beta-notes-scoped-per-beta.md new file mode 100644 index 0000000000..75058f48e2 --- /dev/null +++ b/.changeset/beta-notes-scoped-per-beta.md @@ -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. diff --git a/scripts/__tests__/channel-changeset-scope.test.mjs b/scripts/__tests__/channel-changeset-scope.test.mjs new file mode 100644 index 0000000000..2db991b96b --- /dev/null +++ b/scripts/__tests__/channel-changeset-scope.test.mjs @@ -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" }); +}); diff --git a/scripts/lib/channel-changeset-scope.mjs b/scripts/lib/channel-changeset-scope.mjs new file mode 100644 index 0000000000..69b493284b --- /dev/null +++ b/scripts/lib/channel-changeset-scope.mjs @@ -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 }; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 6cbcb6216e..550873a0bf 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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);