Skip to content

Commit 388663d

Browse files
authored
feat: consolidate per-package changelogs into root CHANGELOG (#316)
* feat: consolidate per-package changelogs into root CHANGELOG on version Adds a script run as part of `ci:version` that aggregates the latest release entries from each package's CHANGELOG into a single root CHANGELOG, deduplicating by PR/commit and dropping `Updated dependencies` noise. * fix(scripts): sort package CHANGELOG paths for deterministic output fs.readdirSync makes no ordering guarantee, which left the consolidated CHANGELOG's targetVersion selection, dedup tiebreakers, and within-section entry order dependent on filesystem iteration order. Sort the discovered paths so the output is byte-stable across OS/filesystems. * fix(scripts): preserve distinct entries that share a PR number The per-package dedup pass keyed entries by PR number, which collapsed legitimate distinct changeset entries when a single PR produced multiple changesets in one package (e.g. PR #275 in packages/brownfield). Drop the per-package dedup entirely; the cross-package dedup in consolidate() still handles the one-PR-shows-up-in-many-packages case correctly. * fix(scripts): skip writing empty version blocks When every package's release entries are filtered out as "Updated dependencies", the consolidated map ends up empty but the script would still emit a bare "## <version>" block with no content. Treat that case as a no-op and log it. * fix(scripts): robust regex for idempotency check The previous literal-substring check missed legitimate matches when the existing root CHANGELOG starts directly with the version heading, uses CRLF newlines, or has a date/suffix after the version. Replace with a multiline regex that matches "##" at any line start, allows any horizontal whitespace, and ends at a whitespace or end-of-line boundary. * fix(scripts): detect version heading at file start indexOf('\n## ') required a preceding newline, so a root CHANGELOG that begins directly with a "## <version>" heading (e.g. after a human strips the "# Changelog" header) was treated as having no version block, and new content got appended to the end instead of spliced at the top. Switch to a multiline regex that matches "## " at any line start, including line 0. * fix(scripts): preserve "Updated dependencies" entries Per discussion on PR #316, consumers tracking transitive dependency bumps (e.g. for security advisories) need this information in the consolidated root CHANGELOG. Drop the filter so every package's "Updated dependencies" entries flow through to the rollup.
1 parent d2906f8 commit 388663d

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"build": "turbo run build",
1414
"test:apps": "turbo run test --filter='./apps/*'",
1515
"dev": "yarn workspaces foreach -Api run dev",
16-
"ci:version": "changeset version && yarn install --no-immutable",
16+
"ci:version": "changeset version && yarn install --no-immutable && node --experimental-strip-types --no-warnings ./scripts/consolidate-changelog.ts",
1717
"ci:publish": "yarn workspaces foreach --no-private -At npm publish && changeset tag",
1818
"brownfield:plugin:publish:local": "bash ./gradle-plugins/publish-to-maven-local.sh --skip-signing",
1919
"brownfield:plugin:publish:local:signed": "bash ./gradle-plugins/publish-to-maven-local.sh",

scripts/consolidate-changelog.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
const ROOT_DIR = process.cwd();
5+
const PACKAGES_DIR = path.join(ROOT_DIR, 'packages');
6+
const ROOT_CHANGELOG = path.join(ROOT_DIR, 'CHANGELOG.md');
7+
8+
const SECTION_ORDER = ['Major Changes', 'Minor Changes', 'Patch Changes'];
9+
10+
interface ParsedVersion {
11+
version: string;
12+
sections: Map<string, string[]>;
13+
}
14+
15+
function extractEntryKey(entry: string): string {
16+
const prMatch = entry.match(/\[#(\d+)\]/);
17+
if (prMatch) return `pr-${prMatch[1]}`;
18+
19+
const hashMatch = entry.match(/\[`([a-f0-9]{7,40})`\]/);
20+
if (hashMatch) return `commit-${hashMatch[1]}`;
21+
22+
return entry.trim();
23+
}
24+
25+
function parseEntries(block: string): string[] {
26+
const entries: string[] = [];
27+
let current: string[] = [];
28+
29+
for (const line of block.split('\n')) {
30+
if (line.startsWith('- ')) {
31+
if (current.length > 0) entries.push(current.join('\n').trim());
32+
current = [line];
33+
} else if (line.startsWith(' ') && current.length > 0) {
34+
current.push(line);
35+
}
36+
// blank lines and non-indented non-bullet lines within a block are ignored
37+
}
38+
39+
if (current.length > 0) entries.push(current.join('\n').trim());
40+
41+
return entries.filter((e) => e.length > 0);
42+
}
43+
44+
function parseLatestVersion(content: string): ParsedVersion | null {
45+
const lines = content.split('\n');
46+
47+
let vStart = -1;
48+
let version = '';
49+
for (let i = 0; i < lines.length; i++) {
50+
const m = lines[i].match(/^## (\d+\.\d+\.\d+)/);
51+
if (m) {
52+
vStart = i;
53+
version = m[1];
54+
break;
55+
}
56+
}
57+
if (vStart === -1) return null;
58+
59+
let vEnd = lines.length;
60+
for (let i = vStart + 1; i < lines.length; i++) {
61+
if (lines[i].match(/^## /)) {
62+
vEnd = i;
63+
break;
64+
}
65+
}
66+
67+
const sectionContent = lines.slice(vStart + 1, vEnd).join('\n');
68+
const subsectionHeaders = [...sectionContent.matchAll(/^### (.+)$/gm)];
69+
const subsectionBodies = sectionContent.split(/^### .+$/m);
70+
71+
const sections = new Map<string, string[]>();
72+
73+
for (let i = 0; i < subsectionHeaders.length; i++) {
74+
const name = subsectionHeaders[i][1].trim();
75+
const body = subsectionBodies[i + 1] ?? '';
76+
const entries = parseEntries(body);
77+
78+
if (entries.length > 0) {
79+
sections.set(name, entries);
80+
}
81+
}
82+
83+
return { version, sections };
84+
}
85+
86+
function consolidate(): void {
87+
const changelogPaths = fs
88+
.readdirSync(PACKAGES_DIR, { withFileTypes: true })
89+
.filter((d) => d.isDirectory())
90+
.map((d) => path.join(PACKAGES_DIR, d.name, 'CHANGELOG.md'))
91+
.filter((p) => fs.existsSync(p))
92+
.sort();
93+
94+
if (changelogPaths.length === 0) {
95+
console.error('No package CHANGELOG files found.');
96+
process.exit(1);
97+
}
98+
99+
let targetVersion: string | null = null;
100+
const consolidated = new Map<string, Map<string, string>>();
101+
102+
for (const changelogPath of changelogPaths) {
103+
const parsed = parseLatestVersion(fs.readFileSync(changelogPath, 'utf-8'));
104+
if (!parsed) continue;
105+
106+
if (!targetVersion) {
107+
targetVersion = parsed.version;
108+
} else if (parsed.version !== targetVersion) {
109+
console.warn(
110+
`Version mismatch: expected ${targetVersion}, got ${parsed.version} in ${changelogPath}`
111+
);
112+
continue;
113+
}
114+
115+
for (const [section, entries] of parsed.sections) {
116+
if (!consolidated.has(section)) consolidated.set(section, new Map());
117+
const target = consolidated.get(section)!;
118+
for (const entry of entries) {
119+
const key = extractEntryKey(entry);
120+
if (!target.has(key)) target.set(key, entry);
121+
}
122+
}
123+
}
124+
125+
if (!targetVersion) {
126+
console.error('Could not determine release version from package CHANGELOGs.');
127+
process.exit(1);
128+
}
129+
130+
if (consolidated.size === 0) {
131+
console.log(
132+
`No substantive entries for ${targetVersion} (all filtered as "Updated dependencies"), skipping root CHANGELOG update.`
133+
);
134+
return;
135+
}
136+
137+
// Idempotency: skip if this version is already in the root CHANGELOG
138+
if (fs.existsSync(ROOT_CHANGELOG)) {
139+
const existing = fs.readFileSync(ROOT_CHANGELOG, 'utf-8');
140+
const escapedVersion = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
141+
const versionHeadingRe = new RegExp(`^##\\s+${escapedVersion}(\\s|$)`, 'm');
142+
if (versionHeadingRe.test(existing)) {
143+
console.log(`Root CHANGELOG already contains ${targetVersion}, skipping.`);
144+
return;
145+
}
146+
}
147+
148+
// Build new version block
149+
const block: string[] = [`## ${targetVersion}`, ''];
150+
151+
const orderedSections = [
152+
...SECTION_ORDER.filter((s) => consolidated.has(s)),
153+
...[...consolidated.keys()].filter((s) => !SECTION_ORDER.includes(s)),
154+
];
155+
156+
for (const section of orderedSections) {
157+
const entries = [...consolidated.get(section)!.values()];
158+
if (entries.length === 0) continue;
159+
block.push(`### ${section}`, '');
160+
for (const entry of entries) {
161+
block.push(entry, '');
162+
}
163+
}
164+
165+
const newBlock = block.join('\n');
166+
167+
let header: string;
168+
let body: string;
169+
170+
if (fs.existsSync(ROOT_CHANGELOG)) {
171+
const content = fs.readFileSync(ROOT_CHANGELOG, 'utf-8');
172+
const firstHeadingMatch = content.match(/^## /m);
173+
if (firstHeadingMatch && firstHeadingMatch.index !== undefined) {
174+
header = content.slice(0, firstHeadingMatch.index);
175+
body = content.slice(firstHeadingMatch.index);
176+
} else {
177+
header = content.endsWith('\n') ? content : content + '\n';
178+
body = '';
179+
}
180+
} else {
181+
header = `# Changelog\n\n_History prior to ${targetVersion} is available in the per-package CHANGELOG files._\n\n`;
182+
body = '';
183+
}
184+
185+
fs.writeFileSync(
186+
ROOT_CHANGELOG,
187+
header + newBlock + (body ? '\n' + body : '\n'),
188+
'utf-8'
189+
);
190+
console.log(`✓ Root CHANGELOG.md updated with ${targetVersion}`);
191+
}
192+
193+
consolidate();

scripts/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

0 commit comments

Comments
 (0)