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.0 diff links, or update them from ...main to ...vX.Y.Z at 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.Z references 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 that changelog.yaml uses.

  • No citation file integration. None update citation.cff release 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.location into 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:

Path

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.

Parameters:

config (Config) – The resolved [tool.repomatic] configuration.

Return type:

tuple[Path, str] | None

Returns:

(changelog_path, repo_url), or None after logging why neither could be determined (no changelog on disk, or no comparison link in it).

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:

  1. repomatic.pypi._fetch_json() returns None on 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.

  2. Even when the HTTP status is preserved, a 404 from /pypi/<name>/json is not authoritative: Warehouse 404s registered projects that have no published releases, and registered packages can appear in the simple / list_packages indexes 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 --fix against 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 .devN suffix VERSION_TOKEN accepts, 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 of VersionElements.

class repomatic.changelog.VersionElements(compare_url='', date='', version='', availability_admonition='', changes='', development_warning='', editorial_admonition='', yanked_admonition='')[source]

Bases: object

Discrete 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 by Changelog.decompose_version() and used by the release-notes template to render the ## heading line. Body fields are unchanged.

compare_url: str = ''

GitHub comparison URL from the heading (e.g. repo/compare/vA...vB).

date: str = ''

Release date or unreleased label from the heading.

version: str = ''

Version string extracted from the heading (e.g. 1.2.3).

availability_admonition: str = ''

[!NOTE] or [!WARNING] block for platform availability.

changes: str = ''

Hand-written changelog entries (bullet points, prose).

development_warning: str = ''

[!WARNING] block for unreleased versions under active development.

editorial_admonition: str = ''

Hand-written GFM alert blocks not matching auto-generated patterns.

Multiple blocks are joined with double newlines.

yanked_admonition: str = ''

[!CAUTION] block for releases yanked from PyPI.

class repomatic.changelog.Changelog(initial_changelog=None, current_version=None)[source]

Bases: object

Helpers 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 what freeze() 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:

str

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-notes template.

Returns False for 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.

Parameters:
  • release_date (str | None) – Date in YYYY-MM-DD format. Defaults to today (UTC).

  • default_branch (str) – Branch name for comparison URL. Must match what update() used to write it, per that method’s note.

Return type:

bool

Returns:

True if the content was modified.

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.

Parameters:
  • path (Path) – Path to the changelog file.

  • version (str) – Current version string.

  • release_date (str | None) – Date in YYYY-MM-DD format. Defaults to today (UTC).

  • default_branch (str) – Branch name for comparison URL.

Return type:

bool

Returns:

True if the file was modified.

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:

str

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.

Return type:

list[tuple[str, str]]

Returns:

List of (version, date) tuples ordered as they appear in the changelog (newest first).

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.

Return type:

set[str]

Returns:

Set of version strings found in headings.

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.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • date (str) – Release date in YYYY-MM-DD format.

  • repo_url (str) – Repository URL for comparison links.

  • all_versions (list[str]) – All known versions sorted descending.

Return type:

bool

Returns:

True if the content was modified.

update_comparison_base(version, new_base)[source]

Replace the base version in a version heading’s comparison URL.

Changes compare/vOLD...vX.Y.Z to compare/vNEW...vX.Y.Z in the heading for the given version.

Parameters:
  • version (str) – The version whose heading to update.

  • new_base (str) – New base version (without v prefix).

Return type:

bool

Returns:

True if the content was modified.

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 as changes.

Parameters:

version (str) – Version string (e.g. 1.2.3).

Return type:

VersionElements

Returns:

A VersionElements with each field populated.

replace_section(version, new_section)[source]

Replace the entire section (heading + body) for a version.

Locates the version heading and replaces everything up to the next ## heading (or EOF) with new_section.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • new_section (str) – New section content including heading.

Return type:

bool

Returns:

True if the content was modified.

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. When True, uses “is the first version available on” wording.

Return type:

str

Returns:

A > [!NOTE] admonition block, or empty string if neither URL is provided.

repomatic.changelog.build_unavailable_admonition(version, *, missing_pypi=False, missing_github=False)[source]

Build a GFM warning admonition for platforms missing a version.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • missing_pypi (bool) – Whether the version is missing from PyPI.

  • missing_github (bool) – Whether the version is missing from GitHub.

Return type:

str

Returns:

A > [!WARNING] admonition block, or empty string if neither platform is missing.

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 in VersionElements.changes.

Return type:

list[str]

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:

int

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 threshold words emits a logging.WARNING and 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.

Parameters:
  • changelog (Changelog) – The parsed changelog to inspect.

  • threshold (int) – Word ceiling per bullet. 0 (or less) disables the check.

Return type:

None

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 VersionElements fields, 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.

Parameters:

changelog (Changelog) – The parsed changelog to inspect.

Return type:

None

class repomatic.changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, github_fetch_failed=False)[source]

Bases: object

The 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.

package: str | None

PyPI package name, None when the project publishes nothing.

pypi_data: dict[str, PyPIRelease]

Released versions on PyPI, keyed by version string.

repo_url: str

Repository URL read out of the changelog’s own comparison links.

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 use_pypi: bool

Whether PyPI is the reference source, rather than git tags.

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 Version per 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.

classmethod load(changelog, package, pypi_package_history, *, force_refresh=False)[source]

Fetch both release sources, reporting what each answered.

Parameters:
  • changelog (Changelog) – Parsed changelog, read for its repository URL.

  • package (str | None) – PyPI package name, auto-detected when None.

  • pypi_package_history (Sequence[str] | None) – Former package names to merge in.

  • force_refresh (bool) – Bypass the caches, for a live re-confirmation.

Return type:

ReleaseSources

retracted_versions(changelog, releases)[source]

Versions whose section claims availability these lookups now deny.

An available platform renders as a markdown link and a missing one as a bare label, so the [ prefix is what separates a claim of presence from one of absence.

Return type:

set[str]

class repomatic.changelog.DateCheck(corrections: dict[str, str], mismatched: bool, unfixed: bool)[source]

Bases: NamedTuple

What comparing every changelog date against its reference source found.

Create new instance of DateCheck(corrections, mismatched, unfixed)

corrections: dict[str, str]

Version to the date it should carry, for the versions --fix repairs.

mismatched: bool

Whether any version’s date disagreed with its reference source.

unfixed: bool

Whether a mismatch was left standing (no --fix to apply).

class repomatic.changelog.OrphanReconciliation(releases: list[tuple[str, str]], found: bool, modified: bool, unfixed: bool)[source]

Bases: NamedTuple

What reconciling versions missing from the changelog produced.

Create new instance of OrphanReconciliation(releases, found, modified, unfixed)

releases: list[tuple[str, str]]

Documented releases, re-read when insertions changed the file.

found: bool

Whether any orphan was detected at all.

modified: bool

Whether a section was actually inserted.

unfixed: bool

Whether an orphan was left in place, unrepaired.

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, and warn_on_empty_sections() over the released ones.

When fix is 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-changelog workflow job skips this function during the release cycle (when release_commits_matrix is 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 under fix) for entries split out of the live changelog. Archived dates are not re-validated.

  • package (str | None) – PyPI package name. If None, auto-detected from pyproject.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 the not found on PyPI warning, 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 (see warn_on_long_bullets()). 0 disables the check. Never affects the exit code.

Return type:

int

Returns:

0 if all dates match or references were corrected in-place, 1 if any date mismatch or orphan is found without a fix being applied, 2 if 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.