diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 82cdff6b3a..f1d4d26c98 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f296912f6..d4fd967321 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//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 diff --git a/scripts/changelog-history-guard.test.ts b/scripts/changelog-history-guard.test.ts new file mode 100644 index 0000000000..ce23011e22 --- /dev/null +++ b/scripts/changelog-history-guard.test.ts @@ -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"); + }); +}); diff --git a/scripts/changelog-history-guard.ts b/scripts/changelog-history-guard.ts new file mode 100644 index 0000000000..bae3e389ee --- /dev/null +++ b/scripts/changelog-history-guard.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env bun + +/** + * Guard: a pull request must not remove released CHANGELOG history. + * + * Why this exists + * --------------- + * `packages//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 `## []` heading present in a `packages//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 { + const result = await $`git show ${`${rev}:${file}`}`.quiet().nothrow(); + return result.exitCode === 0 ? result.text() : undefined; +} + +async function changedChangelogs(base: string, head: string): Promise { + 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 { + 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 { + 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=, 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 { + 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))); +}