Skip to content

ADR-0073: Changelog fragments (Towncrier-style)

  • Status: Accepted
  • Date: 2026-05-23
  • Driver: 2026-05-23 architectural review post-drain post-mortem
  • Supersedes: implicit edit-monolith convention

Context

The repository maintains three CHANGELOGs in parallel:

  • CHANGELOG.md (English) — ~180 kB
  • CHANGELOG.pt-BR.md — ~183 kB
  • CHANGELOG.es.md — ~184 kB

Each is the authoritative changelog for one of the three first-class languages (per project's translation-fidelity gate). Every PR that ships user-visible code adds an entry to all three under the ## [Unreleased] heading.

The problem

Every entry lands in a narrow byte range immediately under ## [Unreleased]. When PR N merges, every other open PR M ≠ N now has a merge conflict at that exact range, because both PRs touched the same line. Resolution requires manual rebase per open PR, even though the actual change is purely additive ("keep both blocks").

The drain that exposed the smell

On 2026-05-23 the operator ran a queue drain of 18 PRs (#947–#964). Despite each PR's underlying code being independent and conflict-free, the CHANGELOG cascade required ~30 rebase cycles to walk through. A Python auto-resolver ([scripts/resolve-changelog-conflict.py]) made the resolution mechanical but did not solve the fundamental problem: shared byte ranges in a write-heavy hot file.

Forces

  • Translation fidelity — three-language parity is a project policy (LOCKED via memory feedback_always_document_new_functionality). Any solution must keep all three languages in lock-step.
  • Release notes quality — entries are operator-facing; auto- generated commit-message-only changelogs (release-please etc.) produce noise, not narrative.
  • Tooling minimalism — the repo already has Python in CI for other scripts (e.g. tools/validate-ip-pool.sh companion). Adding Towncrier (PyPI package) is heavier than necessary for ~10 entries per release.
  • Reviewability — fragments must be reviewable in the PR diff without specialist tooling.

Decision

Adopt a Towncrier-style fragment workflow with a custom builder:

  1. Every PR with user-visible changes drops one (or more) fragment under changelog.d/<id>.<category>.md where:
  2. <id> is the PR number (or <topic>-<slug> for pre-PR work)
  3. <category> ∈ {added, changed, deprecated, removed, fixed, security, docs, chore}
  4. The fragment file contains three sections demarcated by <!-- lang: en -->, <!-- lang: pt-BR -->, <!-- lang: es -->.
  5. A CI gate (.github/workflows/changelog-fragment-check.yml) validates that PRs touching code add at least one valid fragment.
  6. At release-cut, the operator runs python3 scripts/changelog/build-changelog.py --release v3.7.0 which:
  7. Parses every fragment
  8. Groups by category
  9. Prepends a new ## [v3.7.0] — DATE block to each CHANGELOG.<lang>.md
  10. Deletes all consumed fragments
  11. The release PR captures both the CHANGELOG updates and the fragment deletions in one commit.

Consequences

Positive

  • Zero PR-on-PR CHANGELOG conflicts. Each PR writes its own file; the byte-range collision goes away.
  • Faster drains. The 2026-05-23 drain would have completed in ~5 minutes instead of ~2 hours of rebase juggling.
  • Better release notes. Categories are enforced at fragment-name level, so the release script can present them in canonical order (Added → Changed → Deprecated → Removed → Fixed → Security → Docs → Chore).
  • Lower review burden. Reviewers see only the +N lines of the new fragment file in the diff, not a re-rendered CHANGELOG diff.

Negative

  • One more file per PR. Mitigated by the CI gate's clear error message and the changelog.d/README.md documenting naming.
  • Release-cut becomes a manual step. Mitigated by the script being idempotent and dry-runnable (--dry-run).
  • Existing pre-merged history retains the monolithic format. No retroactive migration — the current 180 kB CHANGELOGs stay; new entries land under the next release heading.

Alternatives considered

  • release-please / changesets — Auto-generates CHANGELOG from Conventional Commits. Rejected: produces commit-message-quality notes, not the operator-facing narrative this project requires; and would need three independent release-please instances for the three languages.
  • git rerere + per-PR auto-resolver — Documented in feedback_doc_audit_drift_job_flaky.md style. Rejected: only treats the symptom; still requires a rebase per PR even if the resolution is mechanical.
  • Single-language CHANGELOG with i18n at build — Rejected: breaks the project's translation-fidelity gate which requires structural parity at file level.
  • Upstream Towncrier (PyPI) — Considered. Custom builder chosen because (a) we already need 3-language support which Towncrier doesn't ship out of the box, (b) zero new dependencies.

See also

  • changelog.d/README.md — operator-facing how-to
  • scripts/changelog/build-changelog.py — the builder
  • scripts/changelog/tests/test_build_changelog.py — 9 unit tests
  • .github/workflows/changelog-fragment-check.yml — PR gate
  • 2026-05-23 drain post-mortem (memory: project_pr_drain_lessons_2026_05_23.md)