Docs: Changelog · diff
git:20261028.1de3b88 to git:20261104.4299707
3 added, 1 removed. Audit A to A.
---
name: "Docs: Changelog"
description: "Build or update the changelog from conventional commits and project it to every surface the project uses"
when_to_use: "After a release tag lands (pr-land names this moment), or when the user asks for a changelog, release notes, a what's-new page or to bring an existing CHANGELOG.md up to date."
model: sonnet # was haiku: auto mode skips haiku and keeps the session model, which is sometimes Fable here; sonnet pins it
effort: medium
metadata:
glyph: ᛊ
family: doc
goblin-spinner: reading the commits|writing history|projecting the changelog
disable-model-invocation: false # programmatic (built from commits), and its trigger moment follows pr-land; approval gates the write
allowed-tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash(git:*)", "Bash(gh:*)", "Bash(svu:*)", "Bash(cd:*)", "Bash(test:*)", "Bash(python3:*)", "Bash(~/.claude/library/scripts/slop-scan.py:*)", "Bash(~/.claude/library/scripts/safe-version-next.sh:*)"]
arguments: ["scope", "targets", "version"]
argument-hint: "[root|plugin:NAME|pkg:NAME] [md|release|app|docs|all] [tag (optional, scopes to one release)] (default scope: root; default targets: md + whatever already exists)"
---
# Distro: Changelog
One source of truth, projected outward, the same shape as the roadmap system. `CHANGELOG.md` at the repo root is canonical; GitHub Releases, an in-app what's-new surface and a docs-site page are projections built from it, never written independently. A projection that drifts from the file is a bug.
## Scope
The first argument, always optional, always defaulting to `root`:
| Scope | Covers | Tag series | Changelog file |
|---|---|---|---|
| `root` (default) | the whole repo | `v*` | `CHANGELOG.md` |
| `plugin:NAME` | one plugin's own subtree | `NAME-v*` | plugin's own changelog source, or `<plugin dir>/CHANGELOG.md` |
| `pkg:NAME` | one release package from `.github/release-packages.json` | the package's `prefix` (`api/v*`, say) | the package's `changelog` path |
`plugin:NAME` resolves `NAME` against `.claude-plugin/marketplace.json`'s `plugins[]` entries: `name` gives the tag prefix (`NAME-v`), `source` gives the subtree directory (strip a leading `./`) that `git log` and `safe-version-next.sh` scope to. No matching entry is an error, not a silent fallback to root. This is why the token carries a `kind:` prefix rather than a bare name: `docs` is both a target and a real top-level directory here, so slot one has to be unambiguous on sight. A future subtree of some other kind gets its own resolver (`dir:`, say); the parse rule (`root`, or anything containing `:`, is a scope; anything else is a target) never changes.
`pkg:NAME` resolves `NAME` against `.github/release-packages.json`'s `packages[]` entries (the manifest is `{"signers": [...], "packages": [{name, dir, prefix, changelog, consumers}]}`): `dir` becomes `{scope-dir}`, `prefix` becomes `{prefix}` verbatim (it already ends in `v`, as in `api/v`) and `changelog` becomes `{changelog-path}`. No matching entry is an error, not a silent fallback to root. A repo with this manifest is tagged by its own CI, so `safe-version-next.sh` plays no part in a `pkg:` run: the series is read straight from `git tag --list "{prefix}*" --sort=-v:refname` and the next version is whatever CI tagged. **Nobody creates or suggests a 1.x tag in a manifest series by hand.** Only the repo's Declare Stable workflow, a manual dispatch by a signer listed in the manifest's `signers[]`, moves a package from 0.x to 1.0.0; this skill never proposes one and never writes a `[1.0.0]` section for a tag that does not already exist.
**Where the changelog is written is not always inside `{scope-dir}`.** A hand-maintained plugin (every plugin in this repo) keeps it at `<plugin dir>/CHANGELOG.md`. A plugin generated by a build script regenerates its subtree wholesale, so a file written there is deleted on the next rebuild; such a script names its changelog source as a constant (`CHANGELOG_SOURCE`, beside `README_SOURCE` and `VERSION_SOURCE`), found by reading the plugin's docs rather than assumed, and that path is `{changelog-path}`. Root scope has no such indirection: `{changelog-path}` is always `CHANGELOG.md` at the repo root.
Every step below runs identically for every scope; only the directory git log and svu/`safe-version-next.sh` are scoped to (`{scope-dir}`), the tag prefix they use (`{prefix}`) and where the changelog file itself is written (`{changelog-path}`) change. All three are plain prose placeholders, not literal shell variables, filled in from what Step 1 resolves. `{scope-dir}` and `{changelog-path}` are the same path for root scope, but diverge for a build-output plugin, where history is scoped to the built subtree while the file itself lives with the plugin's other hand-maintained sources.
## Targets
| Token | Surface | Applies when |
|---|---|---|
| `md` | `CHANGELOG.md`, Keep a Changelog layout | always; this is the source |
| `release` | GitHub Release notes per tag (`gh release`) | repo has tags on GitHub |
| `app` | structured data for an in-app what's-new surface | the app has (or wants) one |
| `docs` | changelog page in the docs site | a docs site exists |
| `all` | every surface that applies to this project | |
No arguments: build `md`, then refresh any projection that already exists in the repo (an existing release for the previous tag, an existing changelog data file or docs page). Never scaffold `app` or `docs` surfaces unasked; offer when the project looks like it wants one.
## Step 1: Establish the range
Parse `$ARGUMENTS` (whitespace-separated words) into scope, targets and an optional tag, in that order. This is prose parsing, not variable substitution: `$ARGUMENTS` is the only token the harness fills in; every other placeholder in this skill (`{scope-dir}`, `{changelog-path}`, `{prefix}`, `{tag}`) is a value this step resolves and later steps refer back to.
1. **Scope** (always optional, first word only counts as scope): the literal word `root`, or any word containing `:`, is consumed as the scope word and removed from the argument list. Any other first word (`md`, `release`, `v2.5.0`, no arguments at all) means scope defaults to `root` and that word is left for the next rule to read. `root` resolves to `{scope-dir}` = `{changelog-path}` = `.` (repo root, and `CHANGELOG.md` within it) and `{prefix}` = `v`. `plugin:NAME` resolves `NAME` against `.claude-plugin/marketplace.json`'s `plugins[]` entries: `source` (with a leading `./` stripped) becomes `{scope-dir}`, and `{prefix}` becomes `NAME-v`; `{changelog-path}` is resolved per the Scope section above (the plugin's own build-script constant when one exists, else `{scope-dir}/CHANGELOG.md`). `pkg:NAME` resolves `NAME` against `.github/release-packages.json`'s `packages[]` entries: `dir` becomes `{scope-dir}`, `prefix` becomes `{prefix}` and `changelog` becomes `{changelog-path}`. For either kind, no matching entry is an error, not a silent fallback to root.
2. **Targets**: the next remaining word, if it's one of `md`/`release`/`app`/`docs`/`all`; otherwise targets defaults per the Targets section below and that word is left for the next rule.
3. **Tag**: whatever word remains, if any; this is the optional version argument scoping the run to one release.
+ **Then the repository's policy**, before anything is read from history: `python3 "$HOME"/.claude/library/scripts/versioning-policy.py check changelog {target}`, where `{target}` is `root` for root scope or the plugin's or package's name. Exit 0 means this target keeps no changelog: say so in one line and stop. Exit 0 from `check release {target}` means the `release` target is dropped from this run (the section is still written; no GitHub release is created or edited), said in the Step 5 report as `omitted by policy`. The file is `.claude/versioning.json`, set through `project-versioning_policy`; a missing file omits nothing.
+
- **(a) No file at `{changelog-path}`** → build fresh from the earliest tag matching `{prefix}*` (or full history under `{scope-dir}` if untagged).
- **(b) Placeholder, or already Keep a Changelog-shaped** → a `<!-- doc-changelog: generated ... -->` marker (left as the first line by this skill from now on, mirroring `doc-readme`'s convention) or a file that's already unambiguously in Keep a Changelog structure (a prior unmarked run of this same skill); proceed with the normal forward-generation logic below.
- **(c) Hand-written changelog in some other format/voice** → stop and flag it explicitly before writing anything: offer to convert it to Keep a Changelog format, or to respect the existing format and append new entries in its own voice instead. Don't silently Frankenstein a Keep a Changelog section onto a differently-structured hand-written file.
`git tag --list "{prefix}*" --sort=-v:refname` for existing tags in this scope's series; for root and plugin scopes, `~/.claude/library/scripts/safe-version-next.sh` (with `--plugin NAME --dir {scope-dir}` for a plugin scope) rather than bare `svu current`/`svu next`, so the 0.x guard and the plugin's own tag/directory scoping both apply here the same as they do to any other tag this config creates. A `pkg:` scope skips the script entirely: CI tags that repo, so the tag list is the whole truth and no next version is ever computed here. The unit of work is tag-to-tag within this series: each version section covers `previousTag..tag` (both `{prefix}`-matching), and `[Unreleased]` covers `latestTag..HEAD`, in both cases limited to commits touching `{scope-dir}` (`-- {scope-dir}` on every `git log`, a no-op for the root scope, where `{scope-dir}` is `.`). If the file at `{changelog-path}` exists (state b), its most recent version heading shows where it stopped: only generate forward from there; never rewrite sections already published.
The resolved tag from Step 1's parse (if any) names a tag in this scope's series: scope the run to that single release (`previousTag..{tag}`), leaving every other section untouched. This is pr-land's hand-off; it can invoke `/doc-changelog root md {tag}`, `/doc-changelog plugin:NAME md {tag}` or `/doc-changelog pkg:NAME md {tag}` right after tagging, once per tag the landing produced.
**Promote, don't regenerate:** when a new tag lands and `[Unreleased]` already carries curated entries, promote that content into the new version section, verify nothing in the tag range is missing (add what is), and rebuild `[Unreleased]` from `newTag..HEAD`. Hand-polish survives; only genuinely new material gets derived.
## Step 2: Build the canonical entries
For each version in range, read `git log --first-parent previousTag..tag --format='%h %s%n%b' -- {scope-dir}`: with merge-commit landings this yields one commit per merged PR (plus direct-to-main commits), so each entry derives from a PR-level change rather than branch-internal noise. The `-- {scope-dir}` limit is what makes a plugin or package scope mean anything: a PR that touches both root config and a plugin (docs plus `marketplace/roadmap/**` in one PR, say) contributes an entry to both changelogs independently, as does one that touches two packages at once (`apps/api` and `supabase`), each changelog reading only the part of that PR's diff under its own scope, each reading only the part of that PR's diff under its own scope. A stacked-PR landing does not preserve this: `gh stack merge` makes one merge commit for the whole stack, titled for the top PR, so first-parent history shows a single line for every layer (see `library/references/stacked-prs.md`). When a range contains one, read `git log --no-merges previousTag..tag -- {scope-dir}` for that landing and write one entry per coherent change, as for any other. Fall back to the full log (still `-- {scope-dir}`-limited) only when the first-parent output is too thin to describe the release. Map conventional-commit types to Keep a Changelog sections:
| Commit type | Section |
|---|---|
| `feat` | Added |
| `fix` | Fixed |
| `enhance`, `perf` | Changed |
| `BREAKING CHANGE` footer or `!` | its own **Breaking** entry, listed first |
| `refactor`, `docs`, `test`, `chore`, `ci`, `deps` | omitted unless user-visible |
Entries describe the change from the user's side ("Exports now include timestamps"), not the commit's ("add timestamp to export serialiser"). Collapse commit-level noise: one entry per coherent change, not per commit. British spelling; no em-dashes.
## Step 3: Write `{changelog-path}`
Keep a Changelog structure: `# Changelog` intro, `## [Unreleased]`, then `## [x.y.z] - YYYY-MM-DD` sections newest first, comparison links at the bottom when the repo is on GitHub. On a fresh build (state a/b from Step 1), the very first line is the marker comment: `<!-- doc-changelog: generated {date}. Delete this line once you hand-edit this file. -->`.
**Collapsibility for older entries.** Once the file holds more than a handful of version sections, wrap everything older than the most recent few in GFM's native `<details><summary>`: `[Unreleased]` and the newest 2-3 versions stay always-visible, older ones collapse behind a one-line summary (`<summary>0.4.0 and earlier</summary>`). No custom chevron needed. Adjusting which sections are wrapped as new versions land is presentational only; it doesn't touch a section's own entries, so it isn't the "rewrite already-published sections" the red flags below warn against.
No badge row and no separate provenance line here: each entry is already dated by construction, which is its own honesty record; a "generated on X" line would just repeat what the file already states more precisely.
Scan the new or changed sections before showing them:
```bash
~/.claude/library/scripts/slop-scan.py --strict - <<'SLOP_EOF'
<draft sections>
SLOP_EOF
```
Non-zero exit: rewrite to clear every `L<n> <rule>: <excerpt>` line and rescan, at most twice. If hits remain, list them under the draft so the reviewer decides.
Show the draft (or the diff, when updating) and **await approval** before writing.
**Where the commit goes: straight to main.** A changelog commit is docs only, and docs-only commits go to `main` directly, never via a `docs/` branch and a PR (the user redirected a `docs/changelog-6-13-1` branch on 2026-10-26; that habit is gone). The main checkout may be another session's, so never write there:
```bash
git fetch origin main \
&& git worktree add --detach "${TMPDIR:-/tmp}/doc-changelog-{repo}-{tag}" origin/main \
&& cd "${TMPDIR:-/tmp}/doc-changelog-{repo}-{tag}"
# write {changelog-path}, then:
git add {changelog-path} \
&& git commit -m "docs: changelog for {versions}" \
&& git fetch origin main && git merge-base --is-ancestor origin/main HEAD \
&& git push origin HEAD:main
```
The fetch and the `merge-base` check together make the push a fast-forward: the worktree was cut from `origin/main` as it stood before the draft, so without the fetch the check can never fail. It fails when main moved while the draft was being approved; then `git rebase origin/main` (the fetch has already run) and repeat the check. A push the remote refuses (branch protection) is the one case that falls back to a `docs/` branch and `pr-create`; say so when it happens. Keep the worktree until Step 4 has read the section it needs for `release`, then remove it from outside it: `cd "$(git rev-parse --path-format=absolute --git-common-dir)/.." && git worktree remove "${TMPDIR:-/tmp}/doc-changelog-{repo}-{tag}"`.
**npm packages** (a `package.json` with no `"private": true`): check `CHANGELOG.md` ships or is reachable: either listed in `files` or linked from the README, since the npm page shows only the README. Flag whichever is missing; registry users otherwise never see release history.
## Step 4: Project
Only after `md` is approved and written:
- - **`release`**: for each new tag (in this scope's `{prefix}` series), a GitHub Release whose body is that version's section verbatim, not a rewrite. A plugin's `NAME-v*` tags and a package's `{prefix}*` tags get their own GitHub Releases, distinct from the root `v*` series; several series point at the same commit when one landing bumped them all. Three rules, each from an incident:
+ - **`release`**: only when Step 1's policy check did not drop it. For each new tag (in this scope's `{prefix}` series), a GitHub Release whose body is that version's section verbatim, not a rewrite. A plugin's `NAME-v*` tags and a package's `{prefix}*` tags get their own GitHub Releases, distinct from the root `v*` series; several series point at the same commit when one landing bumped them all. Three rules, each from an incident:
1. **Extract with python3, never sed.** BSD sed's address syntax silently matched nothing in October 2026 and two releases went out with empty bodies. Cut the section (from its `## [x.y.z]` heading to the line before the next `## [` heading) with a short python3 script and write it to `<scratchpad>/release-notes-{tag}.md`.
2. **Refuse an empty file.** `test -s <file>` before `gh release create`; an empty or whitespace-only file stops the step with the path named. The `gh` guard hook blocks a `gh release create` without a non-empty notes file regardless, so the check here is what makes the failure legible.
3. **Read it back.** `gh release create {tag} --title {tag} --notes-file <file>` (`gh release edit {tag} --notes-file <file>` when the release exists), then `gh release view {tag} --json body -q .body` and compare with the file, whitespace-trimmed. A mismatch or an empty body is repaired with `gh release edit` and read back again; report the repair.
- **`app`**: emit the structured form the app consumes. Root scope only; a plugin or package has no in-app what's-new surface of its own. If none exists yet and the user asked for `app`, propose the simplest fit for the stack (for SvelteKit: a `changelog.json` importable by a route) and build it from the same entries.
- **`docs`**: update the docs-site changelog page from the same sections, matching that site's existing format. Root scope only, for the same reason.
## Step 5: Report
Versions covered, surfaces written, surfaces skipped and why, plus any npm visibility flag from Step 3.
## Red flags
**Never:** write a projection that disagrees with `CHANGELOG.md`; rewrite already-published version sections (corrections get an explicit edit, called out to the user); invent user-facing descriptions for changes you don't understand; quote the commit and ask; create or suggest a 1.x tag in a `pkg:` series (only the repo's Declare Stable workflow, dispatched by a listed signer, does that); commit the changelog on a `docs/` branch or open a PR for it when the push to main was not refused; cut a release section with sed; publish a release without reading its body back.
<raw-arguments value="$ARGUMENTS" />