Files
OmniRoute/scripts/release/aggregate-changelog.mjs
Diego Rodrigues de Sa e Souza 86b1cb84fe feat(release): reconcile-changelog tool + version-anchored fragment aggregation (#12987)
* feat(release): reconcile-changelog tool + version-anchored fragment aggregation

`npm run release:reconcile` (scripts/release/reconcile-changelog.mjs) turns the
v3.8.51 reconciliation pass (#12971) into a repeatable Phase 0a step:

- folds `changelog.d/` fragments under `## [<version>]` — never under the first
  matching heading — and credits each one with the PR of the commit that ADDED
  it (`git log --diff-filter=A`), because the filename prefix is not reliable
  (issue numbers, closed/recreated PRs, literal `#PR_NUMBER`); `--carrier N`
  marks a PR that only back-filled fragments for other people's PRs
- drops fragments whose text already ships in another version section
  (phantom fragments re-added by branches cut before the previous aggregation)
- covers a commit only when its OWN PR is a primary ref of a bullet (a
  `/pull/N` link, the trailing `(#N)`, or an explicit `(#N …)` group) so an
  incidental mention cannot hide a PR's own bullet; generates
  `**type(scope):** subject (#PR) — thanks @author` for the rest, rolls
  Dependabot bumps into one line, documents direct pushes by hash
- `--credit N=handle` carries the closed-PR / co-author / deleted-PR audit
- keeps pre-existing section bullets verbatim (changelog-integrity compares
  bullet lines), never touches `[Unreleased]` or older sections
- opens the section with "📊 Release by the numbers" + "🏆 Top 25" (mailmap +
  merged-PR login), the v3.8.50 format

`aggregate-changelog.mjs` gains the same anchoring: `insertBullets(text,
bullets, version)` searches the heading inside `## [version]` only (with
`[Unreleased]` still carrying `###  New Features`, every feature fragment was
landing there); `aggregate()` reads the version from package.json.

Tests: tests/unit/reconcile-changelog.test.ts (helpers + an end-to-end
reconcile fixture) and two new cases in tests/unit/changelog-fragments.test.ts.

* fix(release): escape every regex metacharacter before building the mention regex

CodeQL js/incomplete-sanitization on reconcile-changelog.mjs: the handle was only
escaping '-' before being interpolated into a RegExp. Use a full escapeRegExp
helper instead; handles are [A-Za-z0-9_-] in practice, so behaviour is unchanged
for real input and the addCredit tests still pass.
2026-09-08 09:11:13 -03:00

192 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// scripts/release/aggregate-changelog.mjs
//
// Changelog FRAGMENTS aggregator (towncrier/changesets pattern, adopted 2026-07-09).
//
// Why: during a release cycle every PR used to edit the same few lines at the top of
// CHANGELOG.md (its bullet). In a merge-storm each merge conflicted every sibling
// (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per
// merge — O(N²) CI runs for N queued PRs. With fragments, a PR adds ONE NEW FILE under
// changelog.d/<section>/ instead, so two PRs never touch the same file: no conflicts,
// no eat, no re-sync. This script is the single place fragments become CHANGELOG.md
// bullets — run by the release captain (or /generate-release) at reconciliation, and
// safe to run mid-cycle whenever a consolidated view is wanted.
//
// Convention:
// changelog.d/features/<PR>-<slug>.md → appended to "### ✨ New Features"
// changelog.d/fixes/<PR>-<slug>.md → appended to "### 🐛 Bug Fixes"
// changelog.d/maintenance/<PR>-<slug>.md → appended to "### 📝 Maintenance"
// File content = the exact bullet line(s), starting with "- " (continuation lines
// allowed). Credit format stays the repo norm: "(#PR — thanks @user)".
//
// Usage:
// node scripts/release/aggregate-changelog.mjs [--dry-run]
// --dry-run print the would-be CHANGELOG.md to stdout and list fragments;
// touch nothing.
//
// On a real run, aggregated fragment files are DELETED (leaving README.md and the
// .gitkeep placeholders) — the caller commits both the CHANGELOG.md update and the
// deletions in one commit.
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FRAGMENTS_DIR = "changelog.d";
/** Section subdir → the CHANGELOG heading its bullets are appended under. */
export const SECTIONS = Object.freeze({
features: "### ✨ New Features",
fixes: "### 🐛 Bug Fixes",
maintenance: "### 📝 Maintenance",
});
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
/** The living cycle version = package.json `version` (null when unreadable → legacy first-heading mode). */
export function readVersion(root = ROOT) {
try {
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version || null;
} catch {
return null;
}
}
/**
* Validate one fragment's text. Returns null when OK, or a human-readable error.
* Pure — unit-tested.
*/
export function validateFragmentText(text) {
const body = String(text || "").replace(/^/, "");
const lines = body.split("\n");
const firstContent = lines.find((l) => l.trim().length > 0);
if (!firstContent) return "empty fragment";
if (!firstContent.trimStart().startsWith("- ")) {
return 'fragment must start with a markdown bullet ("- ")';
}
if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers";
return null;
}
/**
* Collect fragments from <root>/changelog.d, sorted by filename per section for a
* deterministic output order. Returns { features: [...], fixes: [...],
* maintenance: [...], invalid: [{file, error}] } where each valid entry is
* { file, text } (text trimmed of trailing whitespace).
*/
export function collectFragments(root) {
const out = { features: [], fixes: [], maintenance: [], invalid: [] };
const base = join(root, FRAGMENTS_DIR);
if (!existsSync(base)) return out;
for (const section of Object.keys(SECTIONS)) {
const dir = join(base, section);
if (!existsSync(dir)) continue;
const files = readdirSync(dir)
.filter((f) => f.endsWith(".md") && !SKIP_FILES.has(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
for (const f of files) {
const file = join(dir, f);
const text = readFileSync(file, "utf8").replace(/\s+$/, "");
const error = validateFragmentText(text);
if (error) out.invalid.push({ file: relative(root, file), error });
else out[section].push({ file: relative(root, file), text });
}
}
return out;
}
/**
* Append bullets at the END of a living-section heading's bullet block (before the
* next "##"/"###" heading). When `version` is given the heading is searched INSIDE the
* `## [version]` block only — `[Unreleased]` still carries a `### ✨ New Features`
* heading, so the first occurrence in the file is the wrong one (v3.8.51: every feature
* fragment was landing under `[Unreleased]`, #12971). Without `version` the FIRST
* occurrence is used (legacy behaviour). Pure — unit-tested. Throws when a needed heading
* is missing (the release captain adds the heading; the script never invents structure).
*/
export function insertBullets(changelogText, bulletsBySection, version = null) {
let lines = changelogText.split("\n");
for (const [section, heading] of Object.entries(SECTIONS)) {
const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b);
if (bullets.length === 0) continue;
let from = 0;
let to = lines.length;
if (version) {
from = lines.findIndex((l) => l.startsWith(`## [${version}]`));
if (from === -1) {
throw new Error(
`section "## [${version}]" not found in CHANGELOG.md — fragments must land in the living version section`
);
}
to = lines.findIndex((l, i) => i > from && l.startsWith("## ["));
if (to === -1) to = lines.length;
}
const rel = lines.slice(from, to).findIndex((l) => l.trim() === heading);
const headIdx = rel === -1 ? -1 : from + rel;
if (headIdx === -1) {
throw new Error(
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
);
}
// End of this section's block: last non-empty line before the next heading.
let nextHead = lines.length;
for (let i = headIdx + 1; i < lines.length; i++) {
if (/^##/.test(lines[i])) {
nextHead = i;
break;
}
}
let insertAt = nextHead;
while (insertAt > headIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--;
const block = bullets.flatMap((b) => b.split("\n"));
lines = [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)];
}
return lines.join("\n");
}
/**
* Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is
* true nothing is written or deleted.
*/
export function aggregate({ root = ROOT, dryRun = false, version = readVersion(root) } = {}) {
const collected = collectFragments(root);
if (collected.invalid.length > 0) {
const detail = collected.invalid.map((i) => `${i.file}: ${i.error}`).join("\n");
throw new Error(`invalid changelog fragments:\n${detail}`);
}
const total = collected.features.length + collected.fixes.length + collected.maintenance.length;
const changelogPath = join(root, "CHANGELOG.md");
const before = readFileSync(changelogPath, "utf8");
const after = total === 0 ? before : insertBullets(before, collected, version);
if (!dryRun && total > 0) {
writeFileSync(changelogPath, after);
for (const section of Object.keys(SECTIONS)) {
for (const { file } of collected[section]) unlinkSync(join(root, file));
}
}
return { total, collected, changed: total > 0, after };
}
function main() {
const dryRun = process.argv.includes("--dry-run");
const result = aggregate({ dryRun });
if (result.total === 0) {
console.log("[aggregate-changelog] no fragments to aggregate — nothing to do.");
return 0;
}
for (const section of Object.keys(SECTIONS)) {
for (const { file } of result.collected[section]) {
console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`);
}
}
console.log(
`[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}`
);
return 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(main());
}