repomatic.changelog module¶
Changelog parsing, updating, and release lifecycle management.
This module is the single source of truth for all changelog management decisions and operations. It handles two phases of the release cycle:
Post-release (unfreeze) — Changelog.update():
Decomposes the latest release section via Changelog.decompose_version(),
transforms the elements into an unreleased entry (date → unreleased,
comparison URL → ...main, body → development warning), renders via the
release-notes template, and prepends the result to the changelog.
Release preparation (freeze) — Changelog.freeze():
Decomposes the current unreleased section, sets the release date, freezes
the comparison URL to ...vX.Y.Z, clears the development warning,
renders via the release-notes template, and replaces the section
in place.
Both operations follow the same decompose → modify → render → replace
pattern, with the release-notes.md template as the single source of
truth for section layout. Both are idempotent: re-running them produces
the same result. This is critical for CI workflows that may be retried.
Note
This is a custom implementation. After evaluating all major alternatives — towncrier, commitizen, python-semantic-release, generate-changelog, release-please, scriv, and git-changelog (see issue #94) — none were found to cover even half of the requirements.
Why not use an off-the-shelf tool?¶
Existing tools fall into two camps, neither of which fits:
Commit-driven tools (python-semantic-release, commitizen, generate-changelog, release-please) auto-generate changelogs from Git history. This conflicts with the project’s philosophy of hand-curated changelogs: entries are written for users, consolidated by hand, and summarize only changes worth knowing about. Auto-generated logs from developer commits are too noisy and don’t account for back-and-forth during development.
Fragment-driven tools (towncrier, scriv) avoid merge conflicts by using per-change files, but handle none of the release orchestration: comparison URL management, GFM warning lifecycle, workflow action reference freezing, or the two-commit freeze/unfreeze release cycle. The multiplication of files across the repo adds complexity, and there is no 1:1 mapping between fragments and changelog entries.
Specific gaps across all evaluated tools:
No comparison URL management. None generate GitHub
v1.0.0...v1.1.0diff links, or update them from...mainto...vX.Y.Zat release time.No unreleased section lifecycle. None manage the
[!WARNING]GFM alert warning that the version is under active development, inserting it post-release and removing it at release time.No workflow action reference freezing. None handle the freeze/unfreeze cycle for
@main↔@vX.Y.Zreferences in workflow files.No two-commit release workflow. None support the freeze commit (
[changelog] Release vX.Y.Z) plus unfreeze commit ([changelog] Post-release bump) pattern thatchangelog.yamluses.No citation file integration. None update
citation.cffrelease dates.No version bump eligibility checks. None prevent double version increments by comparing the current version against the latest Git tag with a commit-message fallback.
The custom implementation in this module is tightly integrated with the release workflow. Adopting any external tool would require keeping most of this code and adding a new dependency — more complexity, not less.
- repomatic.changelog.resolved_changelog_path(config)[source]¶
Absolute path of the configured changelog.
The one derivation of
[tool.repomatic] changelog.locationinto a filesystem path, so every consumer (the CLI commands, the release freeze, the metadata singleton, the setup guide) resolves the same file the same way.- Return type:
- repomatic.changelog.load_changelog_repo(config)[source]¶
Load the configured changelog and read the repository URL out of it.
The shared preamble of the two release-notes syncers, which both need the changelog’s path (to read sections from) and the repository it belongs to (to address the GitHub API with). The URL comes from the changelog’s own comparison links rather than from the git remote, so a project whose changelog points elsewhere stays authoritative.
- repomatic.changelog.AVAILABLE_VERB = 'is available on'¶
Verb phrase for versions present on a platform.
- repomatic.changelog.CHANGELOG_HEADER = '# Changelog\n'¶
Default changelog header for empty changelogs.
- repomatic.changelog.EMPTY_PYPI_SANITY_THRESHOLD = 3¶
Minimum number of existing PyPI links in the changelog above which an empty PyPI lookup is treated as a transient failure rather than a genuine “package has no releases” state.
Note
Two layers of ambiguity make this threshold necessary:
repomatic.pypi._fetch_json()returnsNoneon every failure mode (HTTP 4xx/5xx, network error, timeout, JSON parse error), collapsing “package not on PyPI” and “transient API failure” into the same empty result.Even when the HTTP status is preserved, a
404from/pypi/<name>/jsonis not authoritative: Warehouse 404s registered projects that have no published releases, and registered packages can appear in thesimple/list_packagesindexes while still 404’ing on the JSON endpoint. See pypi/warehouse#1388 and pypi/warehouse#9536.
The threshold guards against a transient failure silently stripping every PyPI link from the changelog. Re-runs of
lint-changelog --fixagainst a healthy API restore the file.
- repomatic.changelog.FIRST_AVAILABLE_VERB = 'is the *first version* available on'¶
Verb phrase for the inaugural release on a platform.
- repomatic.changelog.GITHUB_LABEL = '🐙 GitHub'¶
Display label for GitHub releases in admonitions.
- repomatic.changelog.GITHUB_RELEASE_URL = '{repo_url}/releases/tag/v{version}'¶
GitHub release page URL for a specific version.
- repomatic.changelog.NOT_AVAILABLE_VERB = 'is **not available** on'¶
Verb phrase for versions missing from a platform.
- repomatic.changelog.SECTION_START = '##'¶
Markdown heading level for changelog version sections.
- repomatic.changelog.YANKED_DEDUP_MARKER = 'yanked from PyPI'¶
Dedup marker for the yanked admonition to prevent duplicate insertion.
- repomatic.changelog.RELEASE_VERSION_TOKEN = '\\d+\\.\\d+\\.\\d+'¶
Regex fragment for a final release version, like
1.2.3.The strict half of the version vocabulary: it deliberately rejects the
.devNsuffixVERSION_TOKENaccepts, because a changelog documents only final releases. Anything keyed off a published version (a dated heading, a comparison URL) uses this one.
- repomatic.changelog.GFM_ALERT_RE = re.compile('(?:^>.*$\\n?)+', re.MULTILINE)¶
A GFM alert block: consecutive lines starting with
>.The shape every changelog admonition takes (the development warning, the PyPI availability note, a yanked caution), matched wherever a section’s blocks are inventoried.
- repomatic.changelog.VERSION_COMPARE_PATTERN = re.compile('v\\d+\\.\\d+\\.\\d+\\.\\.\\.v\\d+\\.\\d+\\.\\d+')¶
Pattern matching GitHub comparison URLs like
v1.0.0...v1.0.1.
- repomatic.changelog.RELEASED_VERSION_PATTERN = re.compile('^##\\s*\\[`?(?P<version>\\d+\\.\\d+\\.\\d+)`?\\s+\\((?P<date>\\d{4}-\\d{2}-\\d{2})\\)\\]', re.MULTILINE)¶
Pattern matching released version headings with dates.
Captures version and date from headings like
## `5.9.1 (2026-02-14) <...>`_. Skips unreleased versions which use(unreleased)instead of a date. Backticks around the version are optional.
- repomatic.changelog.HEADING_PARTS_PATTERN = re.compile('^##\\s*\\[`?(?P<version>\\d+\\.\\d+\\.\\d+(?:\\.\\w+)?)`?\\s+\\((?P<date>[^)]+)\\)\\]\\((?P<url>[^)]+)\\)', re.MULTILINE)¶
Pattern extracting version, date/label, and URL from a heading.
Used by
Changelog.decompose_version()to populate the heading fields ofVersionElements.
- class repomatic.changelog.VersionElements(compare_url='', date='', version='', availability_admonition='', changes='', development_warning='', editorial_admonition='', yanked_admonition='')[source]¶
Bases:
objectDiscrete building blocks of a changelog version section.
Each field is a pre-formatted markdown block (or empty string when absent). Templates compose these elements into the final section layout. Empty variables produce empty strings, which
render_template’s 3+ newline collapsing handles gracefully.Heading fields (
compare_url,date,version) are populated byChangelog.decompose_version()and used by therelease-notestemplate to render the##heading line. Body fields are unchanged.
- class repomatic.changelog.Changelog(initial_changelog=None, current_version=None)[source]¶
Bases:
objectHelpers to manipulate changelog files written in Markdown.
- update(default_branch='main')[source]¶
Add a new unreleased entry at the top of the changelog.
Decomposes the current version section, transforms it into an unreleased entry (date set to
unreleased, comparison URL retargeted to the default branch, body replaced with the development warning), and prepends it to the changelog.Idempotent: returns the current content unchanged if an unreleased entry already exists.
- Parameters:
default_branch (
str) – Branch name for the comparison URL. Must match whatfreeze()is later given, since the two halves of a release cycle retarget the same URL in opposite directions: a mismatch leaves the released section pointing at a branch that does not exist.- Return type:
- Returns:
The updated changelog content.
- freeze(release_date=None, default_branch='main')[source]¶
Freeze the current unreleased section for release.
Decomposes the current version section, sets the release date, freezes the comparison URL to the release tag, clears the development warning, and re-renders via the
release-notestemplate.Returns
Falsefor three different situations, only one of which is benign: an already-frozen section (idempotent no-op), no version to freeze, and a version whose section is missing. The last one is what a release would otherwise ship an(unreleased)heading over, so it is logged as a warning rather than left to look like the no-op.
- classmethod freeze_file(path, version, release_date=None, default_branch='main')[source]¶
Freeze a changelog file in place.
Reads the file, applies all freeze operations via
freeze(), and writes the result back.
- extract_repo_url()[source]¶
Extract the repository URL from changelog comparison links.
Parses the first
## `... <<repo_url>/compare/...>`_heading and returns the base repository URL (e.g.https://github.com/user/repo).- Return type:
- Returns:
The repository URL, or empty string if not found.
- extract_all_releases()[source]¶
Extract all released versions and their dates from the changelog.
Scans for headings matching
## `X.Y.Z (YYYY-MM-DD) <...>`_. Unreleased versions (with(unreleased)) are skipped.
- extract_all_version_headings()[source]¶
Extract all version strings from
##headings.Includes both released and unreleased versions, so the caller can avoid false-positive orphan detection for the current development version.
- insert_version_section(version, date, repo_url, all_versions)[source]¶
Insert a placeholder section for a missing version.
The section is placed at the correct position in descending version order. The comparison URL points from the next-lower version to this one. After insertion, the next-higher version’s comparison URL base is updated to reference this version, keeping the timeline coherent.
Idempotent: returns False if the version heading already exists.
- update_comparison_base(version, new_base)[source]¶
Replace the base version in a version heading’s comparison URL.
Changes
compare/vOLD...vX.Y.Ztocompare/vNEW...vX.Y.Zin the heading for the given version.
- decompose_version(version)[source]¶
Decompose a version section into discrete elements.
Parses both the heading (version, date, URL) and the body (admonitions, changes).
Classifies each GFM alert block (consecutive
>lines) as one of the auto-generated element types. Everything not classified as auto-generated is preserved aschanges.- Parameters:
version (
str) – Version string (e.g.1.2.3).- Return type:
- Returns:
A
VersionElementswith each field populated.
- repomatic.changelog.build_release_admonition(version, *, pypi_url='', github_url='', first_on_all=False)[source]¶
Build a GFM release admonition with available distribution links.
- Parameters:
version (
str) – Version string (e.g.1.2.3).pypi_url (
str) – PyPI project URL, or empty if not on PyPI.github_url (
str) – GitHub release URL, or empty if no release exists.first_on_all (
bool) – Whether every listed platform is a first appearance. WhenTrue, uses “is the first version available on” wording.
- Return type:
- Returns:
A
> [!NOTE]admonition block, or empty string if neither URL is provided.
Build a GFM warning admonition for platforms missing a version.
- repomatic.changelog.split_changelog_bullets(changes)[source]¶
Split a version section’s change body into top-level bullet entries.
Each returned item is one entry: its
-marker line plus any wrapped continuation lines and indented sub-bullets, joined with newlines. Blank lines and prose outside a bullet are dropped.- Parameters:
changes (
str) – The hand-written body of a version section, as captured inVersionElements.changes.- Return type:
- Returns:
One string per top-level bullet, in document order.
- repomatic.changelog.count_bullet_words(bullet)[source]¶
Count the words in a changelog bullet, ignoring list markers.
Leading
-/*markers (on the entry and any nested sub-bullets) are stripped so they do not inflate the count; everything else, including inline code and link text, counts as written.- Return type:
- repomatic.changelog.warn_on_long_bullets(changelog, threshold)[source]¶
Warn about over-long bullets in the unreleased section, non-fatally.
A changelog entry is a release note, not a commit message: one short sentence stating what changed. Canonical guideline: https://github.com/kdeldycke/repomatic/blob/main/claude.md#changelog-entry-length Each unreleased bullet longer than
thresholdwords emits alogging.WARNINGand a GitHub Actions warning annotation, without affecting the lint exit code.Only the unreleased section is inspected. Released sections are immutable, so re-flagging historical entries on every run would be noise.
- repomatic.changelog.warn_on_empty_sections(changelog)[source]¶
Warn about released sections holding no entry, non-fatally.
A published heading with nothing under it reads as broken to anyone scanning release notes, and it is not merely cosmetic: the GitHub release body is rebuilt from this section, so an empty one publishes an empty release.
claude.md§ Changelog and docs updates gives the fix, which is to name what actually moved rather than to leave the section blank.Only released sections are inspected. The unreleased section is legitimately empty for most of a cycle, since the post-release bump creates it with no entries, so flagging it would fire on every push in the hours after a release and train the reader to ignore the check. That is the mirror of
warn_on_long_bullets(), which inspects the unreleased section alone because re-flagging immutable history is the noise there.Availability, editorial and yanked admonitions live in their own
VersionElementsfields, so a section carrying nothing but a[!WARNING]about missing binaries still counts as empty: an admonition explains a caveat, it does not say what changed.
- class repomatic.changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, github_fetch_failed=False)[source]¶
Bases:
objectThe external lookups a changelog’s dates and availability are checked against.
Both lookups are TTL-cached, and the boundary versions derived from them used to be recomputed by hand after a forced refresh: deriving them here means a refresh cannot leave a stale boundary behind. Frozen so the boundary properties can be cached: a refreshed half swaps in through
dataclasses.replace, which is what actually guarantees the derivations can never go stale against an in-place edit.- pypi_data: dict[str, PyPIRelease]¶
Released versions on PyPI, keyed by version string.
- github_releases: dict[str, GitHubRelease]¶
Published GitHub releases, keyed by version string.
- github_fetch_failed: bool = False¶
Whether the GitHub lookup errored, as opposed to answering empty.
- property first_pypi_version: Version | None[source]¶
Oldest version on PyPI, for the predates-the-index boundary.
Cached: the fix loop reads the boundary several times per version, and each uncached read parsed a
Versionper PyPI release all over again.
- property first_github_version: Version | None[source]¶
Oldest GitHub release, for the predates-the-releases boundary.
Cached, like
first_pypi_version.
- class repomatic.changelog.DateCheck(corrections: dict[str, str], mismatched: bool, unfixed: bool)[source]¶
Bases:
NamedTupleWhat comparing every changelog date against its reference source found.
Create new instance of DateCheck(corrections, mismatched, unfixed)
- class repomatic.changelog.OrphanReconciliation(releases: list[tuple[str, str]], found: bool, modified: bool, unfixed: bool)[source]¶
Bases:
NamedTupleWhat reconciling versions missing from the changelog produced.
Create new instance of OrphanReconciliation(releases, found, modified, unfixed)
- repomatic.changelog.lint_changelog_dates(changelog_path, package=None, *, archive_path=None, fix=False, pypi_package_history=(), abandoned_versions=(), bullet_word_threshold=0)[source]¶
Verify that changelog release dates match canonical release dates.
Uses PyPI upload dates as the canonical reference when the project is published to PyPI. Falls back to git tag dates for projects not on PyPI.
Versions older than the first PyPI release are expected to be absent and logged at info level. Versions newer than the first PyPI release but missing from PyPI are unexpected and logged as warnings.
Also detects orphaned versions: versions that exist as git tags, GitHub releases, or PyPI packages but have no corresponding changelog entry. Orphans are logged as warnings and cause a non-zero exit code.
Two non-fatal content checks run first and never affect the exit code:
warn_on_long_bullets()over the unreleased section, andwarn_on_empty_sections()over the released ones.When
fixis enabled, date mismatches are corrected in-place and admonitions are added to the changelog:A
[!NOTE]admonition listing available distribution links (PyPI, GitHub) for each version. Links are conditional: only sources where the version exists are included.A
[!WARNING]admonition listing platforms where the version is not available (missing from PyPI, GitHub, or both).A
[!CAUTION]admonition for yanked releases.
Caution
The
fix-changelogworkflow job skips this function during the release cycle (whenrelease_commits_matrixis non-empty). At that point the release pipeline hasn’t published to PyPI or created a GitHub release yet, so this function would incorrectly add “not available” admonitions to the freshly-released version.Placeholder sections for orphaned versions, with comparison URLs linking to adjacent versions.
- Parameters:
changelog_path (
Path) – Path to the changelog file.archive_path (
Path|None) – Optional path to a frozen changelog archive. Versions documented there are treated as present, suppressing false-positive orphan detection (and re-insertion underfix) for entries split out of the live changelog. Archived dates are not re-validated.package (
str|None) – PyPI package name. IfNone, auto-detected frompyproject.toml. If detection fails, falls back to git tags.fix (
bool) – If True, fix dates and add admonitions to the file.pypi_package_history (
Sequence[str]) – Former PyPI package names for renamed projects. Releases from each former name are merged into the lookup table so versions published under old names are recognized. The current package name wins on version collisions.abandoned_versions (
Sequence[str]) – Versions documented in the changelog but never published. Each listed version is reported as skipped (info log) instead of triggering thenot found on PyPIwarning, for both the PyPI lookup and the git-tag fallback. Use for releases that were frozen but skipped per the “skip and move forward” practice (botched build, broken artifact).bullet_word_threshold (
int) – Word count above which an unreleased-section bullet triggers a non-fatal length warning (seewarn_on_long_bullets()).0disables the check. Never affects the exit code.
- Return type:
- Returns:
0if all dates match or references were corrected in-place,1if any date mismatch or orphan is found without a fix being applied,2if the sanity gate refused a destructive rewrite because an upstream data source (GitHub Releases or PyPI) appeared to be returning incomplete or empty results while the existing changelog has substantial coverage on that platform.