feat(release): changelog.d/ fragments — eliminate the CHANGELOG merge-storm cascade (#6783)

* feat(release): changelog.d/ fragments — kill the CHANGELOG-eat merge-storm cascade

Every PR used to edit the same top lines of CHANGELOG.md (its bullet), so 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^2) CI runs.

A PR now adds ONE new file under changelog.d/{features|fixes|maintenance}/ with its
bullet; two PRs never touch the same file. scripts/release/aggregate-changelog.mjs
(npm run changelog:aggregate) folds fragments into the living section and deletes
them at release reconciliation. check:changelog-integrity (already wired in the
merge-integrity CI job — zero workflow change) now also validates fragment
well-formedness. This PR dogfoods the convention: its own entry is a fragment.

* chore(changelog): fragment filename matches PR number (#6783)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 07:22:49 -03:00
committed by GitHub
parent 1bc6da5318
commit 1ddb102a90
10 changed files with 453 additions and 2 deletions

View File

@@ -341,7 +341,7 @@ Write unit tests in `tests/unit/` covering at minimum:
- [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md))
- [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation
- [ ] All inputs validated with Zod schemas
- [ ] CHANGELOG updated (if user-facing change)
- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs
- [ ] Documentation updated (if applicable)
- [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc
- [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md)

42
changelog.d/README.md Normal file
View File

@@ -0,0 +1,42 @@
# changelog.d/ — changelog fragments
**A PR never edits `CHANGELOG.md` directly during the cycle.** Instead it adds ONE new
file here — its changelog entry as a *fragment*. Two PRs never touch the same file, so
changelog merge conflicts (the "CHANGELOG-eat" cascade that forced a re-sync push + full
CI re-run after every sibling merge) are structurally impossible.
## Convention
| Directory | Aggregates under |
| -------------- | ----------------------- |
| `features/` | `### ✨ New Features` |
| `fixes/` | `### 🐛 Bug Fixes` |
| `maintenance/` | `### 📝 Maintenance` |
- **Filename**: `<PR-number>-<short-slug>.md` (e.g. `fixes/6700-dockerfile-better-sqlite3.md`).
The PR number prefix keeps aggregation order deterministic.
- **Content**: the exact bullet line(s) that should land in `CHANGELOG.md`, starting with
`- `. Multi-line (continuation) bullets are fine. Keep the repo's credit format:
`(#PR — thanks @user)`.
- One fragment per PR (rarely more, e.g. a PR that both fixes and adds).
## Example
`changelog.d/fixes/6496-cloudflare-relay-worker-syntax.md`:
```markdown
- **fix(providers):** Cloudflare relay Worker deploys use Service Worker syntax with `body_part` metadata ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)) — thanks @SeaXen
```
## Aggregation
The release captain (or `/generate-release`) folds all fragments into `CHANGELOG.md` and
deletes them:
```bash
node scripts/release/aggregate-changelog.mjs # write + delete fragments
node scripts/release/aggregate-changelog.mjs --dry-run # preview only
```
Fragment well-formedness is enforced by `npm run check:changelog-integrity` (the same
gate that guards against CHANGELOG-eat for legacy direct edits).

View File

View File

@@ -0,0 +1 @@
- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md`) instead of editing `CHANGELOG.md` — two PRs never touch the same file, structurally eliminating the CHANGELOG-eat merge conflicts that forced a re-sync push + full CI re-run after every sibling merge (O(N²) CI runs in a merge-storm). `scripts/release/aggregate-changelog.mjs` (npm run changelog:aggregate) folds fragments into the living section at release reconciliation, and `check:changelog-integrity` now also validates fragment well-formedness. Regression guard: `tests/unit/changelog-fragments.test.ts`.

View File

View File

View File

@@ -144,6 +144,7 @@
"check:test-masking": "node scripts/check/check-test-masking.mjs",
"check:test-runner-api": "node scripts/check/check-test-runner-api.mjs",
"check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs",
"changelog:aggregate": "node scripts/release/aggregate-changelog.mjs",
"check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs",
"check:build-scope": "node scripts/check/check-build-scope.mjs",
"check:error-helper": "node scripts/check/check-error-helper.mjs",

View File

@@ -26,12 +26,15 @@
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const CHANGELOG = "CHANGELOG.md";
const FRAGMENTS_DIR = "changelog.d";
const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"];
const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]);
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
@@ -56,6 +59,49 @@ export function findLostBullets(baseText, headText) {
return lost;
}
/**
* Validate changelog FRAGMENTS (changelog.d/<section>/*.md — see changelog.d/README.md).
* A fragment must be a well-formed markdown bullet ("- ...") with no merge-conflict
* markers, and must live in a known section dir. Returns [{file, error}]. Pure over
* the filesystem — unit-tested via a tmp root.
*/
export function findInvalidFragments(root = ROOT) {
const invalid = [];
const base = join(root, FRAGMENTS_DIR);
if (!existsSync(base)) return invalid;
const entries = readdirSync(base, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
if (!FRAGMENT_SKIP.has(entry.name)) {
invalid.push({
file: `${FRAGMENTS_DIR}/${entry.name}`,
error: `fragments live in a section dir (${FRAGMENT_SECTIONS.join("|")}), not at changelog.d root`,
});
}
continue;
}
if (!FRAGMENT_SECTIONS.includes(entry.name)) {
invalid.push({
file: `${FRAGMENTS_DIR}/${entry.name}/`,
error: `unknown section dir (expected ${FRAGMENT_SECTIONS.join("|")})`,
});
continue;
}
for (const f of readdirSync(join(base, entry.name))) {
if (FRAGMENT_SKIP.has(f) || !f.endsWith(".md")) continue;
const file = `${FRAGMENTS_DIR}/${entry.name}/${f}`;
const text = readFileSync(join(base, entry.name, f), "utf8");
const firstContent = text.split("\n").find((l) => l.trim().length > 0);
if (!firstContent) invalid.push({ file, error: "empty fragment" });
else if (!firstContent.trimStart().startsWith("- "))
invalid.push({ file, error: 'fragment must start with a markdown bullet ("- ")' });
else if (/^(<{7}|={7}|>{7})/m.test(text))
invalid.push({ file, error: "fragment contains merge-conflict markers" });
}
}
return invalid;
}
function git(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
@@ -77,6 +123,16 @@ function resolveBaseRef() {
}
function main() {
// Fragment well-formedness first (changelog.d/ — the fragments pattern makes the
// eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md).
const invalidFragments = findInvalidFragments();
if (invalidFragments.length > 0) {
console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`);
for (const { file, error } of invalidFragments) console.error(`${file}: ${error}`);
console.error("\nSee changelog.d/README.md for the fragment convention.");
return 1;
}
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");

View File

@@ -0,0 +1,167 @@
#!/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"]);
/**
* 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). 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).
*/
export function insertBullets(changelogText, bulletsBySection) {
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);
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 } = {}) {
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);
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());
}

View File

@@ -0,0 +1,184 @@
// Guards the changelog FRAGMENTS pipeline (changelog.d/ → CHANGELOG.md), adopted
// 2026-07-09 to kill the CHANGELOG-eat / DIRTY merge-storm cascade: a PR adds ONE new
// file under changelog.d/<section>/ instead of editing CHANGELOG.md, so sibling PRs
// never conflict. Covers the aggregator (scripts/release/aggregate-changelog.mjs) and
// the fragment validation wired into the merge-integrity gate
// (scripts/check/check-changelog-integrity.mjs::findInvalidFragments).
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const { SECTIONS, validateFragmentText, collectFragments, insertBullets, aggregate } =
await import("../../scripts/release/aggregate-changelog.mjs");
const { findInvalidFragments } = await import("../../scripts/check/check-changelog-integrity.mjs");
const CHANGELOG_FIXTURE = `# Changelog
## [Unreleased]
## [3.8.47] — TBD
_Living section — bullets land here as PRs merge._
### ✨ New Features
- **existing feature**: already here (#1 — thanks @a)
### 🐛 Bug Fixes
- **fix(x):** existing fix (#2 — thanks @b)
### 📝 Maintenance
- chore: existing maintenance (#3)
## [3.8.46] - 2026-07-04
### ✨ New Features
- **old feature**: shipped (#0)
`;
function makeRoot({ fragments = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), "chfrag-"));
writeFileSync(join(root, "CHANGELOG.md"), CHANGELOG_FIXTURE);
mkdirSync(join(root, "changelog.d"), { recursive: true });
for (const [rel, text] of Object.entries(fragments)) {
const abs = join(root, "changelog.d", rel);
mkdirSync(join(abs, ".."), { recursive: true });
writeFileSync(abs, text);
}
return root;
}
test("validateFragmentText accepts a bullet and rejects garbage", () => {
assert.equal(validateFragmentText("- **fix:** ok (#9 — thanks @x)"), null);
assert.equal(validateFragmentText("- multi\n continuation line"), null);
assert.match(validateFragmentText(""), /empty/);
assert.match(validateFragmentText("not a bullet"), /must start/);
assert.match(validateFragmentText("- ok\n<<<<<<< HEAD"), /conflict markers/);
});
test("collectFragments reads sections sorted and flags invalid files", () => {
const root = makeRoot({
fragments: {
"fixes/6700-b.md": "- fix B (#6700)",
"fixes/6496-a.md": "- fix A (#6496)",
"features/6728-chaos.md": "- feat chaos (#6728)",
"features/bad.md": "no bullet here",
},
});
const c = collectFragments(root);
assert.deepEqual(
c.fixes.map((f) => f.text),
["- fix A (#6496)", "- fix B (#6700)"]
);
assert.equal(c.features.length, 1);
assert.equal(c.invalid.length, 1);
assert.match(c.invalid[0].file, /bad\.md/);
rmSync(root, { recursive: true, force: true });
});
test("insertBullets appends at the END of each living section", () => {
const out = insertBullets(CHANGELOG_FIXTURE, {
features: [{ text: "- NEW feature bullet (#10)" }],
fixes: [{ text: "- NEW fix bullet (#11)" }],
maintenance: [{ text: "- NEW maintenance bullet (#12)" }],
});
const lines = out.split("\n");
const featIdx = lines.indexOf("- NEW feature bullet (#10)");
const bugHeadIdx = lines.indexOf("### 🐛 Bug Fixes");
const fixIdx = lines.indexOf("- NEW fix bullet (#11)");
const maintHeadIdx = lines.indexOf("### 📝 Maintenance");
const maintIdx = lines.indexOf("- NEW maintenance bullet (#12)");
// Each new bullet lands after its own existing bullets, before the next heading.
assert.ok(featIdx > lines.indexOf("- **existing feature**: already here (#1 — thanks @a)"));
assert.ok(featIdx < bugHeadIdx, "feature bullet must stay inside the features section");
assert.ok(fixIdx > bugHeadIdx && fixIdx < maintHeadIdx);
assert.ok(maintIdx > maintHeadIdx && maintIdx < lines.indexOf("## [3.8.46] - 2026-07-04"));
// Only the FIRST (living) occurrence of a heading is touched — the shipped 3.8.46
// section is byte-identical.
assert.ok(out.includes("## [3.8.46] - 2026-07-04\n\n### ✨ New Features\n\n- **old feature**: shipped (#0)"));
// No existing bullet lost.
for (const existing of ["#1 — thanks @a", "existing fix (#2", "existing maintenance (#3"]) {
assert.ok(out.includes(existing));
}
});
test("insertBullets throws when a needed heading is missing", () => {
const noMaint = CHANGELOG_FIXTURE.replace("### 📝 Maintenance\n\n- chore: existing maintenance (#3)\n", "");
assert.throws(
() => insertBullets(noMaint, { maintenance: [{ text: "- x" }] }),
/📝 Maintenance.*not found/s
);
});
test("aggregate dry-run touches nothing; real run writes and deletes fragments", () => {
const root = makeRoot({
fragments: { "fixes/6800-real.md": "- real aggregated fix (#6800 — thanks @c)" },
});
const dry = aggregate({ root, dryRun: true });
assert.equal(dry.total, 1);
assert.ok(!readFileSync(join(root, "CHANGELOG.md"), "utf8").includes("#6800"));
assert.ok(existsSync(join(root, "changelog.d/fixes/6800-real.md")));
const real = aggregate({ root });
assert.equal(real.total, 1);
const after = readFileSync(join(root, "CHANGELOG.md"), "utf8");
assert.ok(after.includes("- real aggregated fix (#6800 — thanks @c)"));
assert.ok(!existsSync(join(root, "changelog.d/fixes/6800-real.md")), "fragment must be deleted");
// Idempotence: nothing left → second run is a no-op.
const again = aggregate({ root });
assert.equal(again.total, 0);
assert.equal(readFileSync(join(root, "CHANGELOG.md"), "utf8"), after);
rmSync(root, { recursive: true, force: true });
});
test("aggregate refuses invalid fragments loudly", () => {
const root = makeRoot({ fragments: { "features/oops.md": "forgot the dash" } });
assert.throws(() => aggregate({ root }), /invalid changelog fragments/);
rmSync(root, { recursive: true, force: true });
});
test("gate findInvalidFragments: clean tree passes, bad placement/content fail", () => {
const clean = makeRoot({ fragments: { "maintenance/1-ok.md": "- ok (#1)" } });
assert.deepEqual(findInvalidFragments(clean), []);
rmSync(clean, { recursive: true, force: true });
const dirty = makeRoot({
fragments: {
"stray.md": "- misplaced at root",
"unknown-section/2-x.md": "- wrong dir",
"fixes/3-bad.md": "missing dash",
},
});
const invalid = findInvalidFragments(dirty);
const files = invalid.map((i) => i.file).sort();
assert.equal(invalid.length, 3);
assert.ok(files.some((f) => f.includes("stray.md")));
assert.ok(files.some((f) => f.includes("unknown-section")));
assert.ok(files.some((f) => f.includes("3-bad.md")));
rmSync(dirty, { recursive: true, force: true });
});
test("gate skips README.md and .gitkeep; absent changelog.d is fine", () => {
const root = makeRoot();
writeFileSync(join(root, "changelog.d/README.md"), "# docs, not a fragment");
mkdirSync(join(root, "changelog.d/fixes"), { recursive: true });
writeFileSync(join(root, "changelog.d/fixes/.gitkeep"), "");
assert.deepEqual(findInvalidFragments(root), []);
rmSync(root, { recursive: true, force: true });
const bare = mkdtempSync(join(tmpdir(), "chfrag-bare-"));
assert.deepEqual(findInvalidFragments(bare), []);
rmSync(bare, { recursive: true, force: true });
});
test("SECTIONS maps every dir to a real living-section heading in the fixture", () => {
for (const heading of Object.values(SECTIONS)) {
assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`);
}
});