Generating a changelog from the document graph
Turn CHANGELOG.md into a generated projection of your BUG/ADR/CR
documents instead of a file you edit by hand.
What this buys you
Once a namespace is whitelisted, a document that ships (its status turns
fixed, implemented, accepted – whatever you configure) appears in the
next release’s section automatically. There is no second copy of the fact to
keep in sync, and nothing to remember to write by hand when you cut a release.
Released sections are read from the git tag that shipped them, so they never
change underneath you: editing a document at HEAD only ever changes
## [Unreleased]. There is no freeze step and no “don’t edit old entries”
rule to follow – the immutability comes from git, not from discipline.
Prerequisites
ctxgrd0.52.0 or later.- A
ctxgrd.tomlwith at least one id-claimed namespace (BUG,ADR,CR, …) whose documents carry a terminalstatusvalue, e.g.fixed. - Every release tagged. This is load-bearing – see Tag every release below.
1. Declare the whitelist
Add a [changelog] table to ctxgrd.toml: which namespaces contribute, and
where each namespace’s terminal status lands in the Keep a Changelog section
vocabulary (Added / Changed / Fixed / Removed / Deprecated /
Security).
[changelog]
namespaces = ["BUG"]
since = "v0.48.0"
[changelog.BUG]
when = "fixed"
section = "Fixed"This is this project’s own config. Nothing is hardcoded – ctxgrd ships no
default namespace list and no default status→section mapping, so a namespace
missing from namespaces never contributes, even if its documents are
terminal.
Lead with BUG. It maps cleanly to ### Fixed with one line per entry.
Add ADR or CR only if you want decision records in the changelog too –
opting in every namespace at once tends to bury a handful of bug fixes under
dozens of decision titles.
To add a second namespace, repeat the sub-table:
[changelog.CR]
when = "implemented"
section = "Added"2. Write the entry text
A document’s changelog line is its changelog: frontmatter field:
---
id: BUG-017
title: Nested ctxgrd.toml silently ignored by a parent run
status: fixed
changelog: "Fixed a nested `ctxgrd.toml` being silently ignored by a parent run."
---If changelog: is absent, the generator falls back to title. It never reads
the document body – so a diagnostic-sounding title like the one above is worth
overriding with a sentence a reader outside the project can parse.
3. Generate the changelog
ctxgrd changelogPrints the generated markdown to stdout without touching anything on disk. To write it:
ctxgrd changelog --writeThis regenerates CHANGELOG.md in place. Running it again with nothing
changed is a no-op – the output is a pure function of your tags and the
whitelisted documents at each tag.
4. Gate freshness in CI
ctxgrd changelog --checkRegenerates the changelog to memory and diffs it against the committed file –
the same contract as cargo fmt --check or gofmt -l. Exit codes:
0– the committed file matches what--writewould produce.1– it is stale; runctxgrd changelog --writeand commit the result.2– a config or kernel error (e.g.ctxgrd.tomlis malformed).
On this repo it is wired into make check:
check: adr-lint changelog-check
changelog-check:
ctxgrd changelog --check
Add the equivalent line to your own build gate so a commit that ships a fixed
BUG without regenerating the changelog fails the same way an unformatted
file would.
5. Drive it from an agent or script
ctxgrd changelog --format jsonEmits the structured changelog – versions, each with its sections and entries – on a clean stdout, so a script or agent can consume it without parsing markdown:
{
"versions": [
{
"version": null,
"date": null,
"sections": {}
}
]
}version: null is ## [Unreleased]. The exit-code contract (0/1/2) is
identical across rich, simple, and json, so branching on outcome never
requires screen-scraping.
6. Adopt on top of an existing hand-written changelog
If CHANGELOG.md already exists, keep its history intact and generate only
going forward. Insert a literal cutover marker at the point you want
generation to start, and set since to the tag at that point:
## [Unreleased]
<!-- ctxgrd:cutover — hand-authored history below is not regenerated (ADR-084 § CHG-006) -->
## [0.51.0]
### Added
- Existing hand-written entries, untouched.Everything below the marker is byte-preserved – --write never rewrites it.
Everything above it (## [Unreleased], plus any new version section for a
tag cut after since) is regenerated on every run. Seed the marker once; from
then on --write only ever grows the region above it.
Tag every release, or attribution collapses
Attribution works by reading each whitelisted document at each release tag and assigning it to the first tag whose frozen tree marks it terminal. If a release is never tagged, its documents don’t vanish – they attribute to the next tag instead, silently collapsing several versions’ worth of changes into one section. Dogfooding this on this repo’s own history (12 tags across 48-plus released versions) produced exactly that: a couple of tags absorbing everything shipped since the previous tag.
The fix is procedural, not configuration: tag every release you want to show up as its own section.
What a generated changelog will not do
- It will not invent an entry for a change with no whitelisted document. A
code-only change with no
BUG/CR/ADRbehind it is not eligible – either give it a document, or accept that it is not part of this ledger. - It will not regenerate a section that predates your cutover marker, even if
you edit the config’s
sincevalue afterward. - It will not catch a document that shipped while its status stayed
non-terminal. If a feature ships but its ADR is still
draft, the changelog omits it – correctly, since that is what the document says. Keep status current if you want the changelog to be current.