Skip to content

Commit 2f44043

Browse files
committed
fix(release): the stable cut folds each prerelease section into the stable section instead of copying it
Leaving pre mode re-applies every changeset the cycle's prereleases consumed, so the stable `## X.Y.Z` section repeated each `## X.Y.Z-next.N` section below it. The changelog generator now arms a fold on an exit-mode cut: when `changeset version` exits 0, each CHANGELOG whose top was a prerelease before the cut drops the cycle's prerelease sections whose every entry the new stable section reproduces. Stable sections and older cycles stay byte for byte. Claude-Session: https://claude.ai/code/session_01PbYqgMPfuTjR2CMEqrjHVL Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95294be commit 2f44043

2 files changed

Lines changed: 253 additions & 4 deletions

File tree

‎scripts/changeset-changelog.mjs‎

Lines changed: 184 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,200 @@
1616
// The pins live in `node scripts/release-pending-publish.mjs --self-test`,
1717
// which lint.yml runs: an add -> version -> revert -> re-cut fixture through
1818
// these two functions.
19+
//
20+
// ## The stable cut folds its prerelease sections (#26079, ruling A′)
21+
//
22+
// Leaving pre mode (`changeset pre exit`) and versioning applies every
23+
// changeset the cycle's prereleases consumed a second time, so the stable
24+
// `## X.Y.Z` section repeats each `## X.Y.Z-next.N` section below it: 18.0.0's
25+
// cut left spec with 4,076 duplicated long lines. On that cut the prerelease
26+
// sections are FOLDED into the stable section instead of copied: the stable
27+
// section is written whole, as the stock writer always writes it, and each
28+
// prerelease section of the cycle is dropped once every entry it carries is
29+
// found in the new stable section.
30+
//
31+
// What may fold is fixed by the file as it stood before the cut, never by a
32+
// version guess: only a CHANGELOG whose top section was a prerelease when
33+
// `changeset version` started and that now opens with a new stable section,
34+
// and in it only the prerelease sections between that stable section and the
35+
// next stable one below. Every stable section, and every older cycle's
36+
// prerelease sections, is kept byte for byte -- existing text is not rewritten
37+
// (#25919 A 「存量不横扫」). A prerelease section holding an entry the stable
38+
// section does not reproduce (amended in place by a docs PR, say) is kept,
39+
// with a `::warning::` naming it. An entry is compared without its commit
40+
// prefix, and dependency-bump entries (`Updated dependencies` and the
41+
// `pkg@X.Y.Z-next.N` lines under them) are superseded by the stable section's
42+
// own, so neither holds a section back.
43+
//
44+
// The generator API hands out one release line at a time and nothing of it
45+
// runs after the files are written, so the fold is armed on the first line of
46+
// an exit-mode cut (`.changeset/pre.json` with `"mode": "exit"`, which the
47+
// version pass itself deletes) and runs when that process exits 0. The ruling
48+
// keeps `CHANGELOG.md` in every package's `files`: the tarball copy is still
49+
// the text an upgrading agent greps (AGENTS.md, Documentation Guardrails).
50+
// Its pins sit beside the attribution's, in the same CI-run self-test.
1951

52+
import { closeSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
53+
import { execFileSync } from 'node:child_process';
54+
import { join } from 'node:path';
2055
import stock from '@changesets/cli/changelog';
2156
import { createChangelogAttribution } from './release-pending-publish.mjs';
2257

2358
/** The two functions `changeset version` calls, attributing from the repository at `cwd`. */
2459
export function changelogFunctions(options = {}) {
2560
const attribute = createChangelogAttribution({ cwd: process.cwd(), ...options });
2661
const attributed = (changeset) => ({ ...changeset, commit: attribute(changeset) });
62+
let armed = false;
63+
const arm = () => {
64+
if (armed) return;
65+
armed = true;
66+
armPrereleaseFold({ cwd: options.cwd ?? process.cwd(), log: options.log });
67+
};
2768
return {
28-
getReleaseLine: (changeset, type, opts) => stock.getReleaseLine(attributed(changeset), type, opts),
29-
getDependencyReleaseLine: (changesets, dependenciesUpdated, opts) =>
30-
stock.getDependencyReleaseLine(changesets.map(attributed), dependenciesUpdated, opts),
69+
getReleaseLine: (changeset, type, opts) => {
70+
arm();
71+
return stock.getReleaseLine(attributed(changeset), type, opts);
72+
},
73+
getDependencyReleaseLine: (changesets, dependenciesUpdated, opts) => {
74+
arm();
75+
return stock.getDependencyReleaseLine(changesets.map(attributed), dependenciesUpdated, opts);
76+
},
3177
};
3278
}
3379

3480
export default changelogFunctions();
81+
82+
// ─────────────────────────────────────────────────────────────────────────────
83+
// The fold
84+
// ─────────────────────────────────────────────────────────────────────────────
85+
86+
const HEADING = /^## (\S+)[ \t]*$/gm;
87+
const STABLE = /^\d+\.\d+\.\d+$/;
88+
const PRERELEASE = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/;
89+
const COMMIT_PREFIX = /^[0-9a-f]{7,40}: /;
90+
91+
/** Every `## VERSION` section of a CHANGELOG: its version and [start, end) offsets. */
92+
export function versionSections(text) {
93+
const heads = [...text.matchAll(HEADING)].filter((m) => STABLE.test(m[1]) || PRERELEASE.test(m[1]));
94+
return heads.map((m, i) => ({ version: m[1], start: m.index, end: i + 1 < heads.length ? heads[i + 1].index : text.length }));
95+
}
96+
97+
/** A section's changeset entries, each without its commit prefix; dependency bumps are left out. */
98+
function changesetEntries(body) {
99+
const entries = [];
100+
let current = null;
101+
for (const line of body.split('\n')) {
102+
if (line.startsWith('- ')) {
103+
current = [line.slice(2).replace(COMMIT_PREFIX, '')];
104+
entries.push(current);
105+
} else if (current && (line.startsWith(' ') || line === '')) {
106+
current.push(line);
107+
} else {
108+
current = null;
109+
}
110+
}
111+
return entries
112+
.map((lines) => lines.join('\n').trimEnd())
113+
.filter((entry) => !entry.startsWith('Updated dependencies'));
114+
}
115+
116+
/**
117+
* Fold one CHANGELOG after a stable cut. `previousTop` is the version its top
118+
* section carried before the cut; nothing folds unless that was a prerelease
119+
* and the text now opens with a stable section directly above it.
120+
*/
121+
export function foldPrereleaseSections(text, previousTop) {
122+
const unchanged = (reason) => ({ text, folded: [], kept: [], reason });
123+
if (!PRERELEASE.test(previousTop ?? '')) return unchanged('the top section before the cut was not a prerelease');
124+
const sections = versionSections(text);
125+
if (sections.length < 2 || !STABLE.test(sections[0].version)) return unchanged('the file does not open with a stable section');
126+
if (sections[1].version !== previousTop) return unchanged(`the section under ${sections[0].version} is not ${previousTop}, the top before the cut`);
127+
const stable = new Set(changesetEntries(text.slice(sections[0].start, sections[0].end)));
128+
const folded = [];
129+
const kept = [];
130+
let out = text.slice(0, sections[1].start);
131+
let i = 1;
132+
for (; i < sections.length && PRERELEASE.test(sections[i].version); i += 1) {
133+
const s = sections[i];
134+
const missing = changesetEntries(text.slice(s.start, s.end)).filter((entry) => !stable.has(entry));
135+
if (missing.length === 0) {
136+
folded.push(s.version);
137+
} else {
138+
kept.push({ version: s.version, missing });
139+
out += text.slice(s.start, s.end);
140+
}
141+
}
142+
out += text.slice(i < sections.length ? sections[i].start : text.length);
143+
return { text: out, folded, kept, reason: null };
144+
}
145+
146+
/** The first `## VERSION` of a file, read from its head only (spec's CHANGELOG is 10 MB). */
147+
function topVersion(path) {
148+
const fd = openSync(path, 'r');
149+
try {
150+
const buf = Buffer.alloc(64 * 1024);
151+
const n = readSync(fd, buf, 0, buf.length, 0);
152+
return versionSections(buf.subarray(0, n).toString('utf8'))[0]?.version ?? null;
153+
} finally {
154+
closeSync(fd);
155+
}
156+
}
157+
158+
/**
159+
* On an exit-mode cut, snapshot the tracked CHANGELOGs whose top section is a
160+
* prerelease, and hand back the function that folds them once written. Null
161+
* when this is not a stable cut out of pre mode.
162+
*/
163+
export function prepareFold({ cwd, log = console.log }) {
164+
let root;
165+
let mode;
166+
try {
167+
root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
168+
mode = JSON.parse(readFileSync(join(root, '.changeset', 'pre.json'), 'utf8')).mode;
169+
} catch {
170+
return null;
171+
}
172+
if (mode !== 'exit') return null;
173+
const warn = (text) => log(`::warning::changeset-changelog: ${text}`);
174+
const tops = new Map();
175+
const listed = execFileSync('git', ['ls-files', '-z', '--', '*CHANGELOG.md'], { cwd: root, encoding: 'utf8' });
176+
for (const rel of listed.split('\0').filter(Boolean)) {
177+
try {
178+
const top = topVersion(join(root, rel));
179+
if (top && PRERELEASE.test(top)) tops.set(rel, top);
180+
} catch (err) {
181+
warn(`${rel} could not be read before the cut (${err instanceof Error ? err.message : err}); its prerelease sections stay.`);
182+
}
183+
}
184+
const apply = () => {
185+
let files = 0;
186+
let sections = 0;
187+
for (const [rel, previousTop] of tops) {
188+
try {
189+
const path = join(root, rel);
190+
const r = foldPrereleaseSections(readFileSync(path, 'utf8'), previousTop);
191+
for (const k of r.kept) {
192+
warn(`${rel}: ${k.version} holds ${k.missing.length} entr${k.missing.length === 1 ? 'y' : 'ies'} the stable section does not reproduce, so it stays.`);
193+
}
194+
if (r.folded.length === 0) continue;
195+
writeFileSync(path, r.text);
196+
files += 1;
197+
sections += r.folded.length;
198+
} catch (err) {
199+
warn(`${rel} could not be folded (${err instanceof Error ? err.message : err}); it stays as the version pass wrote it.`);
200+
}
201+
}
202+
log(`changeset-changelog: the stable cut folded ${sections} prerelease section(s) in ${files} CHANGELOG(s) into their stable section.`);
203+
};
204+
return { root, tops, apply };
205+
}
206+
207+
function armPrereleaseFold({ cwd, log }) {
208+
const fold = prepareFold({ cwd, log });
209+
if (fold === null || fold.tops.size === 0) return;
210+
// `changeset version` writes the files after the last line is generated, so
211+
// the fold runs when the version pass ends, and only when it succeeded.
212+
process.once('exit', (code) => {
213+
if (code === 0) fold.apply();
214+
});
215+
}

‎scripts/release-pending-publish.mjs‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1489,8 +1489,11 @@ const SELF_TEST_BATTERIES = Object.freeze({
14891489
'annotate-shipped -> no version commit, an unpublished or unreadable version, an error anywhere: a warning or a plain line, nothing written, exit 0': 5,
14901490
'changelog attribution -> a revert of a version commit restores its changesets: each entry names the commit that first added it, through the generator changeset version loads': 5,
14911491
'changelog attribution -> a shallow clone deepens itself to the first add; with no history to fetch, or nothing to decide, the stock commit stays and the version pass goes on': 4,
1492+
'changelog fold -> on the stable cut each prerelease section of the cycle folds into the stable section: an entry is its summary, not its commit, and a dependency bump is superseded': 4,
1493+
'changelog fold -> the stable section is kept whole, and so is every older cycle; a prerelease section carrying an entry the stable section lacks stays': 5,
1494+
'changelog fold -> armed only on an exit-mode cut, for the CHANGELOGs whose top was a prerelease before the version pass wrote': 3,
14921495
});
1493-
const SELF_TEST_BATTERY_FLOOR = 31;
1496+
const SELF_TEST_BATTERY_FLOOR = 34;
14941497

14951498
async function selfTest() {
14961499
let failed = 0;
@@ -2023,6 +2026,71 @@ async function selfTest() {
20232026
'no stock commit, a changeset this history never added, a changeset nothing restored: the stock answer, unchanged',
20242027
);
20252028

2029+
// The stable cut out of pre mode (#26079, ruling A′): the generator folds
2030+
// the cycle's prerelease sections into the stable section the version pass
2031+
// writes, instead of leaving them as a second copy below it.
2032+
const { foldPrereleaseSections, prepareFold } = await import('./changeset-changelog.mjs');
2033+
const OLD_CYCLE = [
2034+
'## 1.1.0', '', '### Minor Changes', '', '- 1111111: old feature', '',
2035+
'## 1.1.0-next.0', '', '### Minor Changes', '', '- 1111111: old feature', '',
2036+
].join('\n');
2037+
const STABLE_SECTION = [
2038+
'## 1.2.0', '', '### Minor Changes', '',
2039+
'- aaaaaaa: feature A', ' with a second line', '- bbbbbbb: feature B', '- ccccccc: feature C', '',
2040+
'### Patch Changes', '', '- Updated dependencies [aaaaaaa, bbbbbbb, ccccccc]', ' - @demo/core@1.2.0', '',
2041+
].join('\n');
2042+
const NEXT_1 = [
2043+
'## 1.2.0-next.1', '', '### Minor Changes', '', '- 9999999: feature B', '',
2044+
'### Patch Changes', '', '- Updated dependencies [bbbbbbb]', ' - @demo/core@1.2.0-next.1', '',
2045+
].join('\n');
2046+
const NEXT_0 = ['## 1.2.0-next.0', '', '### Minor Changes', '', '- aaaaaaa: feature A', ' with a second line', ''].join('\n');
2047+
const changelog = (...sections) => `# @demo/pkg\n\n${sections.join('\n')}`;
2048+
const stableCut = changelog(STABLE_SECTION, NEXT_1, NEXT_0, OLD_CYCLE);
2049+
const folded = changelog(STABLE_SECTION, OLD_CYCLE);
2050+
2051+
battery('changelog fold -> on the stable cut each prerelease section of the cycle folds into the stable section: an entry is its summary, not its commit, and a dependency bump is superseded');
2052+
const f = foldPrereleaseSections(stableCut, '1.2.0-next.1');
2053+
check(f.folded.join() === '1.2.0-next.1,1.2.0-next.0' && f.kept.length === 0, `both prerelease sections of the cycle fold (folded: ${f.folded.join()})`);
2054+
check(f.text === folded, 'the folded file is the stable section, then the older cycle, and nothing else');
2055+
check(!f.text.includes('9999999'), 'an entry naming another commit than its stable line still folds: the summary is the entry');
2056+
check(!f.text.includes('@demo/core@1.2.0-next.1'), "a prerelease's dependency bump does not hold its section back");
2057+
2058+
battery('changelog fold -> the stable section is kept whole, and so is every older cycle; a prerelease section carrying an entry the stable section lacks stays');
2059+
check(f.text.includes(STABLE_SECTION), 'the new stable section is byte-identical to what the version pass wrote');
2060+
check(f.text.endsWith(OLD_CYCLE), "an older cycle's stable section and its prerelease sections are byte-identical (存量不横扫)");
2061+
check(foldPrereleaseSections(folded, '1.1.0').text === folded, 'a cut whose top was a stable section changes nothing');
2062+
const noStable = changelog(NEXT_1, NEXT_0);
2063+
check(foldPrereleaseSections(noStable, '1.2.0-next.0').text === noStable, 'a file that does not open with a stable section changes nothing');
2064+
const amended = NEXT_0.replace('feature A', 'feature A, amended');
2065+
const fa = foldPrereleaseSections(changelog(STABLE_SECTION, NEXT_1, amended, OLD_CYCLE), '1.2.0-next.1');
2066+
check(
2067+
fa.folded.join() === '1.2.0-next.1' && fa.kept.length === 1 && fa.text === changelog(STABLE_SECTION, amended, OLD_CYCLE),
2068+
'a prerelease section carrying an entry the stable section does not reproduce is kept whole; the rest fold',
2069+
);
2070+
2071+
battery('changelog fold -> armed only on an exit-mode cut, for the CHANGELOGs whose top was a prerelease before the version pass wrote');
2072+
const fd = mkdtempSync(join(tmpdir(), 'release-pending-publish-fold-'));
2073+
dirs.push(fd);
2074+
gitOk(fd, ['init', '-q']);
2075+
for (const d of ['.changeset', 'a', 'b']) mkdirSync(join(fd, d));
2076+
writeFileSync(join(fd, 'a', 'CHANGELOG.md'), changelog(NEXT_1, NEXT_0, OLD_CYCLE));
2077+
writeFileSync(join(fd, 'b', 'CHANGELOG.md'), changelog(OLD_CYCLE));
2078+
gitOk(fd, ['add', '.']);
2079+
check(prepareFold({ cwd: fd, log: () => {} }) === null, 'no .changeset/pre.json -> not armed');
2080+
writeFileSync(join(fd, '.changeset', 'pre.json'), '{"mode":"exit","tag":"next"}');
2081+
const foldLog = [];
2082+
const armed = prepareFold({ cwd: fd, log: (line) => foldLog.push(line) });
2083+
check(armed !== null && [...armed.tops.keys()].join() === 'a/CHANGELOG.md', 'an exit-mode cut -> armed for a/ only: b/ already opens with a stable section');
2084+
// What `changeset version` writes: the new stable section above the old top.
2085+
writeFileSync(join(fd, 'a', 'CHANGELOG.md'), stableCut);
2086+
writeFileSync(join(fd, 'b', 'CHANGELOG.md'), folded);
2087+
armed?.apply();
2088+
check(
2089+
readFileSync(join(fd, 'a', 'CHANGELOG.md'), 'utf8') === folded && readFileSync(join(fd, 'b', 'CHANGELOG.md'), 'utf8') === folded &&
2090+
foldLog.length === 1 && /folded 2 prerelease section\(s\) in 1 CHANGELOG/.test(foldLog[0]),
2091+
'after the write, a/ is folded, b/ is untouched, and one plain line says so',
2092+
);
2093+
20262094
battery('annotate-shipped -> a depth-1 clone deepens itself to the version commit; with no reachable history it warns, annotates nothing and exits 0');
20272095
q.g('checkout', '-q', '--', '.changeset');
20282096
q.g('config', 'uploadpack.allowReachableSHA1InWant', 'true');

0 commit comments

Comments
 (0)