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

  • ctxgrd 0.52.0 or later.
  • A ctxgrd.toml with at least one id-claimed namespace (BUG, ADR, CR, …) whose documents carry a terminal status value, 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 changelog

Prints the generated markdown to stdout without touching anything on disk. To write it:

ctxgrd changelog --write

This 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 --check

Regenerates 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 --write would produce.
  • 1 – it is stale; run ctxgrd changelog --write and commit the result.
  • 2 – a config or kernel error (e.g. ctxgrd.toml is 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 json

Emits 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/ADR behind 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 since value 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.