Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ jobs:
- uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly
with:
toolchain: nightly-2026-04-29
# Released CHANGELOG sections are append-only. These files have no
# `merge=union` driver, so a rebase conflicts here for real and a bad
# resolution can silently drop the whole file — which is exactly what
# happened to ten open PRs across six authors within ten minutes of the
# driver being removed. Runs in affected-plan because it already has
# full history and the immutable event base sha.
- name: Guard released CHANGELOG history
if: ${{ github.event_name == 'pull_request' }}
run: bun scripts/changelog-history-guard.ts
- name: Compute changed-path relevance
id: relevance
run: bun scripts/ci-job-relevance.ts
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Use focused tests first for code changes, then broader checks when the change af

**`packages/*/CHANGELOG.md` conflicts are normal.** These files have no custom merge driver: if your branch and `dev` both added entries under `## [Unreleased]`, git reports a real conflict. Resolve it by keeping **both** entries under `## [Unreleased]`. Never move an entry into a released `## [X.Y.Z]` section, and never edit a released section — that version already shipped and its notes are historical record.

Resolving one of these by emptying the file is a real hazard, not a hypothetical: ten pull requests across six authors did exactly that within ten minutes of the merge driver being removed, each leaving a one-byte changelog with every released section gone. CI now fails a PR that removes any `## [X.Y.Z]` heading (`scripts/changelog-history-guard.ts`), but check before you push:

```sh
git cat-file -s HEAD:packages/coding-agent/CHANGELOG.md # expect ~300 KB, not 1
```

If it is already lost, recover with `git checkout origin/dev -- packages/<pkg>/CHANGELOG.md` and re-add only your own entry.

**`packages/coding-agent/src/internal-urls/docs-index.generated.ts` is generated and untracked.** `bun install` rebuilds it through the root `prepare` hook, and `bun run generate-docs-index` rebuilds it on demand. Do not commit it. If you see it in `git status`, something forced it back into the index — `git rm --cached` it. A tracked copy inlines every doc onto a single line, which git cannot three-way merge, so it conflicts on every rebase.

## PR checklist
Expand Down
101 changes: 101 additions & 0 deletions scripts/changelog-history-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { compareHistory, formatViolation, releaseHeadings } from "./changelog-history-guard";

const FULL = `# Changelog

## [Unreleased]

### Fixed

- Something new.

## [0.12.12] - 2026-08-05

### Fixed

- An older fix.

## [0.12.11] - 2026-08-03

### Added

- An even older feature.
`;

describe("releaseHeadings", () => {
test("collects released versions and skips Unreleased", () => {
expect(releaseHeadings(FULL)).toEqual(["0.12.12", "0.12.11"]);
});

test("treats Unreleased case-insensitively", () => {
expect(releaseHeadings("## [unreleased]\n## [1.0.0] - 2026-01-01\n")).toEqual(["1.0.0"]);
});

test("returns nothing for an emptied file", () => {
// The exact shape produced by the bad rebase resolutions this guard exists for.
expect(releaseHeadings("\n")).toEqual([]);
});
});

describe("compareHistory", () => {
test("passes when an entry is added under Unreleased", () => {
const head = FULL.replace("- Something new.", "- Something new.\n- Something newer.");
expect(compareHistory("packages/x/CHANGELOG.md", FULL, head)).toBeUndefined();
});

test("passes when a release commit consumes Unreleased into a new version", () => {
const head = FULL.replace("## [Unreleased]\n", "## [Unreleased]\n\n## [0.12.13] - 2026-08-06\n");
expect(compareHistory("packages/x/CHANGELOG.md", FULL, head)).toBeUndefined();
});

test("catches a fully emptied changelog", () => {
const violation = compareHistory("packages/coding-agent/CHANGELOG.md", FULL, "\n");
expect(violation).toBeDefined();
expect(violation?.removed).toEqual(["0.12.12", "0.12.11"]);
expect(violation?.baseHeadingCount).toBe(2);
expect(violation?.headHeadingCount).toBe(0);
});

test("catches a single dropped released section", () => {
const head = FULL.replace("## [0.12.11] - 2026-08-03\n\n### Added\n\n- An even older feature.\n", "");
const violation = compareHistory("packages/x/CHANGELOG.md", FULL, head);
expect(violation?.removed).toEqual(["0.12.11"]);
});

test("ignores a file absent on either side", () => {
expect(compareHistory("packages/x/CHANGELOG.md", undefined, FULL)).toBeUndefined();
expect(compareHistory("packages/x/CHANGELOG.md", FULL, undefined)).toBeUndefined();
});

test("does not flag reordering or rewording that keeps every version", () => {
const head = FULL.replace("- An older fix.", "- An older fix, reworded.");
expect(compareHistory("packages/x/CHANGELOG.md", FULL, head)).toBeUndefined();
});
});

describe("formatViolation", () => {
test("names the recovery command and the counts", () => {
const message = formatViolation({
file: "packages/coding-agent/CHANGELOG.md",
removed: ["0.12.12", "0.12.11"],
baseHeadingCount: 2,
headHeadingCount: 0,
});
expect(message).toContain("removes 2 released section(s): 0.12.12, 0.12.11");
expect(message).toContain("Base had 2 released headings, this head has 0");
expect(message).toContain("git checkout");
expect(message).toContain("packages/coding-agent/CHANGELOG.md");
});

test("summarizes instead of listing every version when many are lost", () => {
const removed = Array.from({ length: 12 }, (_, index) => `0.1.${index}`);
const message = formatViolation({
file: "packages/x/CHANGELOG.md",
removed,
baseHeadingCount: 12,
headHeadingCount: 0,
});
expect(message).toContain("(+4 more)");
expect(message).not.toContain("0.1.11");
});
});
156 changes: 156 additions & 0 deletions scripts/changelog-history-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env bun

/**
* Guard: a pull request must not remove released CHANGELOG history.
*
* Why this exists
* ---------------
* `packages/<pkg>/CHANGELOG.md` deliberately has no `merge=union` driver (see the
* comment in `.gitattributes`): union never conflicts, it concatenates both
* sides of an overlapping hunk, which silently filed entries into versions that
* had already shipped. Removing the driver was correct, but it also means a
* rebase now produces a *real* conflict in these files for the first time — and
* a bad resolution can drop the file's entire history without any marker.
*
* That is not hypothetical. Within ten minutes of the driver being removed, ten
* open pull requests across six authors force-pushed heads whose CHANGELOG was
* a single newline, having lost every released section. Nothing in CI noticed:
* the files still parsed, no test read them, and the diff was just a large
* deletion among a legitimate change.
*
* Contract
* --------
* Every `## [<version>]` heading present in a `packages/<pkg>/CHANGELOG.md` at the
* merge base must still be present at the head. Adding headings is fine.
* Editing entry text is fine. Removing a released section is not.
*
* `## [Unreleased]` is exempt in one direction only: it may disappear, because
* a release commit legitimately consumes it. It may not take released sections
* with it.
*/

import { $ } from "bun";

/** `## [1.2.3] - 2026-01-01` or `## [Unreleased]`. */
const RELEASE_HEADING = /^##\s+\[([^\]]+)\]/;
const UNRELEASED = "unreleased";
/** Only package changelogs carry release history worth guarding. */
const GUARDED_PATH = /^packages\/[^/]+\/CHANGELOG\.md$/;

export interface ChangelogHistoryViolation {
file: string;
/** Release headings present at the base and missing at the head. */
removed: string[];
baseHeadingCount: number;
headHeadingCount: number;
}

/** Released version headings, in file order, excluding `[Unreleased]`. */
export function releaseHeadings(text: string): string[] {
const headings: string[] = [];
for (const line of text.split("\n")) {
const match = RELEASE_HEADING.exec(line);
if (!match) continue;
const version = match[1] ?? "";
if (version.trim().toLowerCase() === UNRELEASED) continue;
headings.push(version.trim());
}
return headings;
}

/**
* Compare one changelog across a range.
*
* A file that did not exist at the base cannot have lost history, and a file
* deleted at the head is a different review conversation (and is visible in the
* diff as a deletion), so both return no violation here.
*/
export function compareHistory(
file: string,
baseText: string | undefined,
headText: string | undefined,
): ChangelogHistoryViolation | undefined {
if (baseText === undefined || headText === undefined) return undefined;
const before = releaseHeadings(baseText);
const after = new Set(releaseHeadings(headText));
const removed = before.filter(version => !after.has(version));
if (removed.length === 0) return undefined;
return { file, removed, baseHeadingCount: before.length, headHeadingCount: after.size };
}

async function gitShow(rev: string, file: string): Promise<string | undefined> {
const result = await $`git show ${`${rev}:${file}`}`.quiet().nothrow();
return result.exitCode === 0 ? result.text() : undefined;
}

async function changedChangelogs(base: string, head: string): Promise<string[]> {
const result = await $`git diff --name-only ${base} ${head}`.quiet().nothrow();
if (result.exitCode !== 0) {
throw new Error(`git diff ${base}..${head} failed: ${result.stderr.toString().trim()}`);
}
return result
.text()
.split("\n")
.map(line => line.trim())
.filter(line => GUARDED_PATH.test(line));
}

export async function collectViolations(base: string, head: string): Promise<ChangelogHistoryViolation[]> {
const violations: ChangelogHistoryViolation[] = [];
for (const file of await changedChangelogs(base, head)) {
const [baseText, headText] = await Promise.all([gitShow(base, file), gitShow(head, file)]);
const violation = compareHistory(file, baseText, headText);
if (violation) violations.push(violation);
}
return violations;
}

async function resolveBase(explicit: string | undefined): Promise<string> {
if (explicit) return explicit;
const fromEnv = process.env.GITHUB_BASE_SHA?.trim();
if (fromEnv) return fromEnv;
const mergeBase = await $`git merge-base HEAD origin/dev`.quiet().nothrow();
if (mergeBase.exitCode !== 0) {
throw new Error("no base: pass --base=<sha>, set GITHUB_BASE_SHA, or fetch origin/dev");
}
return mergeBase.text().trim();
}

function readFlag(argv: string[], name: string): string | undefined {
const prefix = `--${name}=`;
const inline = argv.find(arg => arg.startsWith(prefix));
if (inline) return inline.slice(prefix.length);
const index = argv.indexOf(`--${name}`);
return index >= 0 ? argv[index + 1] : undefined;
}

/** At most this many removed versions are listed before the message summarizes. */
const MAX_LISTED = 8;

export function formatViolation(violation: ChangelogHistoryViolation): string {
const listed = violation.removed.slice(0, MAX_LISTED).join(", ");
const more = violation.removed.length > MAX_LISTED ? ` (+${violation.removed.length - MAX_LISTED} more)` : "";
return (
`${violation.file} removes ${violation.removed.length} released section(s): ${listed}${more}. ` +
`Base had ${violation.baseHeadingCount} released headings, this head has ${violation.headHeadingCount}. ` +
`Released history is append-only. If a rebase conflicted here, resolve it by keeping BOTH sides' entries ` +
`under "## [Unreleased]" — never by dropping released sections. Recover with: ` +
`git checkout ${process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/dev"} -- ${violation.file}`
);
}

export async function main(argv: string[]): Promise<number> {
const base = await resolveBase(readFlag(argv, "base"));
const head = readFlag(argv, "head") ?? "HEAD";
const violations = await collectViolations(base, head);
if (violations.length === 0) {
console.log(`changelog-history-guard: no released sections removed (${base.slice(0, 12)}..${head})`);
return 0;
}
for (const violation of violations) console.error(`::error file=${violation.file}::${formatViolation(violation)}`);
return 1;
}

if (import.meta.main) {
process.exit(await main(process.argv.slice(2)));
}
Loading