From 8a0cfff79a432ff961f10161e731f2a18fdc4694 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 8 May 2026 08:34:55 +0200 Subject: [PATCH 1/7] 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. --- package.json | 2 +- scripts/consolidate-changelog.ts | 189 +++++++++++++++++++++++++++++++ scripts/package.json | 3 + 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 scripts/consolidate-changelog.ts create mode 100644 scripts/package.json diff --git a/package.json b/package.json index f594a46f..721c22cd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "typecheck": "turbo run typecheck", "build": "turbo run build", "dev": "yarn workspaces foreach -Api run dev", - "ci:version": "changeset version && yarn install --no-immutable", + "ci:version": "changeset version && yarn install --no-immutable && node --experimental-strip-types --no-warnings ./scripts/consolidate-changelog.ts", "ci:publish": "yarn workspaces foreach --no-private -At npm publish && changeset tag", "brownfield:plugin:publish:local": "bash ./gradle-plugins/publish-to-maven-local.sh --skip-signing", "brownfield:plugin:publish:local:signed": "bash ./gradle-plugins/publish-to-maven-local.sh", diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts new file mode 100644 index 00000000..41ce6e04 --- /dev/null +++ b/scripts/consolidate-changelog.ts @@ -0,0 +1,189 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT_DIR = process.cwd(); +const PACKAGES_DIR = path.join(ROOT_DIR, 'packages'); +const ROOT_CHANGELOG = path.join(ROOT_DIR, 'CHANGELOG.md'); + +const SECTION_ORDER = ['Major Changes', 'Minor Changes', 'Patch Changes']; + +interface ParsedVersion { + version: string; + sections: Map>; +} + +function extractEntryKey(entry: string): string { + const prMatch = entry.match(/\[#(\d+)\]/); + if (prMatch) return `pr-${prMatch[1]}`; + + const hashMatch = entry.match(/\[`([a-f0-9]{7,40})`\]/); + if (hashMatch) return `commit-${hashMatch[1]}`; + + return entry.trim(); +} + +function parseEntries(block: string): string[] { + const entries: string[] = []; + let current: string[] = []; + + for (const line of block.split('\n')) { + if (line.startsWith('- ')) { + if (current.length > 0) entries.push(current.join('\n').trim()); + current = [line]; + } else if (line.startsWith(' ') && current.length > 0) { + current.push(line); + } + // blank lines and non-indented non-bullet lines within a block are ignored + } + + if (current.length > 0) entries.push(current.join('\n').trim()); + + return entries.filter((e) => e.length > 0); +} + +function parseLatestVersion(content: string): ParsedVersion | null { + const lines = content.split('\n'); + + let vStart = -1; + let version = ''; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^## (\d+\.\d+\.\d+)/); + if (m) { + vStart = i; + version = m[1]; + break; + } + } + if (vStart === -1) return null; + + let vEnd = lines.length; + for (let i = vStart + 1; i < lines.length; i++) { + if (lines[i].match(/^## /)) { + vEnd = i; + break; + } + } + + const sectionContent = lines.slice(vStart + 1, vEnd).join('\n'); + const subsectionHeaders = [...sectionContent.matchAll(/^### (.+)$/gm)]; + const subsectionBodies = sectionContent.split(/^### .+$/m); + + const sections = new Map>(); + + for (let i = 0; i < subsectionHeaders.length; i++) { + const name = subsectionHeaders[i][1].trim(); + const body = subsectionBodies[i + 1] ?? ''; + const entries = parseEntries(body).filter( + (e) => !e.startsWith('- Updated dependencies') + ); + + if (entries.length > 0) { + const map = new Map(); + for (const entry of entries) { + const key = extractEntryKey(entry); + if (!map.has(key)) map.set(key, entry); + } + sections.set(name, map); + } + } + + return { version, sections }; +} + +function consolidate(): void { + const changelogPaths = fs + .readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => path.join(PACKAGES_DIR, d.name, 'CHANGELOG.md')) + .filter((p) => fs.existsSync(p)); + + if (changelogPaths.length === 0) { + console.error('No package CHANGELOG files found.'); + process.exit(1); + } + + let targetVersion: string | null = null; + const consolidated = new Map>(); + + for (const changelogPath of changelogPaths) { + const parsed = parseLatestVersion(fs.readFileSync(changelogPath, 'utf-8')); + if (!parsed) continue; + + if (!targetVersion) { + targetVersion = parsed.version; + } else if (parsed.version !== targetVersion) { + console.warn( + `Version mismatch: expected ${targetVersion}, got ${parsed.version} in ${changelogPath}` + ); + continue; + } + + for (const [section, entries] of parsed.sections) { + if (!consolidated.has(section)) consolidated.set(section, new Map()); + const target = consolidated.get(section)!; + for (const [key, entry] of entries) { + if (!target.has(key)) target.set(key, entry); + } + } + } + + if (!targetVersion) { + console.error('Could not determine release version from package CHANGELOGs.'); + process.exit(1); + } + + // Idempotency: skip if this version is already in the root CHANGELOG + if (fs.existsSync(ROOT_CHANGELOG)) { + const existing = fs.readFileSync(ROOT_CHANGELOG, 'utf-8'); + if (existing.includes(`\n## ${targetVersion}\n`)) { + console.log(`Root CHANGELOG already contains ${targetVersion}, skipping.`); + return; + } + } + + // Build new version block + const block: string[] = [`## ${targetVersion}`, '']; + + const orderedSections = [ + ...SECTION_ORDER.filter((s) => consolidated.has(s)), + ...[...consolidated.keys()].filter((s) => !SECTION_ORDER.includes(s)), + ]; + + for (const section of orderedSections) { + const entries = [...consolidated.get(section)!.values()]; + if (entries.length === 0) continue; + block.push(`### ${section}`, ''); + for (const entry of entries) { + block.push(entry, ''); + } + } + + const newBlock = block.join('\n'); + + let header: string; + let body: string; + + if (fs.existsSync(ROOT_CHANGELOG)) { + const content = fs.readFileSync(ROOT_CHANGELOG, 'utf-8'); + const firstVersionIdx = content.indexOf('\n## '); + if (firstVersionIdx !== -1) { + header = content.slice(0, firstVersionIdx + 1); + body = content.slice(firstVersionIdx + 1); + } else { + header = content.endsWith('\n') ? content : content + '\n'; + body = ''; + } + } else { + header = `# Changelog\n\n_History prior to ${targetVersion} is available in the per-package CHANGELOG files._\n\n`; + body = ''; + } + + fs.writeFileSync( + ROOT_CHANGELOG, + header + newBlock + (body ? '\n' + body : '\n'), + 'utf-8' + ); + console.log(`✓ Root CHANGELOG.md updated with ${targetVersion}`); +} + +consolidate(); \ No newline at end of file diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 00000000..aead43de --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file From 36ef4e639b6f9419ecb6780e0b5f68b8ae373584 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 11 May 2026 09:21:55 +0200 Subject: [PATCH 2/7] 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. --- scripts/consolidate-changelog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index 41ce6e04..0166dc61 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -95,7 +95,8 @@ function consolidate(): void { .readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => path.join(PACKAGES_DIR, d.name, 'CHANGELOG.md')) - .filter((p) => fs.existsSync(p)); + .filter((p) => fs.existsSync(p)) + .sort(); if (changelogPaths.length === 0) { console.error('No package CHANGELOG files found.'); From b90bb63637e68fc28e14dcc0bacae14f8cdf40c1 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 11 May 2026 09:25:55 +0200 Subject: [PATCH 3/7] 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. --- scripts/consolidate-changelog.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index 0166dc61..c3f9f060 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -9,7 +9,7 @@ const SECTION_ORDER = ['Major Changes', 'Minor Changes', 'Patch Changes']; interface ParsedVersion { version: string; - sections: Map>; + sections: Map; } function extractEntryKey(entry: string): string { @@ -68,7 +68,7 @@ function parseLatestVersion(content: string): ParsedVersion | null { const subsectionHeaders = [...sectionContent.matchAll(/^### (.+)$/gm)]; const subsectionBodies = sectionContent.split(/^### .+$/m); - const sections = new Map>(); + const sections = new Map(); for (let i = 0; i < subsectionHeaders.length; i++) { const name = subsectionHeaders[i][1].trim(); @@ -78,12 +78,7 @@ function parseLatestVersion(content: string): ParsedVersion | null { ); if (entries.length > 0) { - const map = new Map(); - for (const entry of entries) { - const key = extractEntryKey(entry); - if (!map.has(key)) map.set(key, entry); - } - sections.set(name, map); + sections.set(name, entries); } } @@ -122,7 +117,8 @@ function consolidate(): void { for (const [section, entries] of parsed.sections) { if (!consolidated.has(section)) consolidated.set(section, new Map()); const target = consolidated.get(section)!; - for (const [key, entry] of entries) { + for (const entry of entries) { + const key = extractEntryKey(entry); if (!target.has(key)) target.set(key, entry); } } From 314265e32b315d06595059539dfb925a6cef9e52 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 11 May 2026 09:28:57 +0200 Subject: [PATCH 4/7] 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 "## " block with no content. Treat that case as a no-op and log it. --- scripts/consolidate-changelog.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index c3f9f060..89f1c8dd 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -129,6 +129,13 @@ function consolidate(): void { process.exit(1); } + if (consolidated.size === 0) { + console.log( + `No substantive entries for ${targetVersion} (all filtered as "Updated dependencies"), skipping root CHANGELOG update.` + ); + return; + } + // Idempotency: skip if this version is already in the root CHANGELOG if (fs.existsSync(ROOT_CHANGELOG)) { const existing = fs.readFileSync(ROOT_CHANGELOG, 'utf-8'); From a5394cac46ee4280aacf764b738ca89bde7ae135 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 11 May 2026 09:31:38 +0200 Subject: [PATCH 5/7] 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. --- scripts/consolidate-changelog.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index 89f1c8dd..aca76a80 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -139,7 +139,9 @@ function consolidate(): void { // Idempotency: skip if this version is already in the root CHANGELOG if (fs.existsSync(ROOT_CHANGELOG)) { const existing = fs.readFileSync(ROOT_CHANGELOG, 'utf-8'); - if (existing.includes(`\n## ${targetVersion}\n`)) { + const escapedVersion = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const versionHeadingRe = new RegExp(`^##\\s+${escapedVersion}(\\s|$)`, 'm'); + if (versionHeadingRe.test(existing)) { console.log(`Root CHANGELOG already contains ${targetVersion}, skipping.`); return; } From e70ec835d716875795877e48321539e1f584656e Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Mon, 11 May 2026 09:34:00 +0200 Subject: [PATCH 6/7] fix(scripts): detect version heading at file start indexOf('\n## ') required a preceding newline, so a root CHANGELOG that begins directly with a "## " 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. --- scripts/consolidate-changelog.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index aca76a80..972b73e2 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -171,10 +171,10 @@ function consolidate(): void { if (fs.existsSync(ROOT_CHANGELOG)) { const content = fs.readFileSync(ROOT_CHANGELOG, 'utf-8'); - const firstVersionIdx = content.indexOf('\n## '); - if (firstVersionIdx !== -1) { - header = content.slice(0, firstVersionIdx + 1); - body = content.slice(firstVersionIdx + 1); + const firstHeadingMatch = content.match(/^## /m); + if (firstHeadingMatch && firstHeadingMatch.index !== undefined) { + header = content.slice(0, firstHeadingMatch.index); + body = content.slice(firstHeadingMatch.index); } else { header = content.endsWith('\n') ? content : content + '\n'; body = ''; From 261ee780acdbbd7edaa2a3aa043d46cae3775667 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Tue, 12 May 2026 11:23:22 +0200 Subject: [PATCH 7/7] 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. --- scripts/consolidate-changelog.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/consolidate-changelog.ts b/scripts/consolidate-changelog.ts index 972b73e2..2f2de12c 100644 --- a/scripts/consolidate-changelog.ts +++ b/scripts/consolidate-changelog.ts @@ -73,9 +73,7 @@ function parseLatestVersion(content: string): ParsedVersion | null { for (let i = 0; i < subsectionHeaders.length; i++) { const name = subsectionHeaders[i][1].trim(); const body = subsectionBodies[i + 1] ?? ''; - const entries = parseEntries(body).filter( - (e) => !e.startsWith('- Updated dependencies') - ); + const entries = parseEntries(body); if (entries.length > 0) { sections.set(name, entries);