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:
7
.changeset/beta-notes-scoped-per-beta.md
Normal file
7
.changeset/beta-notes-scoped-per-beta.md
Normal 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.
|
||||
71
scripts/__tests__/channel-changeset-scope.test.mjs
Normal file
71
scripts/__tests__/channel-changeset-scope.test.mjs
Normal 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" });
|
||||
});
|
||||
49
scripts/lib/channel-changeset-scope.mjs
Normal file
49
scripts/lib/channel-changeset-scope.mjs
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user