repomatic.lint_changelog module¶

Check a changelog against the places its releases are published.

The lint-changelog command reads every released section of the changelog and compares it with three sources: PyPI uploads, GitHub releases and git tags. A date that disagrees, a version published but never documented, and a section whose availability note is stale are reported, and repaired under --fix.

Four content checks run beside it and never change the exit code: a warning on over-long bullets in the unreleased section, on released sections that hold no entry, on a Breaking or Deprecated entry filed after an ordinary one, and on a #N naming no issue. A fifth, on a heading writing its version with the v of a git tag, is fatal, since no check reads such a heading. --fix strips the prefix instead.

The changelog model these checks read and rewrite lives in repomatic.changelog.

repomatic.lint_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.lint_changelog.ENTRY_MARKS: Final[tuple[str, ...]] = ('**Breaking:**', '**Deprecated:**')¶

The bold leads that open a section, in the order they lead it.

A reader scans a release for what breaks before anything else, so every **Breaking:** entry comes first, then every **Deprecated:** one, then the rest. warn_on_entry_order() holds the unreleased section to it.

repomatic.lint_changelog.LINT_DOCS_URL = 'https://repomatic.net/workflows#fix-changelog-fix-changelog'¶

Section of the published documentation on the content checks.

Every check ends its annotation on it. An annotation is read in a downstream repository, which holds no copy of this project’s claude.md, so the link aims at the site.

repomatic.lint_changelog.REFERENCE_RE = re.compile('(?<![\\w/#&])#(?P<number>\\d+)\\b')¶

A bare #N in prose.

The lookbehind leaves out the owner/repo#N slug, where the number follows a word character, and a URL fragment, where it follows a # or an &.

repomatic.lint_changelog.CODE_SPAN_RE = re.compile('`[^`\\n]*`')¶

An inline code span, which warn_on_positional_references() skips.

The target of a Markdown link, which a fragment like #issuecomment-1 sits in.

repomatic.lint_changelog.REFUSED_EXIT_CODE = 2¶

Exit code of a --fix run that refuses to rewrite the changelog.

Returned when an upstream lookup looks unhealthy, so a rewrite would strip links on bad data. See lint_changelog_dates().

repomatic.lint_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.lint_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 of 10 to 25 words stating what changed. 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.lint_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. The fix 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

repomatic.lint_changelog.warn_on_entry_order(changelog)[source]¶

Warn about a marked entry filed after an unmarked one, non-fatally.

The ENTRY_MARKS lead a section in their order: every **Breaking:** entry first, then every **Deprecated:** one, then the rest. Only the unreleased section is inspected, per _unreleased_versions().

Parameters:

changelog (Changelog) – The parsed changelog to inspect.

Return type:

None

repomatic.lint_changelog.warn_on_positional_references(changelog)[source]¶

Warn about a #N in an unreleased entry naming no issue, non-fatally.

GitHub links every #N to the issue or pull request of that number, so a positional test #1 renders as a cross-reference to whatever carries the number. Each number of the unreleased section is looked up on the repository the comparison URLs name. The lookup is skipped, with an info line, when gh cannot read that repository: absent, unauthenticated or offline, which a local run often is.

Parameters:

changelog (Changelog) – The parsed changelog to inspect.

Return type:

None

repomatic.lint_changelog.check_tag_prefixed_headings(changelog, *, fix)[source]¶

Report each heading naming its version with a v, or strip it under fix.

A heading carrying the prefix of a git tag is one no check reads, per TAG_PREFIXED_HEADING_RE, so the finding is fatal: left standing, the release behind it goes unverified. The rewrite is mechanical, so --fix applies it rather than failing.

Parameters:
  • changelog (Changelog) – The parsed changelog to inspect, rewritten under fix.

  • fix (bool) – Strip the prefixes instead of reporting them.

Return type:

bool

Returns:

True when a prefixed heading is left standing.

class repomatic.lint_changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, tag_dates=<factory>, github_fetch_failed=False)[source]¶

Bases: object

The external lookups a changelog’s dates and availability are checked against.

The PyPI and GitHub lookups are TTL-cached. The boundary versions are derived from them here, so a forced refresh cannot leave a stale boundary behind. Frozen so the boundary properties can be cached: a refreshed half swaps in through dataclasses.replace, which guarantees the derivations never go stale against an in-place edit.

Every version these lookups hold parses as PEP 440: load() drops the others.

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.

tag_dates: dict[str, str]¶

Release tag dates in YYYY-MM-DD format, keyed by version string.

Read once per load: the date check and the orphan search both consult it.

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.

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 every release source, 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

platform_gaps(version)[source]¶

Whether version is a gap on PyPI and on GitHub, in that order.

See _is_platform_gap for what counts as a gap.

Return type:

tuple[bool, bool]

unrecorded_gaps(changelog, releases, *, github_only=False)[source]¶

Versions these lookups miss where their section records no absence.

A section claiming the platform is one a stale snapshot would retract, and a section recording nothing yet belongs to a release that may postdate the snapshot: both need a live answer. A gap the section already records is left to the cache, so a permanent one costs no request per run.

Parameters:

github_only (bool) – Report only the versions whose unrecorded gap is on GitHub. A PyPI-only gap needs no GitHub answer, so a failed GitHub lookup still leaves it settled.

Return type:

set[str]

class repomatic.lint_changelog.DateCheck(corrections, mismatched, unfixed)[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.lint_changelog.OrphanReconciliation(releases, found, unfixed)[source]¶

Bases: NamedTuple

What reconciling versions missing from the changelog produced.

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

releases: list[tuple[str, str]]¶

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

found: bool¶

Whether any orphan was detected at all.

unfixed: bool¶

Whether an orphan was left in place, unrepaired.

repomatic.lint_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.

Four non-fatal content checks run first and never affect the exit code: warn_on_long_bullets(), warn_on_entry_order() and warn_on_positional_references() over the unreleased section, and warn_on_empty_sections() over the released ones. A fifth, check_tag_prefixed_headings(), fails the run on a heading no check can read, and rewrites it under fix.

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.

  • Placeholder sections for orphaned versions, with comparison URLs linking to adjacent versions.

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.

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, REFUSED_EXIT_CODE 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.