ADR 0007 — Changelog-driven versioning (committed literal)¶
Status: Accepted
Date: 2026-07-06
Supersedes: the versioning + release mechanism of ADR 0006. ADR 0006’s other decisions (SBOM, PyPI OIDC, the PR-title guard, protected
main) still hold.
Reusable blueprint. Portable across repos; per-repo variables:
{{DIST}}= PyPI name,{{VERSION_FILE}}= the package__init__.py,{{REPO}}=owner/repo. For this repo:aces-scenario-packs,src/aces_scenario_packs/__init__.py,Brad-Edwards/aces-scenario-packs.
Context¶
The version and the changelog must never disagree. Earlier attempts derived the version from git tags (hatch-vcs) or commit messages (semantic-release), kept the changelog separately, and needed special first-release seeding. Too many moving parts, and drift was possible.
Decision¶
One committed literal is the version; the changelog decides the bump.
1. Version = one committed literal¶
{{VERSION_FILE}} contains exactly __version__ = "X.Y.Z". pyproject.toml
declares dynamic = ["version"] and [tool.hatch.version] path = "{{VERSION_FILE}}"
so hatchling reads that literal. No hatch-vcs, no setuptools-scm, no
semantic-release, no static [project].version.
2. Changelog = towncrier¶
Per PR, add changelog.d/<slug>.<type>.md (types: breaking, security,
added, changed, deprecated, removed, fixed). Never edit CHANGELOG.md
directly.
3. tools/release.py cuts a release¶
It computes the next version from the pending fragment types (rubric below),
writes it into {{VERSION_FILE}}, and runs towncrier build (which writes the
CHANGELOG.md section and consumes the fragments). No git ops — you commit on a
release/vX.Y.Z branch and open a PR to main.
4. release.yml (on push to main)¶
decide: skip if any typed fragment is still uncollated (a real release collates first via
tools/release.py; this blocks the firstmainpromotion from publishing before a release is cut). Otherwise read__version__; release only if it has novX.Y.Ztag and a matching## [X.Y.Z]section exists inCHANGELOG.md(so an un-changelogged version, including the unreleased0.0.0, never ships).release (
environment: pypi,contents: write+id-token: write): create and push thevX.Y.Ztag, build sdist + wheel, generate the SBOM, extract the## [X.Y.Z]changelog section as the notes, publish to PyPI via OIDC, and cut the GitHub Release. Only a tag is pushed — never a commit tomain, so no bot/PAT/deploy-key/bypass is needed. Third-party actions are SHA-pinned.
5. Rubric¶
Pre-1.0 (major == 0): any added/changed/removed/deprecated/breaking →
minor; fixed/security → patch. major >= 1: removed/breaking → major,
added/changed/deprecated → minor, fixed/security → patch. (breaking is
effectively capped to minor until you cut 1.0.)
6. One-time per repo (owner)¶
Register a PyPI trusted publisher (project {{DIST}}, owner, repo, workflow
release.yml, environment pypi) and create a GitHub environment named pypi.
Release flow¶
python tools/release.py → release/vX.Y.Z branch → PR to main → merge →
release.yml tags and publishes. The first release is not special — you run
tools/release.py for 0.1.0 exactly as for every later version. For dependent
repos, publish the dependency first (e.g. aces-sdl before aptl).
Consequences¶
The version is one literal; the build, the tag, and the changelog all read the same number — drift is structurally impossible.
No seeding, no pre-tag, no bypass;
mainonly ever receives a tag.Every user-visible change must add a fragment or it neither appears in the changelog nor moves the version.