---
name: changelog
description: How to write GTM4WP CHANGELOG.md / readme.txt entries. Follow when adding, editing, or grouping a changelog bullet for a production-code change, or when the require-changelog Stop/commit-msg hook blocks you. Covers the "last released stable version is the baseline" rule (drop back-ported fixes and dev-only regressions), the "write for the upgrading user" rule (edit an unreleased feature's existing bullet vs. add a new Fixed: bullet), the 2.0 theme grouping, the readme.txt mirror, and the [skip changelog] escape hatch.
license: GPL-2.0-or-later
---

# GTM4WP Changelog Policy

## What requires an entry

Every change to **production code** ships a matching bullet under the top **unreleased**
heading in `CHANGELOG.md` (`* Added:` / `* Changed:` / `* Updated:` / `* Fixed:`). The
heading is `## <development target>` per `.claude/RELEASE-STATE.md`; when the top heading
is a *released* version (right after a release, before any new production change), the
change that needs a bullet **opens the new heading above it** in the same edit.

"Production code" = `src/**.php`, `compat/**.php`, `js/frontend/**.js`, `js/admin/**.js`,
the main plugin file and `uninstall.php`. Tests, docs and `.security/`/`.testing/`
housekeeping are exempt.

## Release dates, anchors and the website

- A released heading carries the date its wordpress.org SVN tag was created:
  `## 2.0.5 (2026-10-01)`. The `release` skill adds it after the SVN push; never type
  it from memory and never use the GitHub date (wordpress.org has followed days later).
  The unreleased headings at the top carry no date.
- `CHANGELOG.md` is the source of the gtm4wp.com changelog pages
  (`tools/build-changelog-page.js`); never edit those pages on the site. The generator
  stops on a released heading without a date, a malformed heading, or markdown it does
  not support: `###`/`####`, `* ` bullets with one tab-indented level, paragraphs, and
  inline bold, italic, code and links.
- Never rename a released heading: its anchor (`#v2-0-5`) is linked from posts, social
  posts and forum replies.
- When a release has a post, the section's last line is
  `Release post: [Title](https://gtm4wp.com/…)`. It renders as "Read more" and is not a
  bullet, so it is outside the word budget.

## The baseline is always the last released stable version

Every bullet in the unreleased block describes a delta against the **last
released stable version** — named in `.claude/RELEASE-STATE.md`, verifiable as
`Stable tag:` in `readme.txt` on the released stable branch. Not against the
previous major, and not against last week's working tree. Two consequences:

- A fix **back-ported** to that stable release gets **no bullet** in the
  unreleased block. It is not a delta any more; the reader sees it in the
  released version's own block directly below.
- Do not soften the baseline because some sites are still on an older version.
  Admins upgrading from further back read the intervening blocks, which sit
  right below the unreleased one, so they stay informed either way.

Before writing "previously…", "the last version did…", or "no longer…", confirm the
claim against the released code (`git grep <symbol> 2.0` <!-- release-coupled: the
released stable branch -->). A bullet whose "previously" only ever existed on the
development branch describes nothing the reader lived through.

## Write for the upgrading user, not for the development history

While a version is **unreleased**, a fix to a feature introduced *in that same
version* must **edit that feature's existing bullet**, not add a new `* Fixed:`
bullet. A user upgrading from the last release never ran the intermediate code,
so for them the feature plus its development fixes is a single `* Added:`. Add a
`* Fixed:` bullet only for a defect that shipped in a **released** version.

Corollaries:

- A change that only repairs a regression introduced earlier in the same
  unreleased version gets **no bullet at all** — its net effect versus the last
  release is zero. Touch `CHANGELOG.md` (e.g. refine the feature's wording) to
  satisfy the hook.
- An internal refactor with "no functional change" is not a changelog entry.
  Use `[skip changelog]` in the commit message instead.
- Editing an existing bullet **satisfies both hooks** — they check that
  `CHANGELOG.md` changed, not that a bullet was added.
- A large release section is grouped under `###` theme headings (the `## 2.0`
  section used: Architecture, Settings screen, Container, Page variables,
  WooCommerce, Media events, Consent, Contact Form 7, AMP, Removed). Where the
  unreleased section has theme groups, put a new bullet in its group rather than
  at the top of the section.
- `readme.txt`'s matching `= <version> =` block **mirrors** the unreleased
  section (flattened for WordPress.org: no nested lists, `**bold**` lead-ins
  instead of `###`). A user-visible change updates both files together, opening
  the readme block alongside the changelog heading when it does not exist yet.

## How long a bullet is

**Budget: 25–40 words, ceiling 60.** A bullet is release notes for somebody
upgrading, not the investigation that produced the change. Measured 2026-09-23,
the unreleased 2.1 section ran to 280 words per bullet against 117 for 2.0 and 57
for 1.22.5, and `readme.txt`'s changelog section stood at 6,928 words against the
**5,000-word cap wordpress.org truncates at** (U150 / drift row D21) — so length
here is a published defect, not a matter of taste.

What a bullet carries, in this order:

1. **What changed**, in the user's vocabulary (setting names as they appear on
   the screen, event and field names as they appear in the data layer).
2. **What they must do**, when anything: a GTM trigger to adjust, a default that
   changed, an option to switch on. This is the part nobody may cut.
3. **Why**, in at most one clause — and only when it changes what they should do.
4. The issue number and the credit: `(#145)`, `Thanks to @user for the report`.

Leave out: how the bug was found, what the code did internally, which class or
hook was involved, how long it had been broken, what was measured or ruled out,
and reassurance that unaffected setups are unaffected. An option's full
explanation belongs in its field description and on gtm4wp.com, not here — link
it instead of restating it.

`readme.txt` is the tighter of the two: it mirrors the entry, flattened, and the
whole `== Changelog ==` section stays under 5,000 words (target ~4,000), so older
sections get summarised and linked to the gtm4wp.com changelog
(`https://gtm4wp.com/changelog`, 1.x: `/changelog/1-x`) rather than left in full.

The `prose-budget` Stop hook reports any bullet over 60 words that the working
tree added; `bash .claude/hooks/prose-budget.sh check` runs the same check by hand.

## Enforcement

One shared script, `.claude/hooks/require-changelog.sh`, enforces this:

- a Claude Code **`Stop` hook** (in `.claude/settings.json`) blocks wrapping up a turn
  that left production code modified without a `CHANGELOG.md` change;
- a git **`commit-msg` hook** (`.githooks/commit-msg`) rejects a commit that stages
  production code without staging `CHANGELOG.md`. Escape hatch for non-user-facing
  commits: put `[skip changelog]` in the commit message (or `git commit --no-verify`).

**One-time setup after cloning** (the git hook lives in a tracked dir, so it must be
activated once per clone): `git config core.hooksPath .githooks`.

### If you ever check out somebody else's branch

That simple setup executes `.githooks/commit-msg`, which execs
`.claude/hooks/require-changelog.sh` — **both resolved from the checked-out tree**. So a
branch you are only *reviewing* supplies the shell code that runs as you on your next
commit, and on every Claude turn through the `Stop` hook, with no command typed
(`.security` finding #77, rated D0 → D1).

That matters only if untrusted branches get checked out in a clone. Where they do, run
the check from a **fixed ref** instead, with the entry point outside the tree:

```bash
mkdir -p ~/.githooks/gtm4wp
# ~/.githooks/gtm4wp/gtm4wp-changelog-check  - materialises the script from a fixed ref:
#   git show-ref --verify -q refs/tags/master && exit 1                     # a tag would shadow it
#   C=$(git rev-parse --verify -q 'refs/heads/master^{commit}') || exit 1
#   git show "$C:.claude/hooks/require-changelog.sh" > "$TMP" || exit 1      # fail CLOSED
#   exec bash "$TMP" "$@"
# ~/.githooks/gtm4wp/commit-msg  - exec .../gtm4wp-changelog-check commitmsg "$1"
git config core.hooksPath ~/.githooks/gtm4wp
```

and point the `Stop` hook in `.claude/settings.json` at the same runner. The logic stays
here, versioned and reviewed; only the copy that *executes* is pinned.

Four things worth knowing before adopting it:

- **Fail closed, deliberately.** The tempting one-liner `bash <(git show "$REF:$SRC")`
  fails **open** — an unresolvable path yields an empty script, `bash` runs nothing, exits
  0, and the commit sails through unchecked. Verified by measurement, not assumed.
- **Pin the branch, never the bare name (#365).** `git show master:<path>` resolves a tag
  named `master` before the branch, and fetching from a fork can import one. Resolve
  `refs/heads/master` to a commit id first. Passing `refs/heads/master:<path>` as one
  argument does not work under Git Bash, which rewrites it as a path list.
- **An edit to `require-changelog.sh` takes effect once it is committed to the ref**, not
  while it sits uncommitted in your tree.
- **It is local git config, so it protects one clone and propagates to none.** It is
  deliberately not wired into a `package.json` `prepare` script: that script comes from
  the worktree too, so a branch would supply the installer meant to defend against
  branch-supplied code. An earlier attempt to make this the tracked default was declined
  because it blocked every commit until an installer had been run — this version changes
  no tracked file, so nothing breaks for anyone who keeps the simple setup.
