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:
repomatic.pypi._fetch_jsonreturnsNoneon 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.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
#Nin prose.The lookbehind leaves out the
owner/repo#Nslug, 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.
- repomatic.lint_changelog.LINK_TARGET_RE = re.compile('\\]\\([^)]*\\)')¶
The target of a Markdown link, which a fragment like
#issuecomment-1sits in.
- repomatic.lint_changelog.REFUSED_EXIT_CODE = 2¶
Exit code of a
--fixrun 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:
- 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
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.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
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.
- repomatic.lint_changelog.warn_on_entry_order(changelog)[source]¶
Warn about a marked entry filed after an unmarked one, non-fatally.
The
ENTRY_MARKSlead 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().
- repomatic.lint_changelog.warn_on_positional_references(changelog)[source]¶
Warn about a
#Nin an unreleased entry naming no issue, non-fatally.GitHub links every
#Nto the issue or pull request of that number, so a positionaltest #1renders 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, whenghcannot read that repository: absent, unauthenticated or offline, which a local run often is.
- 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--fixapplies it rather than failing.
- class repomatic.lint_changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, tag_dates=<factory>, github_fetch_failed=False)[source]¶
Bases:
objectThe 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.- 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.
- tag_dates: dict[str, str]¶
Release tag dates in
YYYY-MM-DDformat, 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 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:
- Return type:
- platform_gaps(version)[source]¶
Whether version is a gap on PyPI and on GitHub, in that order.
See
_is_platform_gapfor what counts as a gap.
- 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.
- class repomatic.lint_changelog.DateCheck(corrections, mismatched, unfixed)[source]¶
Bases:
NamedTupleWhat comparing every changelog date against its reference source found.
Create new instance of DateCheck(corrections, mismatched, unfixed)
- class repomatic.lint_changelog.OrphanReconciliation(releases, found, unfixed)[source]¶
Bases:
NamedTupleWhat reconciling versions missing from the changelog produced.
Create new instance of OrphanReconciliation(releases, found, unfixed)
- 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()andwarn_on_positional_references()over the unreleased section, andwarn_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 underfix.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.Placeholder sections for orphaned versions, with comparison URLs linking to adjacent versions.
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.- 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,REFUSED_EXIT_CODEif 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.