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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-08 09:11:13 -03:00
committed by GitHub
parent 10fef01d20
commit 86b1cb84fe
5 changed files with 1355 additions and 8 deletions

View File

@@ -277,6 +277,7 @@
"postbuild": "node scripts/build/colocate-standalone.mjs",
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
"release:reconcile": "node scripts/release/reconcile-changelog.mjs",
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"",
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"

View File

@@ -44,6 +44,15 @@ export const SECTIONS = Object.freeze({
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.
@@ -89,17 +98,32 @@ export function collectFragments(root) {
/**
* Append bullets at the END of a living-section heading's bullet block (before the
* next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this
* repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested.
* Throws when a needed heading is missing (the release captain adds the heading; the
* script never invents structure).
* 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) {
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;
const headIdx = lines.findIndex((l) => l.trim() === heading);
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`
@@ -125,7 +149,7 @@ export function insertBullets(changelogText, bulletsBySection) {
* 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 } = {}) {
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");
@@ -134,7 +158,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
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);
const after = total === 0 ? before : insertBullets(before, collected, version);
if (!dryRun && total > 0) {
writeFileSync(changelogPath, after);
for (const section of Object.keys(SECTIONS)) {

View File

@@ -0,0 +1,831 @@
#!/usr/bin/env node
// scripts/release/reconcile-changelog.mjs
//
// Reconcile the living `## [<version>]` CHANGELOG section against the FULL development cycle
// (Phase 0a.10a.3a of /generate-release), so that at the moment a release is cut:
// • every cycle commit is represented by a bullet whose PRIMARY reference is that commit's PR,
// • every bullet carries the merged PR link and `— thanks @author` (Hard Rule #16),
// • fragments are folded in under the RIGHT version section (not the first heading that
// matches — `[Unreleased]` still carries a `### ✨ New Features` heading),
// • fragments that duplicate bullets already shipped in a previous version are dropped,
// • the section opens with "📊 Release by the numbers" + "🏆 Top 25 contributors" (v3.8.50 format).
//
// It never touches bullets that already exist in the section (the changelog-integrity gate
// compares bullet lines against the base), never touches `[Unreleased]`, and never edits any
// other version section. Run `npm run release:contributors -- <version> --inject` afterwards to
// (re)build the `### 🙌 Contributors` table, then `release:sync-changelog-i18n`.
//
// Lessons baked in (v3.8.51 reconciliation, 2026-09-07 — PR #12971):
// • prefix of a fragment filename is NOT a reliable PR number (issue numbers, closed/recreated
// PRs, literal `#PR_NUMBER`); the commit that ADDED the fragment (`git log --diff-filter=A`)
// is the definitive origin — unless that commit is a "carrier" PR that only back-filled
// fragments for other people's PRs (`--carrier N`), then the prefix wins;
// • a commit is covered only when its OWN PR is a primary ref (a `/pull/N` link or the last
// `#N` on the bullet line) — an incidental mention ("Opper #11629 + 1min.ai #11631") must not
// hide a PR's own bullet;
// • gen-contributors only reads lines that start with "- ", so fragment bullets are collapsed
// to a single line (pre-existing section bullets are left verbatim).
//
// Usage:
// node scripts/release/reconcile-changelog.mjs [--version 3.8.51] [--base <ref>] [--head <ref>]
// [--release-branch release/v3.8.51] [--credit 12255=backryun] [--carrier 11938]
// [--drop-fragment changelog.d/fixes/x.md] [--fragment-pr changelog.d/fixes/y.md=11845]
// [--prs <cached gh json>] [--dry-run] [--report <path.json>]
//
// Defaults: version = package.json; release branch = release/v<version>; head = HEAD;
// base = parent of the commit that opened the cycle (first commit carrying the version string,
// see resolveCycleBase in list-uncovered-commits.mjs) — pass `--base origin/release/v<prev>` to
// use the previous release tip explicitly. Exit 0 always (advisory: the captain reviews the diff).
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveCycleBase } from "./list-uncovered-commits.mjs";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export const MAINTAINER = "diegosouzapw";
export const SECTION_HEADINGS = Object.freeze({
features: "### ✨ New Features",
fixes: "### 🐛 Bug Fixes",
maintenance: "### 📝 Maintenance",
});
const TYPE_LABEL = {
fix: "🐛 Fixes",
feat: "✨ Features",
docs: "📚 Docs",
chore: "🧹 Chore",
test: "🧪 Tests",
refactor: "♻️ Refactor",
perf: "⚡ Performance",
security: "🔒 Security",
ci: "⚙️ CI",
deps: "📦 Dependencies",
build: "🏗️ Build",
revert: "⏪ Reverts",
other: "🔀 Other",
};
const BOT_RE = /dependabot|\[bot\]|^app\//i;
// ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
/** `[#N](url)` → `#N`, `[@h](url)` → `@h` so refs/handles can be scanned uniformly. */
export const normalizeLinks = (s) =>
s.replace(/\[#(\d+)\]\([^)]*\)/g, "#$1").replace(/\[@([A-Za-z0-9_-]+)\]\([^)]*\)/g, "@$1");
export const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
export const prLink = (repo, n) => `[#${n}](https://github.com/${repo}/pull/${n})`;
/**
* Primary refs of a bullet: every `/pull/N` link, the LAST `#N` on its first line (the
* conventional trailing `(#N)` back-reference) and every explicit `(#N …)` group — a group
* that OPENS with the ref, e.g. `(#11436)` or `(#11436 — thanks @x)`. An incidental mention
* inside prose (`(Opper #11629 + 1min.ai #11631)`) does not count.
*/
export function primaryRefs(bullet) {
const flat = normalizeLinks(bullet.replace(/\n\s+/g, " "));
const out = new Set([...bullet.matchAll(/\/pull\/(\d+)\)/g)].map((m) => Number(m[1])));
const refs = refsIn(flat);
if (refs.length) out.add(refs[refs.length - 1]);
for (const m of flat.matchAll(/\(#(\d+)(?=[\s,)—-])/g)) out.add(Number(m[1]));
return out;
}
/** Commit hashes a bullet documents explicitly as `(direct commit \`<hash>\`)`. */
export const directHashes = (bullet) =>
[...bullet.matchAll(/direct commit `([0-9a-f]{7,40})`/g)].map((m) => m[1]);
/**
* Split a markdown text into bullet blocks per section heading. A block is a `- ` line plus its
* indented continuation lines. `fixedSection` forces every bullet into one section (fragments).
* `collapse` joins continuation lines into the first line (fragments only — never pre-existing
* section bullets, which the changelog-integrity gate compares line by line).
*/
export function parseBlocks(text, { fixedSection = null, collapse = false } = {}) {
const out = { features: [], fixes: [], maintenance: [] };
let cur = fixedSection;
let block = null;
const flush = () => {
if (block && cur) {
out[cur].push(
collapse
? block.map((l, i) => (i ? l.trim() : l.replace(/\s+$/, ""))).join(" ")
: block.join("\n")
);
}
block = null;
};
for (const line of text.split("\n")) {
if (!fixedSection && line.startsWith("### ")) {
flush();
cur = Object.keys(SECTION_HEADINGS).find((k) => SECTION_HEADINGS[k] === line.trim()) || null;
continue;
}
if (line.startsWith("- ")) {
flush();
if (cur) block = [line];
continue;
}
if (block && /^\s+\S/.test(line)) {
block.push(line);
continue;
}
flush();
}
flush();
return out;
}
/** Escape every regex metacharacter (CodeQL js/incomplete-sanitization: never escape just one). */
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mentions = (b, h) => new RegExp(`@${escapeRegExp(h)}(?![A-Za-z0-9_-])`, "i").test(b);
/** Append `— thanks @a / @b` (or extend an existing trailing thanks group) for handles not yet mentioned. */
export function addCredit(bullet, handles, maintainer = MAINTAINER) {
const hs = [...new Set(handles.filter((h) => h && h !== maintainer && !mentions(bullet, h)))];
if (!hs.length) return bullet;
const lines = bullet.split("\n");
let last = lines[lines.length - 1];
const add = hs.map((h) => `@${h}`).join(" / ");
if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\s*$/.test(last)) {
last = `${last.replace(/\s*$/, "")} / ${add}`;
} else if (/thanks\s+@[A-Za-z0-9_-]+(\s*\/\s*@[A-Za-z0-9_-]+)*\)\s*$/.test(last)) {
last = `${last.replace(/\)\s*$/, "")} / ${add})`;
} else {
last = `${last.replace(/\s*$/, "")} — thanks ${add}`;
}
lines[lines.length - 1] = last;
return lines.join("\n");
}
/** Append a `([#N](…))` link to the last line of a bullet, before any trailing thanks group. */
export function appendLink(bullet, repo, n) {
if (refsIn(normalizeLinks(bullet)).includes(n)) return bullet;
const lines = bullet.split("\n");
let last = lines[lines.length - 1];
const th = last.match(/\s*—\s*thanks\s+@[^\n]*$/);
last = th
? `${last.slice(0, th.index).replace(/\s*$/, "")} (${prLink(repo, n)})${th[0]}`
: `${last.replace(/\s*$/, "")} (${prLink(repo, n)})`;
lines[lines.length - 1] = last;
return lines.join("\n");
}
/** Conventional-commit subject → { type, text } with the type prefix bolded (repo format). */
export function bulletFromSubject(subject, special = {}) {
let s = subject
.replace(/\s*\(#\d+\)\s*$/, "")
.replace(/^\[?URGENT\]?\s*/i, "")
.trim();
if (special[s]) s = special[s];
const m = s.match(/^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i);
if (!m) return { type: "other", text: s };
return { type: m[1].toLowerCase(), text: `**${m[1].toLowerCase()}${m[2] || ""}:** ${m[4]}` };
}
export const sectionForType = (type) =>
type === "feat"
? "features"
: ["fix", "perf", "security", "revert"].includes(type)
? "fixes"
: "maintenance";
/** Type of an existing bullet (`- **fix(x):** …` or `- fix(x): …`), else "other". */
export function typeOfBullet(bullet) {
const m = normalizeLinks(bullet).match(/^- \*{0,2}([a-z]+)(?:\([^)]*\))?!?:\*{0,2}/i);
return m ? m[1].toLowerCase() : "other";
}
/** Text key used to spot twins (two fragments for one PR, a direct-commit twin of a synced PR). */
export const dedupKey = (b) =>
normalizeLinks(b)
.split("\n")[0]
.replace(/\s*\((?:#\d+[^)]*|direct commit[^)]*)\)\s*/g, " ")
.replace(/\s*—\s*thanks.*$/, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
.slice(0, 100);
/**
* Keep one bullet per dedupKey (the best-linked one) and MERGE every `([#N](…))` /
* `(direct commit …)` group the dropped twins carried into the survivor.
*/
export function dedupeBullets(list) {
const best = new Map();
const score = (b) => (b.match(/\/pull\//g) || []).length * 10 + b.length / 1000;
for (const b of list) {
const k = dedupKey(b);
if (!best.has(k) || score(b) > best.get(k).score) best.set(k, { b, score: score(b) });
}
const extras = new Map();
const dropped = [];
for (const b of list) {
const k = dedupKey(b);
if (best.get(k).b === b) continue;
dropped.push(b);
if (!extras.has(k)) extras.set(k, []);
for (const m of b.matchAll(/\(\[#\d+\]\([^)]*\)\)|\(direct commit `[0-9a-f]+`\)/g)) {
extras.get(k).push(m[0]);
}
}
const seen = new Set();
const kept = [];
for (const b of list) {
const k = dedupKey(b);
if (best.get(k).b !== b || seen.has(k)) continue;
seen.add(k);
let out = b;
for (const g of extras.get(k) || []) {
if (out.includes(g)) continue;
const th = out.match(/\s*—\s*thanks[^\n]*$/);
out = th ? `${out.slice(0, th.index)} ${g}${th[0]}` : `${out} ${g}`;
}
kept.push(out);
}
return { kept, dropped };
}
/** Extract `## [version]` … up to the next `## [` (exclusive). */
export function versionSectionRange(changelog, version) {
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = changelog.match(new RegExp(`^## \\[${esc}\\][^\\n]*$`, "m"));
if (!m) return null;
const bodyStart = m.index + m[0].length;
const rest = changelog.slice(bodyStart);
const next = rest.search(/\n## \[/);
return { start: m.index, bodyStart, end: next === -1 ? changelog.length : bodyStart + next };
}
/** Everything under every `## [` heading OTHER than `version` (used to spot already-shipped text). */
export function otherSectionsText(changelog, version) {
const r = versionSectionRange(changelog, version);
if (!r) return changelog;
return changelog.slice(0, r.start) + changelog.slice(r.end);
}
/** Commits covered by the bullets: own PR is a primary ref (or closes a cited issue); no-PR commits by any ref. */
export function computeCoverage(
rows,
bullets,
{ closingPrs = new Set(), originHashes = new Set(), skipHashes = new Set() } = {}
) {
const cited = new Set();
const hashes = [];
for (const b of bullets) {
for (const n of primaryRefs(b)) cited.add(n);
hashes.push(...directHashes(b));
}
const documentedHash = (full) =>
hashes.some((h) => full.startsWith(h) || h.startsWith(full.slice(0, 9)));
const uncovered = rows.filter((r) => {
const h = r.hash.slice(0, 9);
if (skipHashes.has(h) || originHashes.has(h)) return false;
if (r.pr) return !(cited.has(r.pr) || closingPrs.has(r.pr));
return !(documentedHash(r.hash) || r.refs.some((x) => cited.has(x)));
});
return { cited, uncovered };
}
/** Rank authors by commits; key = GitHub login of the merged PR when known, else the mailmap name. */
export function rankAuthors(rows, limit = 25) {
const counts = {};
const names = {};
for (const r of rows) {
if (BOT_RE.test(r.authorName)) continue;
const k = r.prAuthor || r.authorName;
counts[k] = (counts[k] || 0) + 1;
names[k] ??= {};
names[k][r.authorName] = (names[k][r.authorName] || 0) + 1;
}
const display = (k) => Object.entries(names[k]).sort((a, b) => b[1] - a[1])[0][0];
return {
counts,
top: Object.entries(counts)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([k, c]) => [display(k) === k ? k : `${display(k)} (@${k})`, c]),
};
}
const fmt = (n) => n.toLocaleString("en-US");
const medal = (i) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : String(i + 1));
/** Render the whole `## [version]` section (header → stats → ranking → three sections). */
export function renderSection({
version,
today,
baseTip,
headTip,
rows,
all,
ranking,
prNumbers,
dateSuffix = "TBD",
}) {
const bullets = Object.values(all)
.flat()
.filter((b) => b.startsWith("- "));
const byType = {};
const SEC_DEFAULT = { features: "feat", fixes: "fix", maintenance: "chore" };
for (const k of Object.keys(all)) {
for (const b of all[k].filter((x) => x.startsWith("- "))) {
let t = typeOfBullet(b);
if (!TYPE_LABEL[t] || t === "other") t = SEC_DEFAULT[k];
byType[TYPE_LABEL[t]] = (byType[TYPE_LABEL[t]] || 0) + 1;
}
}
const prRefs = new Set();
const handles = new Set();
for (const b of bullets) {
for (const n of refsIn(normalizeLinks(b))) if (prNumbers.has(n)) prRefs.add(n);
for (const m of normalizeLinks(b).matchAll(/@([A-Za-z0-9_-]+)/g)) handles.add(m[1]);
}
const humans = rows.filter((r) => !BOT_RE.test(r.authorName));
const people = new Set([...Object.keys(ranking.counts), ...handles].map((x) => x.toLowerCase()));
const lines = [
`## [${version}] — ${dateSuffix}`,
``,
`_Living section — reconciled ${today} from all cycle commits (\`${baseTip}\`\`${headTip}\`, ${fmt(rows.length)} non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each \`/generate-release\` phase._`,
``,
`### 📊 Release by the numbers`,
``,
`| | |`,
`| --- | ---: |`,
`| 👥 People who contributed | **${fmt(people.size)}** |`,
`| 📝 Commits in the cycle | **${fmt(rows.length)}** |`,
`| 🔀 Pull requests referenced | **${fmt(prRefs.size)}** |`,
`| 📋 Changelog entries | **${fmt(bullets.length)}** |`,
`| 🙌 Contributors credited in entries | **${fmt(handles.size)}** |`,
`| 🤖 Automated dependency commits | ${rows.length - humans.length} |`,
``,
`**Entries by type**`,
``,
`| Type | Count |`,
`| --- | ---: |`,
...Object.entries(byType)
.sort((a, b) => b[1] - a[1])
.map(([t, c]) => `| ${t} | ${c} |`),
``,
`### 🏆 Top 25 contributors this cycle`,
``,
`_By commits in \`${baseTip}..${headTip}\`, author identities consolidated via \`.mailmap\` and the merged PR's GitHub login. Bots excluded._`,
``,
`| # | Contributor | Commits |`,
`| ---: | --- | ---: |`,
...ranking.top.map(([n, c], i) => `| ${medal(i)} | ${n} | ${c} |`),
``,
];
for (const k of ["features", "fixes", "maintenance"]) {
lines.push(SECTION_HEADINGS[k], "", ...all[k], "");
}
return `${lines.join("\n")}\n---\n`;
}
// ───────────────────────────── data acquisition (git + gh) ─────────────────────────────
const git = (args, cwd = ROOT) =>
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }).trim();
const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 1 << 28 });
export function repoSlug(cwd = ROOT) {
const url = git(["remote", "get-url", "origin"], cwd);
const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
return m ? m[1] : "diegosouzapw/OmniRoute";
}
/** Non-merge commits in `base..head` with mailmap identities, PR number and co-author trailers. */
export function readCommits(base, head, cwd = ROOT) {
const raw = git(
[
"log",
"--no-merges",
"--use-mailmap",
"--date=short",
"--format=%H%x1f%ad%x1f%aN%x1f%aE%x1f%s%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1e)%x1e%x1e",
`${base}..${head}`,
],
cwd
);
return raw
.split("\x1e\x1e")
.map((s) => s.replace(/^\n/, ""))
.filter((s) => s.trim())
.map((r) => {
const [hash, date, authorName, authorEmail, subject, coa] = r.split("\x1f");
const refs = refsIn(subject);
const prMatch = subject.match(/\(#(\d+)\)\s*$/);
return {
hash,
date,
authorName,
authorEmail,
subject,
refs,
pr: prMatch ? Number(prMatch[1]) : null,
prAuthor: null,
coauthors: (coa || "")
.split("\x1e")
.map((s) => s.trim())
.filter(Boolean),
};
});
}
export function fetchMergedPrs(repo, releaseBranch) {
return JSON.parse(
gh([
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--base",
releaseBranch,
"--limit",
"1000",
"--json",
"number,title,author,body,closingIssuesReferences,mergedAt",
])
);
}
export function fetchPr(repo, n) {
try {
return JSON.parse(
execFileSync(
"gh",
[
"pr",
"view",
String(n),
"--repo",
repo,
"--json",
"number,title,author,body,closingIssuesReferences",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}
)
);
} catch {
return null;
}
}
/** Fragment files at `ref` + the commit that ADDED each one (definitive origin of the credit). */
export function readFragments(ref = "HEAD", cwd = ROOT) {
const out = [];
for (const dir of Object.keys(SECTION_HEADINGS)) {
let files = "";
try {
files = git(["ls-tree", "--name-only", ref, `changelog.d/${dir}/`], cwd);
} catch {
continue;
}
for (const f of files
.split("\n")
.filter((x) => x && !/README\.md$|\.gitkeep$/.test(x))
.sort()) {
const text = git(["show", `${ref}:${f}`], cwd);
const origin =
git(["log", "--diff-filter=A", "--format=%h%x09%s", "--", f], cwd)
.split("\n")
.filter(Boolean)
.pop() || "";
const [ohash, osubject = ""] = origin.split("\t");
const om = osubject.match(/\(#(\d+)\)\s*$/);
const pm = f.match(/\/(\d{4,6})-/);
out.push({
path: f,
section: dir,
text,
originHash: ohash ? ohash.slice(0, 9) : null,
originPr: om ? Number(om[1]) : null,
prefixPr: pm ? Number(pm[1]) : null,
});
}
}
return out;
}
// ───────────────────────────── the reconciliation itself ─────────────────────────────
export function reconcile({
changelog,
version,
repo,
rows,
prs,
fragments,
extraPrs = new Map(),
credits = {},
carriers = new Set(),
dropFragments = new Set(),
fragmentPr = {},
fragmentCredit = {},
today,
baseTip,
headTip,
skipHashes = new Set(),
special = {},
}) {
const prBy = new Map(prs.map((p) => [p.number, p]));
for (const [n, p] of extraPrs) if (!prBy.has(n)) prBy.set(n, p);
const prAuthor = (n) => prBy.get(n)?.author?.login || null;
for (const r of rows) r.prAuthor = r.pr ? prAuthor(r.pr) : null;
const issueToPrs = new Map();
for (const p of prs)
for (const ci of p.closingIssuesReferences || []) {
if (!issueToPrs.has(ci.number)) issueToPrs.set(ci.number, []);
issueToPrs.get(ci.number).push(p.number);
}
const range = versionSectionRange(changelog, version);
if (!range) throw new Error(`CHANGELOG.md has no "## [${version}]" section`);
const existing = parseBlocks(changelog.slice(range.bodyStart, range.end));
const shippedElsewhere = normalizeLinks(otherSectionsText(changelog, version));
const blocks = {
features: [...existing.features],
fixes: [...existing.fixes],
maintenance: [...existing.maintenance],
};
const srcOf = new Map();
for (const f of fragments) {
const parsed = parseBlocks(f.text, { fixedSection: f.section, collapse: true });
for (const b of parsed[f.section]) {
blocks[f.section].push(b);
srcOf.set(b, f);
}
}
const dropped = [];
const patched = [];
const mismatches = [];
const originHashes = new Set();
for (const k of Object.keys(blocks)) {
const out = [];
for (let b of blocks[k]) {
const frag = srcOf.get(b);
const first = b.split("\n")[0].trim();
if (frag && dropFragments.has(frag.path)) {
dropped.push({ why: "dropped by --drop-fragment", first, src: frag.path });
continue;
}
const probe = normalizeLinks(first)
.replace(/\s*\(#\d+.*$/, "")
.slice(0, 90);
if (frag && probe.length > 40 && shippedElsewhere.includes(probe)) {
dropped.push({
why: "text already shipped in another version section",
first,
src: frag.path,
});
continue;
}
if (frag) {
const refs0 = refsIn(normalizeLinks(b));
const override = fragmentPr[frag.path];
const oPr = frag.originPr;
const fPr = frag.prefixPr;
let defPrs = override
? [override]
: oPr && prBy.has(oPr) && !carriers.has(oPr)
? [oPr]
: fPr && prBy.has(fPr)
? [fPr]
: oPr && prBy.has(oPr)
? [oPr]
: [];
if (!defPrs.length)
for (const n of refs0) for (const pr of issueToPrs.get(n) || []) defPrs.push(pr);
defPrs = [...new Set(defPrs)];
if (oPr && fPr && prBy.has(fPr) && oPr !== fPr) {
mismatches.push(
`${frag.path}: prefix #${fPr} (${prAuthor(fPr)}) vs origin #${oPr} (${prAuthor(oPr)}) → used #${defPrs.join("/")}`
);
}
if (b.includes("#PR_NUMBER") && defPrs.length) {
b = b
.replace(/\[#PR_NUMBER\]\([^)]*\)/g, prLink(repo, defPrs[0]))
.replace(/#PR_NUMBER/g, `#${defPrs[0]}`);
}
for (const h of fragmentCredit[frag.path] || []) b = addCredit(b, [h]);
for (const n of defPrs) {
const before = b;
b = appendLink(b, repo, n);
b = addCredit(b, [prAuthor(n), ...(credits[n] || [])]);
if (b !== before) patched.push(`${frag.path} → #${n} @${prAuthor(n)}`);
}
if (!override && !(oPr && carriers.has(oPr)) && frag.originHash)
originHashes.add(frag.originHash);
}
out.push(b);
}
blocks[k] = out;
}
const allExisting = Object.values(blocks)
.flat()
.filter((b) => b.startsWith("- "));
const closingPrs = new Set();
const citedIssues = new Set(allExisting.flatMap((b) => refsIn(normalizeLinks(b))));
for (const p of prs)
for (const ci of p.closingIssuesReferences || [])
if (citedIssues.has(ci.number)) closingPrs.add(p.number);
const { uncovered } = computeCoverage(rows, allExisting, {
closingPrs,
originHashes,
skipHashes,
});
const gen = { features: [], fixes: [], maintenance: [] };
const deps = [];
for (const r of [...uncovered].sort(
(a, b) => (a.pr || 0) - (b.pr || 0) || a.date.localeCompare(b.date)
)) {
if (BOT_RE.test(r.authorName)) {
deps.push(r);
continue;
}
const c = bulletFromSubject(r.subject, special);
let bullet = `- ${c.text}`;
bullet += r.pr ? ` (${prLink(repo, r.pr)})` : ` (direct commit \`${r.hash.slice(0, 10)}\`)`;
bullet = addCredit(bullet, [
r.pr ? prAuthor(r.pr) : null,
...(r.pr ? credits[r.pr] || [] : []),
]);
gen[sectionForType(c.type)].push(bullet);
}
if (deps.length) {
gen.maintenance.push(
`- **deps:** ${deps.length} Dependabot bumps — ${deps
.map(
(r) =>
`${r.subject.replace(/^deps(\([^)]*\))?:\s*/, "").replace(/\s*\(#\d+\)\s*$/, "")} (${prLink(repo, r.pr)})`
)
.join("; ")}`
);
}
const all = {};
const dedupDropped = [];
for (const k of Object.keys(blocks)) {
const existingSet = new Set(existing[k]);
const merged = [...blocks[k], ...gen[k]];
const kept = merged.filter((b) => existingSet.has(b));
const { kept: rest, dropped: dd } = dedupeBullets(merged.filter((b) => !existingSet.has(b)));
dedupDropped.push(...dd);
all[k] = [...kept, ...rest];
}
const ranking = rankAuthors(rows);
const section = renderSection({
version,
today,
baseTip,
headTip,
rows,
all,
ranking,
prNumbers: new Set(prBy.keys()),
});
const next = `${changelog.slice(0, range.start)}${section}${changelog.slice(range.end + 1)}`;
const report = {
version,
baseTip,
headTip,
commits: rows.length,
existing: Object.fromEntries(Object.entries(existing).map(([k, v]) => [k, v.length])),
fragments: fragments.length,
generated: Object.fromEntries(Object.entries(gen).map(([k, v]) => [k, v.length])),
bullets: Object.values(all)
.flat()
.filter((b) => b.startsWith("- ")).length,
dropped,
patched: patched.length,
mismatches,
dedupDropped,
uncovered: uncovered.length,
depsRolled: deps.length,
ranking: ranking.top,
};
return { changelog: next, report };
}
// ───────────────────────────── CLI ─────────────────────────────
function parseArgs(argv) {
const o = {
credit: {},
carriers: new Set(),
dropFragments: new Set(),
fragmentPr: {},
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const v = () => argv[++i];
if (a === "--version") o.version = v();
else if (a === "--base") o.base = v();
else if (a === "--head") o.head = v();
else if (a === "--release-branch") o.releaseBranch = v();
else if (a === "--prs") o.prs = v();
else if (a === "--report") o.report = v();
else if (a === "--dry-run") o.dryRun = true;
else if (a === "--carrier") o.carriers.add(Number(v()));
else if (a === "--drop-fragment") o.dropFragments.add(v());
else if (a === "--credit") {
const [n, hs] = v().split("=");
o.credit[Number(n)] = hs.split(",").map((h) => h.replace(/^@/, ""));
} else if (a === "--fragment-pr") {
const [p, n] = v().split("=");
o.fragmentPr[p] = Number(n);
}
}
return o;
}
export function main(argv = process.argv.slice(2)) {
const o = parseArgs(argv);
const version =
o.version || JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const releaseBranch = o.releaseBranch || `release/v${version}`;
const head = o.head || "HEAD";
let base = o.base;
if (!base) {
const { base: open, source } = resolveCycleBase(version);
base = `${open}^`;
console.log(`[reconcile-changelog] base = parent of the ${source} commit ${open.slice(0, 10)}`);
}
const repo = repoSlug();
const rows = readCommits(base, head);
const prs = o.prs
? JSON.parse(fs.readFileSync(o.prs, "utf8"))
: fetchMergedPrs(repo, releaseBranch);
const known = new Set(prs.map((p) => p.number));
const extraPrs = new Map();
for (const n of new Set(rows.filter((r) => r.pr && !known.has(r.pr)).map((r) => r.pr))) {
const p = fetchPr(repo, n);
if (p) extraPrs.set(n, p);
}
const fragments = readFragments(head);
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
const baseTip = git(["rev-parse", "--short=10", base]);
const headTip = git(["rev-parse", "--short=10", head]);
// the cycle-open bump and the living-section restore are the only legitimate non-bullet commits
const skipHashes = new Set(
rows
.filter(
(r) =>
/^chore\(release\): (open v[\d.]+ development cycle|restore the living)/.test(
r.subject
) || /^Release v[\d.]+$/.test(r.subject)
)
.map((r) => r.hash.slice(0, 9))
);
const { changelog: next, report } = reconcile({
changelog,
version,
repo,
rows,
prs,
fragments,
extraPrs,
credits: o.credit,
carriers: o.carriers,
dropFragments: o.dropFragments,
fragmentPr: o.fragmentPr,
today: new Date().toISOString().slice(0, 10),
baseTip,
headTip,
skipHashes,
});
if (!o.dryRun) {
fs.writeFileSync(path.join(ROOT, "CHANGELOG.md"), next);
for (const f of fragments) {
try {
fs.unlinkSync(path.join(ROOT, f.path));
} catch {
/* already gone */
}
}
}
if (o.report) fs.writeFileSync(o.report, JSON.stringify(report, null, 1));
const { dropped, mismatches, dedupDropped, ranking, ...summary } = report;
console.log(`[reconcile-changelog] ${o.dryRun ? "(dry-run) " : ""}${JSON.stringify(summary)}`);
for (const d of dropped) console.log(` dropped: ${d.why}${d.first.slice(0, 90)}`);
for (const m of mismatches) console.log(` review: ${m}`);
for (const d of dedupDropped) console.log(` deduped: ${d.slice(0, 90)}`);
console.log(
`[reconcile-changelog] next: npm run release:contributors -- ${version} --inject && npx prettier --write CHANGELOG.md && npm run release:sync-changelog-i18n -- ${version} <prev> && npm run check:changelog-integrity`
);
return 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(main());
}

View File

@@ -189,3 +189,63 @@ test("SECTIONS maps every dir to a real living-section heading in the fixture",
assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`);
}
});
test("insertBullets with a version anchors on THAT section even when [Unreleased] has the same heading", () => {
const withUnreleasedHeading = `# Changelog
## [Unreleased]
### ✨ New Features
- **unreleased**: leftover from an older cycle (#9)
## [3.8.47] — TBD
### ✨ New Features
- **existing feature**: already here (#1 — thanks @a)
### 🐛 Bug Fixes
- **fix(x):** existing fix (#2 — thanks @b)
## [3.8.46] - 2026-07-04
### ✨ New Features
- **old feature**: shipped (#0)
`;
const out = insertBullets(
withUnreleasedHeading,
{ features: [{ text: "- **new**: landed (#4)" }] },
"3.8.47"
);
const unreleased = out.slice(out.indexOf("## [Unreleased]"), out.indexOf("## [3.8.47]"));
const living = out.slice(out.indexOf("## [3.8.47]"), out.indexOf("## [3.8.46]"));
assert.ok(!unreleased.includes("landed (#4)"), "must not land under [Unreleased]");
assert.ok(
living.includes("- **existing feature**: already here (#1 — thanks @a)\n- **new**: landed (#4)")
);
assert.throws(
() => insertBullets(withUnreleasedHeading, { features: [{ text: "- x" }] }, "9.9.9"),
/section "## \[9\.9\.9\]" not found/
);
});
test("aggregate reads the living version from package.json and lands fragments under it", () => {
const root = makeRoot({
fragments: { "features/9-new.md": "- **new**: from a fragment (#9)\n" },
});
writeFileSync(join(root, "package.json"), JSON.stringify({ version: "3.8.47" }));
const before = readFileSync(join(root, "CHANGELOG.md"), "utf8");
const withHeading = before.replace(
"## [Unreleased]\n",
"## [Unreleased]\n\n### ✨ New Features\n\n- stale (#8)\n"
);
writeFileSync(join(root, "CHANGELOG.md"), withHeading);
aggregate({ root });
const after = readFileSync(join(root, "CHANGELOG.md"), "utf8");
const unreleased = after.slice(after.indexOf("## [Unreleased]"), after.indexOf("## [3.8.47]"));
assert.ok(!unreleased.includes("from a fragment (#9)"));
assert.ok(after.slice(after.indexOf("## [3.8.47]")).includes("from a fragment (#9)"));
});

View File

@@ -0,0 +1,431 @@
import test from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../scripts/release/reconcile-changelog.mjs");
const {
primaryRefs,
parseBlocks,
addCredit,
appendLink,
bulletFromSubject,
sectionForType,
dedupeBullets,
computeCoverage,
versionSectionRange,
otherSectionsText,
rankAuthors,
reconcile,
} = mod;
const REPO = "diegosouzapw/OmniRoute";
const link = (n: number) => `[#${n}](https://github.com/${REPO}/pull/${n})`;
test("primaryRefs: /pull links and the LAST #N count, incidental mentions do not", () => {
const b = `- **test:** counts after additions — Opper #11629 + 1min.ai #11631 (${link(11674)}) — thanks @x`;
assert.deepEqual([...primaryRefs(b)].sort(), [11674]);
assert.deepEqual(
[...primaryRefs("- fix(x): thing (#11766) (#11794)")].sort(),
[11766, 11794],
"explicit (#N) groups count"
);
assert.deepEqual(
[...primaryRefs("- **security:** verbatim,\n across two lines (#12457)")],
[12457],
"last ref of a multi-line block"
);
assert.deepEqual([...primaryRefs("- plain sentence, no refs")], []);
});
test("parseBlocks: sections by heading, continuation lines kept; fragments collapse to one line", () => {
const text = `### ✨ New Features
- **feat(a):** one
continued here
### 🐛 Bug Fixes
- **fix(b):** two
- **fix(c):** three
`;
const p = parseBlocks(text);
assert.equal(p.features.length, 1);
assert.equal(p.features[0], "- **feat(a):** one\n continued here");
assert.deepEqual(p.fixes, ["- **fix(b):** two", "- **fix(c):** three"]);
const frag = parseBlocks("- **fix(d):** wrapped\n tail line\n", {
fixedSection: "fixes",
collapse: true,
});
assert.deepEqual(frag.fixes, ["- **fix(d):** wrapped tail line"]);
});
test("addCredit: appends, extends a trailing thanks group, skips maintainer and already-mentioned handles", () => {
assert.equal(addCredit("- x (#1)", ["alice"]), "- x (#1) — thanks @alice");
assert.equal(addCredit("- x (#1) — thanks @alice", ["bob"]), "- x (#1) — thanks @alice / @bob");
assert.equal(addCredit("- x (#1 — thanks @alice)", ["bob"]), "- x (#1 — thanks @alice / @bob)");
assert.equal(
addCredit("- x (#1) — thanks @alice", ["alice", "diegosouzapw"]),
"- x (#1) — thanks @alice"
);
assert.equal(
addCredit("- x by @alice-dev", ["alice"]),
"- x by @alice-dev — thanks @alice",
"handle boundary is exact"
);
});
test("appendLink: adds the PR link before a trailing thanks group and never twice", () => {
const once = appendLink("- x — thanks @alice", REPO, 12);
assert.equal(once, `- x (${link(12)}) — thanks @alice`);
assert.equal(appendLink(once, REPO, 12), once);
assert.equal(appendLink("- y", REPO, 3), `- y (${link(3)})`);
});
test("bulletFromSubject: bolds the conventional prefix, strips (#N) and URGENT, maps specials", () => {
assert.deepEqual(bulletFromSubject("fix(cli): thing (#11794)"), {
type: "fix",
text: "**fix(cli):** thing",
});
assert.deepEqual(bulletFromSubject("[URGENT] fix(dev): hot (#1)"), {
type: "fix",
text: "**fix(dev):** hot",
});
assert.deepEqual(bulletFromSubject("Update README.md (#5)"), {
type: "other",
text: "Update README.md",
});
assert.deepEqual(
bulletFromSubject("Update README.md (#5)", { "Update README.md": "docs(readme): touch-ups" }),
{
type: "docs",
text: "**docs(readme):** touch-ups",
}
);
assert.equal(sectionForType("feat"), "features");
assert.equal(sectionForType("perf"), "fixes");
assert.equal(sectionForType("docs"), "maintenance");
});
test("dedupeBullets: keeps the best-linked twin and merges the other twin's refs", () => {
const a = `- **fix(glm):** drop the extra arg (${link(12770)})`;
const b = "- **fix(glm):** drop the extra arg";
const c = `- **test(check):** escape the fixture (direct commit \`fb7445eaa1\`)`;
const d = `- **test(check):** escape the fixture (${link(11942)})`;
const { kept, dropped } = dedupeBullets([b, a, c, d]);
assert.deepEqual(dropped, [b, c]);
assert.equal(kept.length, 2);
assert.equal(kept[0], a);
assert.ok(
kept[1].includes(link(11942)) && kept[1].includes("direct commit `fb7445eaa1`"),
"merged the twin's hash"
);
});
test("computeCoverage: own PR must be a primary ref; incidental mention leaves the commit uncovered", () => {
const rows = [
{ hash: "aaaaaaaaa1", pr: 11631, refs: [11631], subject: "feat: 1min (#11631)" },
{ hash: "bbbbbbbbb2", pr: 11674, refs: [11674], subject: "test: counts (#11674)" },
{ hash: "ccccccccc3", pr: null, refs: [11449], subject: "fix: direct (#11449)" },
{ hash: "ddddddddd4", pr: 12000, refs: [12000], subject: "fix: closes issue (#12000)" },
{ hash: "eeeeeeeee5", pr: null, refs: [], subject: "chore(release): open cycle" },
{ hash: "f0f0f0f0f0abcdef", pr: null, refs: [], subject: "fix(ui): direct push" },
];
const bullets = [
`- **test:** counts — 1min.ai #11631 (${link(11674)})`,
"- **fix:** direct push fixing #11449",
"- **fix:** the issue bullet cites #7000 only",
"- **fix(ui):** direct push (direct commit `f0f0f0f0f0`)",
];
const { uncovered } = computeCoverage(rows, bullets, {
closingPrs: new Set([12000]), // PR 12000 closes issue 7000, which is cited
skipHashes: new Set(["eeeeeeeee"]),
});
assert.deepEqual(
uncovered.map((r) => r.hash),
["aaaaaaaaa1"],
"only the incidentally-mentioned PR is uncovered"
);
});
test("versionSectionRange / otherSectionsText isolate exactly one version block", () => {
const cl = `# Changelog
## [Unreleased]
### ✨ New Features
- old feature (#1)
## [3.8.51] — TBD
### 🐛 Bug Fixes
- **fix:** in section (#2)
---
## [3.8.50] — 2026-08-25
- shipped (#3)
`;
const r = versionSectionRange(cl, "3.8.51");
assert.ok(r && cl.slice(r.start, r.end).includes("in section (#2)"));
assert.ok(!cl.slice(r.start, r.end).includes("shipped (#3)"));
const other = otherSectionsText(cl, "3.8.51");
assert.ok(
other.includes("old feature (#1)") &&
other.includes("shipped (#3)") &&
!other.includes("in section (#2)")
);
assert.equal(versionSectionRange(cl, "9.9.9"), null);
});
test("rankAuthors: consolidates display-name drift by PR login, excludes bots", () => {
const rows = [
{ authorName: "Webman", prAuthor: "jonlwheat2-gif" },
{ authorName: "Webman", prAuthor: "jonlwheat2-gif" },
{ authorName: "WebPerson", prAuthor: "jonlwheat2-gif" },
{ authorName: "solo", prAuthor: null },
{ authorName: "dependabot[bot]", prAuthor: null },
];
const { top, counts } = rankAuthors(rows);
assert.deepEqual(top[0], ["Webman (@jonlwheat2-gif)", 3]);
assert.deepEqual(top[1], ["solo", 1]);
assert.equal("dependabot[bot]" in counts, false);
});
test("reconcile: folds fragments under the version section, credits by fragment origin, drops shipped twins, generates the rest", () => {
const changelog = `# Changelog
## [Unreleased]
### ✨ New Features
- **feat(old):** shipped last cycle but never moved (#9000)
## [3.8.51] — TBD
### ✨ New Features
### 🐛 Bug Fixes
- **security(streaming):** pre-existing bullet stays verbatim,
across two lines (#12457)
### 📝 Maintenance
---
## [3.8.50] — 2026-08-25
### 🐛 Bug Fixes
- **fix(cli):** \`omniroute update\` now finds npm on Windows. It called execFile with no shell (#11335)
---
`;
const rows = [
{
hash: "a0ceccc6f0aaaa",
date: "2026-08-25",
authorName: "Burak",
authorEmail: "b@x",
subject: "feat(search): add Xquik (#11370)",
refs: [11370],
pr: 11370,
coauthors: [],
},
{
hash: "b1b1b1b1b1bbbb",
date: "2026-08-26",
authorName: "Zero",
authorEmail: "z@x",
subject: "feat(providers): add 1min.ai provider (#11631)",
refs: [11631],
pr: 11631,
coauthors: [],
},
{
hash: "c2c2c2c2c2cccc",
date: "2026-08-26",
authorName: "diegosouzapw",
authorEmail: "d@x",
subject: "feat(models): recreated (#11887)",
refs: [11887],
pr: 11887,
coauthors: [],
},
{
hash: "d3d3d3d3d3dddd",
date: "2026-08-27",
authorName: "Markus Hartung",
authorEmail: "m@x",
subject: "fix(x): phantom fragment carrier (#11434)",
refs: [11434],
pr: 11434,
coauthors: [],
},
{
hash: "e4e4e4e4e4eeee",
date: "2026-08-27",
authorName: "dependabot[bot]",
authorEmail: "dep@x",
subject: "deps: bump a from 1 to 2 (#11500)",
refs: [11500],
pr: 11500,
coauthors: [],
},
{
hash: "f5f5f5f5f5ffff",
date: "2026-08-28",
authorName: "diegosouzapw",
authorEmail: "d@x",
subject: "chore(release): open v3.8.51 development cycle",
refs: [],
pr: null,
coauthors: [],
},
{
hash: "0606060606aaaa",
date: "2026-08-29",
authorName: "diegosouzapw",
authorEmail: "d@x",
subject: "fix(streaming): sanitize (#12457)",
refs: [12457],
pr: 12457,
coauthors: [],
},
];
const prs = [
{
number: 11631,
title: "1min",
author: { login: "zero-executioner" },
body: "",
closingIssuesReferences: [],
},
{
number: 11887,
title: "recreated",
author: { login: "diegosouzapw" },
body: "",
closingIssuesReferences: [],
},
{
number: 11434,
title: "carrier",
author: { login: "hartmark" },
body: "",
closingIssuesReferences: [],
},
{
number: 11500,
title: "bump",
author: { login: "app/dependabot" },
body: "",
closingIssuesReferences: [],
},
{
number: 12457,
title: "sanitize",
author: { login: "diegosouzapw" },
body: "",
closingIssuesReferences: [],
},
];
const fragments = [
// no-ref fragment whose origin commit is the contributor's own PR → link + credit from origin
{
path: "changelog.d/features/xquik.md",
section: "features",
text: "- **feat(search):** Add Xquik X search\n with typed results\n",
originHash: "a0ceccc6f",
originPr: 11370,
prefixPr: null,
},
// fragment named after the CLOSED PR (11685) but landed by the recreated PR 11887 → origin wins, extra credit applies
{
path: "changelog.d/features/11685-antigravity.md",
section: "features",
text: "- Default Antigravity connections to auto-sync\n",
originHash: "c2c2c2c2c",
originPr: 11887,
prefixPr: 11685,
},
// phantom fragment: text already shipped in [3.8.50] → dropped
{
path: "changelog.d/fixes/cli-update-npm-win32.md",
section: "fixes",
text: "- **fix(cli):** `omniroute update` now finds npm on Windows. It called execFile with no shell\n",
originHash: "d3d3d3d3d",
originPr: 11434,
prefixPr: null,
},
];
const { changelog: next, report } = reconcile({
changelog,
version: "3.8.51",
repo: REPO,
rows,
prs,
fragments,
credits: { 11887: ["MumuTW"] },
fragmentCredit: { "changelog.d/features/xquik.md": ["kriptoburak"] },
today: "2026-09-07",
baseTip: "0915890",
headTip: "d6f3150",
skipHashes: new Set(["f5f5f5f5f"]),
});
const section = next.slice(next.indexOf("## [3.8.51]"), next.indexOf("## [3.8.50]"));
// structure
assert.ok(
section.includes("### 📊 Release by the numbers") &&
section.includes("### 🏆 Top 25 contributors this cycle")
);
assert.ok(section.indexOf("### ✨ New Features") < section.indexOf("### 🐛 Bug Fixes"));
// [Unreleased] and [3.8.50] untouched
assert.ok(next.includes("- **feat(old):** shipped last cycle but never moved (#9000)"));
assert.equal(
(next.match(/now finds npm on Windows/g) || []).length,
1,
"phantom fragment dropped, original kept"
);
// pre-existing bullet verbatim (two lines)
assert.ok(
section.includes(
"- **security(streaming):** pre-existing bullet stays verbatim,\n across two lines (#12457)"
)
);
// fragment credited by origin + override, collapsed to one line
assert.ok(
section.includes(
`- **feat(search):** Add Xquik X search with typed results — thanks @kriptoburak`
),
section
);
// recreated PR: link to the landing PR, credit to the original author
assert.ok(
section.includes(
`- Default Antigravity connections to auto-sync (${link(11887)}) — thanks @MumuTW`
),
section
);
// uncovered commit generated with author credit; dependabot rolled up; cycle-open skipped
assert.ok(
section.includes(
`- **feat(providers):** add 1min.ai provider (${link(11631)}) — thanks @zero-executioner`
)
);
assert.ok(section.includes("- **deps:** 1 Dependabot bumps — bump a from 1 to 2"));
assert.ok(!section.includes("open v3.8.51 development cycle"));
// the carrier commit of the dropped phantom fragment is NOT silently covered → it gets its own bullet
assert.ok(
section.includes(`- **fix(x):** phantom fragment carrier (${link(11434)}) — thanks @hartmark`)
);
assert.equal(report.dropped.length, 1);
assert.equal(report.generated.features, 1);
assert.equal(
report.generated.fixes,
1,
"12457's own commit is covered by the pre-existing multi-line bullet; only 11434 is generated"
);
});