From 8a0cfff79a432ff961f10161e731f2a18fdc4694 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 8 May 2026 08:34:55 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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 = '';