--- 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 `## ` 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 2.0` ). 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 `= =` 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:` 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:` 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.