Skip to main content

Changelog

Generate changelogs from git commits

The changelog stage generates release notes from git commits between the previous tag and the current tag. The standalone anodizer changelog command is the unified front door for that engine: it refreshes the in-repo CHANGELOG.md, emits a GitHub-release body, or dumps structured JSON — all from the same grouped-and-filtered commit history the release pipeline uses.

The anodizer changelog command

anodizer changelog [<tag>|<range>] [--format keep-a-changelog|release-notes|json] [--write] [--crate <name>] [--snapshot]
Flag / argDefaultEffect
[<tag>|<range>]last-tag..HEADCommit range to render (see Selecting a range)
--formatkeep-a-changelogOutput shape: refresh the [Unreleased] section, a GitHub-release body, or JSON
--writeoff (preview)Apply the regenerated [Unreleased] to the configured CHANGELOG.md in place (keep-a-changelog only)
--crate <name>all selected cratesRestrict to one crate in a workspace
--snapshotoffRender as a snapshot release (release-notes only)

There is no --output/-o (redirect stdout instead), no --from/--to (use the positional range), and no check changelog subcommand.

Selecting a range

The positional arg drives every format identically — the same arg surfaces the same commits whether you render keep-a-changelog, release-notes, or json.

ArgLower boundRenders
(omitted)each crate's last release tagthe pending [Unreleased] window (since the last release)
..none — start of historyfull history → HEAD
..<ref>none — start of historyfull history → <ref>
<from>..<from><from> → HEAD
<from>..<to><from><from><to>
<tag>the tag's predecessorexactly that release's entries
anodizer changelog                       # omit → each crate's pending window (since last release)
anodizer changelog ..                    # full history → HEAD
anodizer changelog ..v1.2.0              # full history → v1.2.0
anodizer changelog v1.0.0..v1.2.0        # explicit range
anodizer changelog v1.2.0                # one release's slice: predecessor..v1.2.0

An empty lower bound (a leading ..) always means "from the beginning of history." Omitting the arg entirely is different: it is the pending window, bounded at each crate's last release tag. So anodizer changelog .. (full history) and anodizer changelog (since last release) are distinct — and .. and ..HEAD are the same (both full history to HEAD).

A single <tag> resolves the owning crate from its tag prefix (core-v0.2.0 → the core crate) and bounds the range at the predecessor tag — the tag immediately below it in that crate's semver-sorted list — so you get exactly that release's entries. A tag that is the earliest in its series has no predecessor, so it falls back to full history up to that tag.

--format keep-a-changelog (default) — refresh [Unreleased]

Regenerates the ## [Unreleased] section of the configured CHANGELOG.md in Keep-a-Changelog form. A bare command previews to stdout and writes nothing:

$ anodizer changelog
## [Unreleased]

### Features

* a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 add config validation

### Bug Fixes

* e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 handle empty target list

By default each line is * {{ SHA }} {{ Message }} with the full hash — the conventional-commit feat: / fix: prefix is stripped into the group heading. Set abbrev to truncate the hash and format to reshape the line (see the config fields below).

--write applies that regenerated [Unreleased] to the file in place. It preserves every released section and the compare-link footer — it rewrites only [Unreleased], and it does not promote/roll [Unreleased] to a dated ## [x.y.z] version (that's anodizer tag --changelog):

$ anodizer changelog --write
changelog: refreshed CHANGELOG.md [Unreleased]

--write is valid only with --format keep-a-changelog; pairing it with release-notes/json errors (those stream to stdout for you to redirect).

--format release-notes — GitHub release body to stdout

Emits the grouped-bullet markdown anodizer posts as the GitHub release body. Redirect stdout to capture it:

$ anodizer changelog --format release-notes
## Changelog

### Features

* a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 add config validation

### Bug Fixes

* e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 handle empty target list
anodizer changelog --format release-notes > NOTES.md            # capture to a file
anodizer changelog v1.2.0 --format release-notes                # body for one release
anodizer changelog --snapshot --format release-notes            # preview the pending window pre-tag

To preview the pending window before the release is tagged — when HEAD is ahead of the last release tag — pass --snapshot. Without it, release-notes requires HEAD to point at a tag and errors otherwise. The snapshot preview is bounded at the last release tag (the same since-last-release window as keep-a-changelog and json), not full history.

--format json — structured array to stdout

Emits a JSON array, one object per selected crate, sorted by crate name. Each object is { crate, from, to, groups }, where every group carries entries (with summary, sha, full_sha, authors) and nested subgroups:

$ anodizer changelog v1.2.0 --format json
[
  {
    "crate": "myapp",
    "from": "v1.1.0",
    "to": "v1.2.0",
    "groups": [
      {
        "title": "Features",
        "entries": [
          {
            "summary": "add config validation",
            "sha": "a1b2c3d",
            "full_sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
            "authors": ["Jane Dev"]
          }
        ],
        "subgroups": []
      }
    ]
  }
]

from is null for full history; to resolves to HEAD when the range is unbounded.

End-to-end: preview → write → edit → tag

The standalone command and the tag-time promotion compose into one flow:

anodizer changelog              # 1. preview the pending [Unreleased]
anodizer changelog --write      # 2. refresh CHANGELOG.md's [Unreleased] in place
# 3. hand-edit the [Unreleased] section, then commit it
anodizer tag --changelog        # 4. promote [Unreleased] → [x.y.z] - <date>,
                                #    preserving your committed edits verbatim

Step 4 is opt-in via --changelog; see Auto-Tagging for the tag-time refresh.

Minimal config

Changelog generation works with no config — it collects all commits since the last tag.

Changelog config fields

FieldTypeDefaultDescription
sortstringascSort order: asc or desc
usestringgitSource: git (commit parsing), github (fetch commits via GitHub API), or github-native (GitHub's generated notes)
abbrevint0Hash length: 0 = full SHA, N = truncate to N chars, -1 = omit the hash
skipbool/templatefalseSkip changelog generation (alias: disable)
headerstringnoneText prepended to changelog
footerstringnoneText appended to changelog
filters.excludelistnoneRegex patterns to exclude commits
filters.includelistnoneRegex patterns to include (whitelist)
groupslistnoneGroup commits by pattern

Commit grouping

Group conventional commits by type:

changelog:
  groups:
    - title: "Features"
      regexp: "^feat"
      order: 0
    - title: "Bug Fixes"
      regexp: "^fix"
      order: 1
    - title: "Documentation"
      regexp: "^docs"
      order: 2
    - title: "Other"
      regexp: ".*"
      order: 99

Filtering commits

changelog:
  filters:
    exclude:
      - "^chore"
      - "^ci"
      - "Merge pull request"

Changelog destination

In a workspace, changelog: chooses where released sections land: a shared root CHANGELOG.md, a per-crate crates/<name>/CHANGELOG.md, or both. Two fields drive it:

FieldTypeDefaultDescription
files.per_crateboolfalseWrite each crate's section to crates/<name>/CHANGELOG.md
files.rootblockon unless files.per_crate: trueWrite the shared root CHANGELOG.md; presence forces it on
files.root.chronologystringdateSection ordering in a multi-track root: date or tag
files.root.crateslistallWhich crates contribute a section to the root

The resolved destination follows one rule: the root is on when a files.root: block is present or files.per_crate is not true. That yields three outcomes, each shown below.

Root only (the default — a bare block aggregates into the workspace root):

changelog: {}            # root CHANGELOG.md

Per-crate files (each crate keeps its own changelog, no root):

changelog:
  files:
    per_crate: true      # crates/<name>/CHANGELOG.md, one per crate

Both (per-crate files and the shared root):

changelog:
  files:
    per_crate: true
    root: {}             # crates/<name>/CHANGELOG.md AND root CHANGELOG.md

Single-crate, lockstep, and shared-prefix flat-crate roots are flat: one aggregated section per release covering the whole workspace (the flat-aggregate shape below covers the last case). files.root.crates filters which crates contribute a section to the root:

changelog:
  files:
    root:
      crates: ["core", "cli"]   # only these crates appear in the root changelog

Commit scoping

Commit scoping is derived — no config required. A per-crate track collects commits that touch its own crate directory; the root aggregate spans every crate directory plus the workspace manifests (Cargo.toml, Cargo.lock). Each crate's changelog stays focused on its own history automatically.

changelog.paths is an optional advanced intersect filter that narrows the derived scope further — list extra path globs to additionally restrict which commits a track considers. It only subtracts from the automatic per-directory scope; you never need it to get correct per-crate or aggregate scoping.

Workspace shapes at a glance

anodizer classifies a repo into one of four shapes from its config + Cargo metadata, and the changelog (and tag, and bump --commit) follow from the shape. The config signal is the only thing you set:

ShapeConfig signalTag behaviorChangelog shape
Singleone crate, or no configone v* tagone flat section
Lockstep[workspace.package].version in root Cargo.tomlone shared v* tagone flat section
Flat-aggregateflat crates: list, every tag_template resolves to the same prefix, per-crate [package].versionone shared v* tagone flat section
Multi-trackflat crates: list (or workspaces:) with distinct tag prefixes (core-v, cli-v)per-crate tags### <crate> subsection per track

Flat-aggregate: one shared tag, one flat section

A flat crates: list whose members all resolve to the same tag prefix (every tag_template is v{{ Version }}, or all acme-v{{ Version }}) releases in lockstep: one tag namespace can't hand v0.2.0 to two crates independently. anodizer treats this exactly like a lockstep workspace — one shared v* tag, one flat [Unreleased]/released section — even though each crate carries its own [package].version (rather than a single [workspace.package].version):

crates:
  - name: core
    path: crates/core
    tag_template: "v{{ Version }}"   # same prefix
  - name: cli
    path: crates/cli
    tag_template: "v{{ Version }}"   # as every other member
$ anodizer changelog
## [Unreleased]

### Features

* a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 add config validation

No ### core / ### cli graft appears — the section is flat, identical to a lockstep root. anodizer tag --changelog and anodizer bump --commit --changelog promote that one flat section under a single ## [v0.2.0] - <date> heading. To get per-crate ### <crate> subsections instead, give each crate a distinct tag prefix (the multi-track shape below).

Coherence: members must agree on [package].version

In a flat-aggregate one tag carries one version, so every member's [package].version must agree. If they diverge, changelog, tag, and bump all error before doing anything — no partial tag, no partial changelog write:

crates:
  - name: core
    path: crates/core
    tag_template: "v{{ Version }}"   # crates/core/Cargo.toml → version = "0.2.0"
  - name: cli
    path: crates/cli
    tag_template: "v{{ Version }}"   # crates/cli/Cargo.toml  → version = "0.3.0"
$ anodizer changelog
Error: crates 'core' (0.2.0), 'cli' (0.3.0) share tag prefix 'v' but set
different [package].version values; one tag can't carry two versions. For
lockstep set [workspace.package].version; for independent releases give each
crate a distinct tag_template prefix.

Two ways to resolve it, depending on intent:

  • Lockstep — the crates truly ship together under one version: set [workspace.package].version in the root Cargo.toml and have each member inherit it (version.workspace = true). The repo is then a genuine lockstep workspace.
  • Independent releases — the crates ship on their own cadences: give each a distinct tag_template prefix (core-v{{ Version }}, cli-v{{ Version }}), promoting the repo to the multi-track shape.

A member with no literal [package].version (e.g. version.workspace = true, or a virtual manifest) is skipped by the check — it has no concrete version to disagree with.

Multi-track root subsections

When crates release on independent tag tracks (e.g. core-v* and cli-v*), the root CHANGELOG.md holds a ### <crate> subsection per track under ## [Unreleased]. Tagging one track promotes only that crate's subsection to a released ## [<tag>] - <date> heading — regrouped under your groups: headings — and leaves every other track's subsection in place.

Before — curate each track's entries under its own subsection:

## [Unreleased]

### core
- add the retry budget

### cli
- new `--watch` flag

[Unreleased]: https://github.com/acme/proj/compare/core-v0.1.0...HEAD

After anodizer tag on the core track — ### core is promoted, ### cli stays untouched, and the compare footer rolls to the core tag:

## [Unreleased]

### cli
- new `--watch` flag

## [core-v0.2.0] - 2026-06-03

### Features
- add the retry budget

[Unreleased]: https://github.com/acme/proj/compare/core-v0.2.0...HEAD
[core-v0.2.0]: https://github.com/acme/proj/compare/core-v0.1.0...core-v0.2.0

Chronology: date vs tag

files.root.chronology orders the released sections in a multi-track root. Given two tracks core-v* and cli-v*, the same set of releases renders differently:

chronology: date (default)chronology: tag
Newest ship date on top, tracks interleavedClustered by tag-prefix, semver-descending within a cluster
# chronology: date — interleaved by release date
## [cli-v0.4.0] - 2026-06-03
## [core-v0.2.0] - 2026-06-01
## [cli-v0.3.0] - 2026-05-20
# chronology: tag — clustered per crate, semver-desc
## [cli-v0.4.0] - 2026-06-03
## [cli-v0.3.0] - 2026-05-20
## [core-v0.2.0] - 2026-06-01

Full example

changelog:
  sort: desc
  header: |
    ## Changelog
  filters:
    exclude:
      - "^chore"
      - "^ci"
  groups:
    - title: "Features"
      regexp: "^feat"
      order: 0
    - title: "Bug Fixes"
      regexp: "^fix"
      order: 1
    - title: "Other"
      regexp: ".*"
      order: 99