---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/changelog.md
description: >-
  The changelog check — a pull request that changes a package's public surface
  adds a changelog fragment and, where the repository keeps one, a migration
  fragment. Covers fragment discovery, the two layouts, the filename convention,
  and the bypass.
---

# `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 {#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](#breaking).

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`](./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 {#packages}

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](#breaking).

The reusable workflow runs it on pull requests as the `CHANGELOG + MIGRATIONS touched` job, which
the [`gates` input](/reference/workflow#inputs) names `changelog`. Run it from the CLI, naming the
pull request's base:

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

## Marking a breaking change {#breaking}

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.
