Skip to content

Release tooling: changelog.d fragments assembled by the npm version commit, plus a CI guard so no PR edits CHANGELOG.md #2877

Description

@thymikee

Purpose

Every PR that changes behavior adds its bullet at the same place: the line after ## Unreleased in
CHANGELOG.md (origin/main 6debef0634, CHANGELOG.md:3). Two open PRs that each add a bullet
there conflict with each other. The first one to merge forces the other to rebase for a
CHANGELOG-only conflict, even when the code does not overlap at all.

Evidence (checked at 6debef0634):

A second defect lives in the same file. No release cuts ## Unreleased into a version section.
## Unreleased runs from line 3 to line 896 and holds 138 bullets. The next heading is ## 0.15.0
(CHANGELOG.md:897). Tags v0.15.1 through v0.21.12 each exist, and each release commit touches only
package.json and server.json. For example, git show --stat v0.21.12 lists those two files
only. The release commit comes from npm version, which runs the version lifecycle script
(package.json:169, pnpm sync:mcp-metadata && git add server.json). So CHANGELOG.md cannot
tell which release shipped which change.

The fix is to have no shared insertion point. Each PR adds its own new file. The release version
commit, which already runs a hook on every release, folds those files into a version section.

Fragments alone do not close the bug class. After the migration, the top of CHANGELOG.md is
## 0.21.1x. docs/agents/pull-requests.md says nothing about CHANGELOG.md today, so agents
learn the habit from the file itself. An agent that keeps editing CHANGELOG.md adds its bullet at
the top of that released section. That is the same shared insertion point, so the conflicts come
back, and the bullet also names the wrong release. So the rule is: no pull_request diff
modifies CHANGELOG.md.
Only the npm version release commit writes it. That commit is pushed
straight to main and is never a PR (for example 01328de411 0.21.12 has no associated PR). CI
enforces the rule.

Required behavior

Fragment files

  • A PR with a user-visible change adds changelog.d/<slug>.md. <slug> matches
    ^[a-z0-9][a-z0-9-]*$. By convention it is <issue-number>-<short-kebab>, taken from the branch
    name (for example changelog.d/2799-macos-fullscreen-surfaces.md). The PR number is not known
    before the PR opens, so it is not required in the name.

  • changelog.d/README.md documents the format. It is the only non-fragment file in the directory,
    and it keeps the directory in place when no fragments are left.

  • A fragment holds one or more bullets in the existing style. A bullet starts on a line that matches:

    ^- (Breaking|Added|Changed|Deprecated|Removed|Fixed|Security)( \([^)]+\))?: \S
    

    Continuation lines are indented by two spaces. Blank lines between bullets are allowed. Anything
    else fails validation, including a leading non-bullet line or an unknown kind. Example:

    - Fixed (macos): `screenshot --fullscreen` on the `desktop`, `menubar`, or `frontmost-app`
      surface now refuses with `INVALID_ARGS` instead of being ignored. (#2849)
  • PRs never edit CHANGELOG.md. Only the release version commit writes to it. The guard below
    fails any PR that does.

Guard: .github/workflows/changelog-guard.yml

A new workflow that fails every PR whose diff touches CHANGELOG.md:

name: Changelog Guard

on:
  pull_request:
    paths:
      - 'CHANGELOG.md'

permissions:
  contents: read

jobs:
  refuse:
    name: PRs do not edit CHANGELOG.md
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - name: Refuse CHANGELOG.md edit
        run: |
          echo "::error file=CHANGELOG.md::PRs never edit CHANGELOG.md. Add a changelog.d/<issue>-<slug>.md fragment instead; see changelog.d/README.md. The release version commit assembles CHANGELOG.md."
          exit 1
  • pull_request.paths runs the workflow when at least one changed file matches. So it catches a
    PR that edits CHANGELOG.md together with code, and a PR that edits only CHANGELOG.md. The
    six workflows that list CHANGELOG.md in paths-ignore skip a CHANGELOG-only PR, so the unit
    gate never sees that case. This separate workflow does.
  • It needs no checkout, no base computation and no tags. The path filter already is the diff
    against the PR base.
  • No push trigger. The release commit reaches main by push, so the guard never fires on it.
  • No escape hatch (no label, no allowlist). See Non-goals.

Assembler: scripts/changelog-release.ts

Run it with node --experimental-strip-types, the same way as the other scripts/*.ts tools. It
exports a pure core and has a thin CLI:

export type ChangelogFragment = { name: string; text: string }; // name = file basename
export function parseFragment(fragment: ChangelogFragment): { kind: Kind; text: string }[]; // throws on invalid
export function assembleChangelog(input: {
  changelog: string;          // current CHANGELOG.md
  fragments: ChangelogFragment[];
  version: string;            // package.json version
}): string;                   // new CHANGELOG.md
  • Ordering is deterministic and does not depend on input order. The assembler sorts bullets by
    kind rank (Breaking, Removed, Changed, Deprecated, Added, Fixed, Security), then by fragment
    name, then by position in the fragment. It keeps bullet text verbatim, minus trailing whitespace.
  • Insertion. It writes ## <version>\n\n<bullets>\n\n after the # Changelog title, before
    the first ## heading.
  • Refusals. The assembler throws with a message that names the file, and makes no writes, in
    these cases:
    • version contains -, so a -dev marker never becomes a heading.
    • CHANGELOG.md already contains ## <version>. This blocks consuming the same fragments twice.
    • CHANGELOG.md contains ## Unreleased.
    • A fragment is invalid.
  • No fragments. The file stays unchanged, and the script prints a notice and exits 0. A release
    with no user-visible change gets no section.
  • CLI, default mode. It reads package.json version, CHANGELOG.md and changelog.d/*.md
    (excluding README.md). It writes CHANGELOG.md and deletes the fragments it consumed.
  • CLI, --check mode. It exits 1 when any fragment is still in changelog.d/, which means a
    release that skipped assembly. It also validates every fragment.

Release flow integration (package.json)

  • "version": "pnpm sync:mcp-metadata && node --experimental-strip-types scripts/changelog-release.ts && git add server.json CHANGELOG.md changelog.d".
    npm version stages files that the hook adds, so the release commit contains the new section and
    the fragment deletions. git add changelog.d stages the deletions of tracked fragments.
  • release:prepare gets node --experimental-strip-types scripts/changelog-release.ts --check &&
    right after the existing release-mark-dev.mjs --check-release-version step.
  • scripts/release-mark-dev.mjs does not change. It writes package.json directly and does not
    run npm version, so it never consumes fragments.

One-time migration of the current ## Unreleased block

Recompute this at your base. At 6debef0634 the latest tag is v0.21.12.

  • Bullets added after the latest tag (git diff <latest-tag> origin/main -- CHANGELOG.md; 12
    bullets and 119 lines at 6debef0634) move verbatim into one fragment per PR. Name each fragment
    after the PR number in the bullet or commit.
  • The remaining bullets stay in CHANGELOG.md under
    ## 0.15.1 – <latest-tag-version>, followed by one line: "These releases did not split the
    changelog per version." Do not try to attribute them per version.
  • After the migration, no ## Unreleased heading remains.

CI and docs

  • Add 'changelog.d/**' to pull_request.paths-ignore in the six workflows that already ignore
    CHANGELOG.md: .github/workflows/{ci,size,ios,android,linux,macos}.yml, line 10 in each.
  • test/ci/root-docs-paths-ignore.test.ts: in the same loop, assert that a sample fragment path
    (changelog.d/2799-example.md) is ignored by all six workflows. Update the header comment.
  • scripts/check-affected/model.ts needs no change, because isDocs (line 218) already classifies
    changelog.d/*.md as docs.
  • docs/agents/pull-requests.md: add one rule. A user-visible change adds a
    changelog.d/<issue>-<slug>.md fragment. PRs never edit CHANGELOG.md; the Changelog Guard
    workflow fails a PR that does. Stay within pnpm check:agent-guidance budgets.
  • changelog.d/README.md states the same rule, because the guard's error message points there.
  • website/docs/docs/migrating-gestures.md:170: change "recorded in CHANGELOG.md under
    Unreleased" to "recorded in a changelog.d/ fragment".
  • CONTRIBUTING.md: add a two-line pointer to changelog.d/README.md, next to the release/version
    section (around line 80).

Delivery order

Two PRs, in this order. The migration PR must edit CHANGELOG.md once, and the guard runs from
the PR's own merge ref, so a guard in the same PR fails that PR.

  1. Migration PR: fragments, assembler, release flow, migration, paths-ignore, docs.
  2. Guard PR: changelog-guard.yml, its test, and the guard sentence in
    docs/agents/pull-requests.md and changelog.d/README.md. It does not touch CHANGELOG.md,
    so it is green on its own run. Open it right after the migration PR merges.

Completion conditions

  • test/ci/changelog-guard.test.ts (vitest, added to the explicit unit-core include list in
    vitest.config.ts next to test/ci/root-docs-paths-ignore.test.ts, line 172). It parses the
    real .github/workflows/changelog-guard.yml with yaml, and handles the YAML 1.1 true key for
    on the same way root-docs-paths-ignore.test.ts does. It asserts:
    • The triggers are exactly pull_request. There is no push trigger, so the release commit on
      main is never refused.
    • pull_request.paths matches CHANGELOG.md through matchesGlob from
      scripts/gate/workflows.ts, does not match changelog.d/2799-example.md, and there is no
      paths-ignore.
    • The job has a step whose run ends in exit 1 and contains changelog.d/README.md.
    • Mutation evidence in the Guard PR body: add push: or drop CHANGELOG.md from paths, and
      this test fails.
  • Manual proof in the Guard PR body: a throwaway draft PR that adds one line to CHANGELOG.md
    gets a failing Changelog Guard check with the pointer message. Close it after.
  • pnpm check:gate-manifest stays green with the new workflow. If it asks for a declaration, add
    it at the declaration it names. Do not add an allowlist.
  • scripts/__tests__/changelog-release.test.ts (vitest, added to the explicit unit-core include
    list in vitest.config.ts) covers the following:
    • Repository guard: the real root CHANGELOG.md has no ## Unreleased heading. This case
      fails on 6debef0634. Every file in the real changelog.d/ other than README.md has a valid
      name and passes parseFragment. A PR that adds a malformed fragment fails the unit-ci gate
      on any PR that runs CI. This case is the content check for the push-to-main CI run, which has
      no paths-ignore and so also checks what a release commit wrote. The rule that a PR does not
      write CHANGELOG.md at all belongs to the Changelog Guard, not to this case.
    • Determinism: a fixed fragment set in two different input orders gives byte-identical output.
      The expected output is a literal in the test, not derived from the implementation.
    • Kind order: a Fixed fragment named a-… sorts after a Breaking fragment named z-….
    • Refusals: each of the four refusal cases throws, and the output is unchanged.
    • No fragments: the input comes back unchanged.
    • CLI in a temporary directory: the default mode writes the section and deletes the consumed
      fragments. --check exits 1 while one fragment remains and 0 when only README.md is left.
  • src/__tests__/npm-package-scripts.test.ts asserts the new version script string and the
    --check step in release:prepare. The existing exact-string assertion for release:prepare
    (line 76) is updated, not removed.
  • test/ci/root-docs-paths-ignore.test.ts fails if any of the six workflows drops
    changelog.d/**.
  • Mutation evidence in the Migration PR body: delete the kind sort and the determinism test fails. Remove the
    ## <version> refusal and the double-run test fails.
  • pnpm check:affected --run is green on each PR. Run pnpm check:fallow --base origin/main. If fallow
    reports the new script as an unused entry, add it to .fallowrc.json entry in the final
    chore(gates) commit.
  • Manual proof in the Migration PR body, in a scratch clone: npm version patch --no-git-tag-version with
    two fragments present gives a ## x.y.z section in kind order and removes both fragments. This
    is docs/tooling only, so no device run is needed.

Non-goals

  • No change to GitHub release notes. They stay auto-generated from PR titles.
  • No attribution of the 126 legacy bullets to individual versions.
  • No escape hatch in the Changelog Guard (no label, no allowlist). A correction to an already
    released section is rare. It rides in the next release commit, or a maintainer merges over the
    red check on purpose. Main has no required status checks, so the guard reports and does not
    block a maintainer.
  • No per-PR "missing changelog fragment" check. Whether a change is user-visible stays a review
    decision.
  • No .gitattributes merge=union. Rejected: GitHub's mergeability check does not use repository
    merge drivers, so PRs still show as conflicted. Union also silently mixes two edits to the same
    existing bullet.
  • No third-party changelog tool (changesets and similar). It would add a dependency and a second
    version-bump flow next to npm version and release:mark-dev.
  • No ADR. This is release tooling, and no architectural boundary moves. ADR 0019 and 0027, the R9
    layering scan and the eager-closure budgets are unaffected, because the script is outside
    src/, is not bundled, and no package entry imports it.

Dependencies/related

  • None blocking.
  • Related: Apple platform + runner: simplification and correctness audit (tracking) #2803 (the audit arc that caused the conflicts).
  • Related: scripts/release-mark-dev.mjs and the "version on main never equals a published version"
    rule (CONTRIBUTING.md:80). This work runs next to it and does not change it.
  • Coordination: every PR in flight that edits CHANGELOG.md conflicts with the Migration PR once.
    Land it at a quiet point, then move those PRs' bullets into fragments when they rebase. After the
    Guard PR merges, any PR that still edits CHANGELOG.md gets a failing Changelog Guard check that
    points to changelog.d/README.md.

Estimate

  • Two PRs (see Delivery order), about ½ to 1 day in total for a Sonnet-class agent.
  • Net production lines: about 90, all in scripts/changelog-release.ts. There are 0 runtime src/
    lines, and no runtime lines are removed.
  • Migration PR gross diff: about 450 to 550 lines. That is the script (about 90) and its test
    (about 130); moved legacy bullets (about 120 removed from CHANGELOG.md and about 120 added as
    fragments); and about 40 lines of workflow, test, package.json and doc edits. It fits the
    1,000-line budget.
  • Guard PR gross diff: about 60 lines. That is the workflow (about 20), its test (about 35), the
    vitest.config.ts include line, and two doc sentences.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions