Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ADR-26024: Structured Commit Bodies for Automated CHANGELOG Generation


title: Structured Commit Bodies for Automated CHANGELOG Generation date: 2026-02-18 tags:


ADR-26024: Structured Commit Bodies for Automated CHANGELOG Generation

Title

Structured Commit Bodies for Automated CHANGELOG Generation

Date

2026-02-10

Status

Accepted

Context

The CHANGELOG has historically been manually curated, with RELEASE_NOTES.md composed by an LLM from a diff file. This approach has three problems:

  1. Manual curation doesn’t scale. As the ecosystem grows to multiple spoke repositories (ADR-26020), maintaining changelogs manually across all repos becomes unsustainable.

  2. LLM-based generation is non-deterministic. The existing workflow introduces “hallucination” risks in technical documentation — the same diff can produce different RELEASE_NOTES on different runs, with no audit trail from commit to changelog entry.

  3. Commit bodies are inconsistent. Some contain prose, some are empty, none follow a structured format. This prevents any automated extraction tool — LLM or otherwise — from reliably producing hierarchical changelogs.

The existing Production Git Workflow Standards define a Three-Tier Naming Structure. Tier 2 (Conventional Commits subject prefix) already provides type classification (feat:, fix:, docs:). Extending this with structured body bullets enables fully automated, deterministic CHANGELOG generation — the CHANGELOG transitions from a narrative/descriptive artifact to a computational/traceable artifact.

The Problem Statement

Generate hierarchical changelogs (section → topic → sub-items) automatically from git commit history, across all repos in the ecosystem, without an LLM. The existing CHANGELOG format is richer than what any standard tool produces:

release 2.4.0
* ADR Validation Toolchain:                              ← topic (from commit subject)
    - check_adr_index.py renamed to check_adr.py...     ← sub-item (from commit body)
    - Term reference validation added...                 ← sub-item
* Architecture Decisions (ADRs) added:                   ← another topic
    - ADR-26016: Metadata-Driven...                      ← sub-item

Decision

1. Mandate structured bullet bodies in all commits

Every commit on trunk must contain at least one changelog bullet in the body:

<type>[(<scope>)]: <subject line>

- <Verb>: <file-path> — <what_and_why>

The <what_and_why> portion explains what changed in the file and why — this is a changelog, so each bullet should capture both the substance and the motivation. Verb prefixes: Created, Updated, Deleted, Renamed, Fixed, Moved, Added, Removed, Refactored, Configured. No line length limit — one bullet = one line. Git trailers and ArchTag: lines are excluded from changelog parsing.

Target Rules — <file-path> is a path relative to the repo root:

  1. Single file: tools/scripts/check_adr.py

  2. Glob or Jupytext pair: tools/docs/website/01_github_pages_deployment.(md|ipynb)

  3. Multiple related files (same verb, same reason): adr_26001.md, adr_26002.md — or use a glob architecture/adr/adr_260{01,02}.md

  4. Rename/move: old_name.py → new_name.py

  5. Every change lives in a file — always use the nearest file path. Dependencies → pyproject.toml, CI variables → deploy.yml, config keys → myst.yml. No conceptual or abstract targets allowed.

2. Enforce via pre-commit hook and CI

validate_commit_msg.py runs as a commit-msg stage hook locally and in CI, validating both the Conventional Commits subject format and the presence of body bullets. This implements the “Ingredients-First” pattern: input data is validated continuously so the generator never encounters malformed commits at release time.

3. Build a custom Python changelog generator

generate_changelog.py extracts structured bullets from git log --first-parent and produces hierarchical CHANGELOG output (section → topic → sub-items). It runs once at release time on a frozen release branch.

4. Adopt Rich-Body Squash as the merge strategy

1 PR = 1 Commit = 1 Logical Change. The repository enforces Squash-and-Merge. The Git host populates the squash commit message from the PR description, which contains the structured body bullets. This moves the system from Micro-Atomic History (line-level changes) to Macro-Atomic History (intent-level changes), reducing history noise by 70-90% while preserving 100% of the semantic metadata needed for changelogs.

Architectural Pillars

Pillar 1: git bisect is formally deprioritized

In ML-heavy systems, regressions are more frequently caused by data/config drift or high-level logic errors, not single-line syntax bugs. Unit/eval tests catch logic bugs faster than history-walking. Maintaining perfect stacked-diff history for bisect represents “Process Debt” that violates SVA (Smallest Viable Architecture) principles — especially without specialized wrappers like Graphite or Sapling.

Consequence: We do not need atomic commit history within feature branches. The topic branch is a transient development workspace. Only the squashed commit on trunk matters.

Pillar 2: Release-Time Batch Generation (“Ingredients-First”)

Running changelog extraction on every commit adds unnecessary latency to the local dev cycle. Generating the changelog only when the release branch is frozen ensures the artifact reflects the final, immutable state of the release candidate. A single generation event is easier to audit.

However, batch generation at release time requires that input data (commit bodies) was validated continuously — otherwise the generator will fail when needed most.

Consequence: Two decoupled concerns: (a) validate_commit_msg.py runs as a hard gate at every commit, ensuring “ingredients” are parseable; (b) generate_changelog.py runs once at release time, “cooking” the CHANGELOG from validated ingredients.

Pillar 3: Git log as single source of truth

No stateful changelog buffer file (UNRELEASED.md or debian/changelog). The Git log itself is the single source of truth.

Consequence: No “double-entry bookkeeping” risk, no merge conflicts on a buffer file, no manual CLI step.

Pillar 4: LLM-based generation is superseded

The previous approach used LLMs to compose RELEASE_NOTES.md from a diff file. This introduces non-determinism and “hallucination” risks in technical documentation. With structured commit bodies, a deterministic regex parser produces identical output every time — traceability from commit to changelog entry is 1:1.

Consequences

Positive

Negative

Alternatives

Evaluation Criteria

#CriterionWeightWhy it matters
1Output fidelityHighMust produce hierarchical CHANGELOG with sub-items, not flat lists
2Python-nativeHighEcosystem uses Python + uv exclusively (ADR-26001, ADR-26003 rejected Node.js)
3Ecosystem portabilityHighMust work across all ecosystem repos, installable as a package
4Convention complianceMediumHow far from Conventional Commits spec?
5Maintenance burdenMediumWho maintains the tooling?
6ExtensibilityMediumCan it adapt as needs evolve?
7Adoption frictionLow-MedHow hard for contributors to learn?

1. git-cliff (Rust binary) — REJECTED

git-cliff is a Rust-based changelog generator with regex parsers and Tera templates. split_commits: true processes each body line as a separate entry. This would turn each bullet into its own changelog line. But it cannot produce hierarchical output (subject as parent, bullets as children) — all lines become flat, same-level entries. Additionally, while pip install git-cliff exists, it wraps a Rust binary, breaking the Python-only ecosystem constraint (ADR-26001).

CriterionScoreNotes
Output fidelityPartialFlat output only. No parent-child hierarchy. Would need post-processing.
Python-nativeNoRust binary. pip install git-cliff wraps Rust. Breaks ADR-26001.
Ecosystem portabilityGoodConfigurable in pyproject.toml, pip-installable
Convention complianceHighStandard CC, no custom body format needed
Maintenance burdenLowCommunity-maintained (10k+ GitHub stars)
ExtensibilityMediumTera templates are powerful but limited to what git-cliff exposes

2. git-changelog (Python + Jinja2) — REJECTED

git-changelog is a Python changelog generator with Jinja2 templates and CC parsing. Body included as a block in the template context. No bullet extraction. Jinja2 templates can render the body as-is but cannot decompose it into structured sub-items without custom Python filters — body bullet extraction is not a first-class feature.

CriterionScoreNotes
Output fidelityPartialBody as a block. Jinja2 COULD split on \n- but it’s a hack.
Python-nativeYesPure Python
Ecosystem portabilityGoodpip-installable, Jinja2 templates
Maintenance burdenLowCommunity-maintained
ExtensibilityMediumBody decomposition requires custom Jinja2 filters

3. Commitizen (Python framework) — REJECTED

Commitizen is a Python framework for guided commits, version bumping, and changelog generation. Has changelog_message_builder_hook that can generate multiple changelog entries from a single commit. However, known issues with multiline body handling in custom configs — each line may appear as a separate entry incorrectly.

Commitizen is an opinionated framework (guided commits, version bumping), not just a changelog parser. Adopting it means adopting its workflow model, which exceeds our need for a focused parsing tool.

CriterionScoreNotes
Output fidelityPossibleWith changelog_message_builder_hook and custom template, but fragile.
Python-nativeYesPure Python
Maintenance burdenLow-MedCommunity-maintained but custom hooks are our code
ExtensibilityHighFull Python plugin system, custom rules, templates

4. Hybrid: git-changelog + custom Jinja2 filter — REJECTED

Use git-changelog as the parsing engine (handles git log, CC parsing, version grouping) but add a custom Jinja2 filter that decomposes body text into bullet sub-items. Less code to write than a full custom parser. But it creates a dependency on git-changelog’s internal data model — if git-changelog changes how it exposes body text, the filter breaks.

CriterionScoreNotes
Output fidelityGoodgit-changelog handles structure, custom filter handles bullets
Python-nativeYesBoth are Python
Maintenance burdenLow-MedOnly the filter is our code
ExtensibilityMediumLimited by what git-changelog exposes to templates

5. Debian dch (stateful metadata buffer) — REJECTED

The dch approach introduces the concept of a Changelog Buffer. Instead of the Git history being the sole source of truth, a structured file (e.g., UNRELEASED.md) acts as a staging area. While this provides higher editorial control, it introduces “Double-Entry Bookkeeping” — the delta between what was actually committed and what was recorded in the changelog buffer can drift.

Critical problems:

Mitigation adopted: The “Unreleased Stanza” concept is preserved — generate_changelog.py can output commits since the last tag, providing the same “what’s pending” visibility without a separate file.

6. Stacked Diffs — REJECTED

Maintain atomic commit history within feature branches for git bisect granularity. Each diff = separate PR, reviewed independently.

Critical problems:

7. LLM-Based Generation (previous approach) — SUPERSEDED

Uses LLMs to compose RELEASE_NOTES.md from a diff file. Zero effort for developers. But non-deterministic: same input can produce different output across runs. No audit trail from commit to changelog entry. Violates traceability requirements.

8. Custom Python Parser (spoke package) — SELECTED

A custom Python package published as a spoke package in the ecosystem. Understands the structured body convention natively. First-class bullet extraction. Knows that - Verb: file-path — what_and_why lines are changelog sub-items. Ignores prose lines. Excludes ArchTag and trailers.

The only approach that can produce the exact hierarchical CHANGELOG format natively — all others require hacks or post-processing. Fits the ecosystem model perfectly: a spoke package that any repo can install via uv add. Full control means it can evolve with the ecosystem.

CriterionScoreNotes
Output fidelityFullDesigned for our exact CHANGELOG format. Hierarchical output is the primary feature.
Python-nativeYesPure Python, follows ADR-26001
Ecosystem portabilityExcellentSpoke package, uv add in any repo. Follows ADR-26020 hub-spoke model.
Convention complianceMediumExtends CC with structured body. Non-standard but well-documented.
Maintenance burdenMediumWe own it. But scope is small (~200 lines parser + tests).
ExtensibilityHighFull control. Can add SemVer detection, RELEASE_NOTES skeleton later.

Decision Matrix

Criteriongit-cliffgit-changelogcommitizenCustom parserHybriddchStackedLLM
Output fidelitypartialpartialpossiblefullgoodgoodN/Apartial
Python-nativenoyesyesyesyesyesyesyes
Ecosystem portabilitygoodgoodgoodexcellentgoodpoorpoormedium

References

Participants

  1. Vadim Rudakov

  2. Senior DevOps Systems Architect (Gemini)

  3. Claude Opus 4.6