Skip to content

changelog ​

A pull request that changes a package's public surface adds a fragment recording the change. This page is the complete record of the check: why fragments replace a shared file, how the repository's layout is discovered, what the check enforces, and its configuration surface.

Why this check exists ​

A single CHANGELOG.md that every pull request appends to at the same anchor conflicts by construction. Two branches touching one package both edit the same line range, so the second to merge resolves a conflict it had no part in creating — a cost that scales with how many people are shipping at once, which is exactly when it hurts most.

A fragment is a new file, so two pull requests against one package add two different paths and the merge is clean. The release step collects the fragments into the published notes; the working tree holds the record in between.

What it enforces ​

For every package whose public surface the pull request changed:

  • One added file under that package's changelog.d/.
  • One added file under that package's migrations.d/, when a commit in the pull request carries a breaking: line.

A file that was modified doesn't satisfy the check — the gate reads the diff filtered to additions, because a fragment satisfies it only when the pull request writes a new one.

The filename convention ​

YYYY-MM-DD-<slug>.md — the UTC merge date, then a kebab-case slug of lowercase letters, digits, and hyphens. A fragment whose name doesn't match is reported against its own path, so the annotation lands on the offending file.

The date prefix sorts the directory chronologically, which is what lets the release step read the fragments in order. Under the per-package layout the directory names the package, so the slug is free text.

Under the pooled layout one directory holds every package's fragments, so the slug opens with the package: YYYY-MM-DD-<pkg>-<slug>.md, where <pkg> is the directory name of the package whose surface changed — 2026-09-21-parser-drop-legacy-flag.md pays for packages/parser. Both kinds follow it, and a fragment naming another package leaves this one unpaid. A pooled repository with no discoverable package root has no package name to carry, so there any well-formed name pays.

What a package may change without owing a fragment ​

A path is exempt when it isn't public surface:

  • The frozen CHANGELOG.md / MIGRATIONS.md archive — the record a fragment replaces.
  • The fragment directories themselves.
  • e2e-attestations/ — CI freshness receipts, written by automation.
  • Test files, in the shapes colocated-test already derives for the package's language.
  • Anything outside every package — see What counts as a package below.

What counts as a package ​

Under the per-package layout the fragment directory answers it: a package is the directory holding changelog.d/.

Under the pooled layout the fragment directories sit outside every package and say nothing about where the packages are, so the check discovers them. A package root is a directory inside a container directory holding a manifest — packages/parser/package.json, crates/lexer/Cargo.toml, packages/engine/pyproject.toml — the same manifests the other checks walk to when they resolve a package root. Public surface is what sits under one, so a root README.md, a docs/ page, or repository tooling outside every package owes no fragment.

A repository where no package root is discoverable is treated as one package at its root: every non-exempt path is public surface.

The two layouts ​

The check derives its shape from where the fragment directories are, so the layout is a fact about the repository rather than something you declare.

  • Per package. packages/<pkg>/changelog.d/ — the fragment's owning package is its parent directory, and the check reports one finding per package that owes one.
  • Pooled. A single changelog.d/ outside any package — every package's fragments share one directory. The check reports one finding per package that owes one, as the per-package layout does; the fragment's name says which package it pays for, where the per-package layout's directory does.

When it runs ​

When the repository holds at least one fragment directory; skipped, never failed otherwise, so the drop-in is safe on a repository that doesn't keep a changelog this way.

Migration fragments are enforced for the pull requests that mark themselves breaking — see Marking a breaking change.

The reusable workflow runs it on pull requests as the CHANGELOG + MIGRATIONS touched job, which the gates input names changelog. Run it from the CLI, naming the pull request's base:

sh
npx testing-conventions changelog --base "$BASE"

Marking a breaking change ​

A breaking: <summary> line on any commit in the pull request requires a migrations fragment beside the changelog fragment, for every package the pull request owes fragments for. The line is read at the start of a line, in any commit body, whatever its case — the same way the bypass line below is — so it works from a commit whose body already carries other text.

The signal is opt-in by design: the author of the change marks the breakage, and a pull request whose commits carry no breaking: line owes a changelog fragment alone. That is the trade for a check that reads no configuration — a repository keeping a migrations.d/ records the breaking subset of its history, which says nothing about the pull request in front of the check, so the pull request states the fact itself.

A migrations fragment the pull request adds without the line is allowed: the line makes a migrations fragment required, never forbidden.

The bypass ​

A skip-changelog: <reason> line on any commit in the pull request bypasses the check, for a change that is genuinely an internal refactor. The line is matched anywhere in any commit body rather than parsed as a git trailer, so it works from a commit whose body already carries other text.

The reason is required, and it is read by whoever reviews the pull request — the check records that a human made the call, it doesn't judge the reason.

Configuration ​

The check reads no configuration. The fragment directories are discovered, the filename convention is fixed, and whether migrations are enforced follows from the pull request's own commits.

The check honors no exemption rules — a package's public surface either got a fragment or it didn't.