@@ -733,6 +733,64 @@ def replace_section(self, version: str, new_section: str) -> bool:
733733 return True
734734
735735
736+ def _load_pypi_releases (
737+ package : str | None , history : Sequence [str ], * , force_refresh : bool = False
738+ ) -> dict [str , PyPIRelease ]:
739+ """Load a package's PyPI releases, merged with its former names.
740+
741+ A renamed project keeps its old releases under the old name, so the
742+ changelog's early versions resolve only through
743+ `[tool.repomatic] pypi.package-history`. The current package wins on
744+ version collisions.
745+
746+ :param package: Current PyPI package name; empty or `None` skips PyPI.
747+ :param history: Former package names, oldest-first.
748+ :param force_refresh: Ignore cached responses and re-fetch.
749+ :return: Dict mapping version strings to {class}`PyPIRelease` tuples.
750+ """
751+ releases : dict [str , PyPIRelease ] = {}
752+ if package :
753+ releases = get_pypi_release_dates (package , force_refresh = force_refresh )
754+ for former_package in history :
755+ former_data = get_pypi_release_dates (
756+ former_package , force_refresh = force_refresh
757+ )
758+ if former_data :
759+ logging .info (
760+ f"Using PyPI history for { former_package !r} "
761+ f" ({ len (former_data )} releases found)."
762+ )
763+ for version , release in former_data .items ():
764+ releases .setdefault (version , release )
765+ return releases
766+
767+
768+ def _is_platform_gap (
769+ parsed : Version ,
770+ on_platform : bool ,
771+ platform_known : bool ,
772+ first_version : Version | None ,
773+ ) -> bool :
774+ """Whether *parsed* is missing from a platform it should appear on.
775+
776+ A gap is an absence *after* the platform's first release: versions
777+ predating it were never expected there, so they are not warned about.
778+
779+ :param parsed: The version under test.
780+ :param on_platform: Whether the lookup found it on the platform.
781+ :param platform_known: Whether the platform is addressable at all (a
782+ package name for PyPI, a repository URL for GitHub).
783+ :param first_version: Earliest version seen on that platform.
784+ :return: `True` when the version is a genuine gap.
785+ """
786+ return (
787+ not on_platform
788+ and platform_known
789+ and first_version is not None
790+ and parsed >= first_version
791+ )
792+
793+
736794def _platform_admonition (
737795 template : str , version : str , verb : str , platforms : Sequence [str ]
738796) -> str :
@@ -985,33 +1043,20 @@ def lint_changelog_dates(
9851043 if package is None :
9861044 package = get_project_name ()
9871045
988- # Fetch all PyPI release dates in a single API call.
989- pypi_data : dict [str , PyPIRelease ] = {}
990- if package :
991- pypi_data = get_pypi_release_dates (package )
992- if pypi_data :
993- logging .info (
994- f"Using PyPI as reference for { package !r} "
995- f" ({ len (pypi_data )} releases found)."
996- )
997- else :
998- logging .info (
999- f"Package { package !r} not found on PyPI, falling back to git tags."
1000- )
1001- else :
1046+ # Fetch all PyPI release dates in a single API call, merged with the
1047+ # releases of any former package name (for renamed projects).
1048+ pypi_data = _load_pypi_releases (package , pypi_package_history )
1049+ if not package :
10021050 logging .info ("No package name detected, falling back to git tags." )
1003-
1004- # Merge releases from former package names (for renamed projects).
1005- for former_package in pypi_package_history :
1006- former_data = get_pypi_release_dates (former_package )
1007- if former_data :
1008- logging .info (
1009- f"Using PyPI history for { former_package !r} "
1010- f" ({ len (former_data )} releases found)."
1011- )
1012- # Current package wins on version collisions.
1013- for v , rel in former_data .items ():
1014- pypi_data .setdefault (v , rel )
1051+ elif pypi_data :
1052+ logging .info (
1053+ f"Using PyPI as reference for { package !r} "
1054+ f" ({ len (pypi_data )} releases found)."
1055+ )
1056+ else :
1057+ logging .info (
1058+ f"Package { package !r} not found on PyPI, falling back to git tags."
1059+ )
10151060
10161061 use_pypi = bool (pypi_data )
10171062 has_mismatch = False
@@ -1051,6 +1096,98 @@ def lint_changelog_dates(
10511096 first_github_version = min (Version (v ) for v in github_releases )
10521097 logging .info (f"First GitHub version: { first_github_version } " )
10531098
1099+ def retracted_versions () -> set [str ]:
1100+ """Versions whose section claims availability the lookups now deny.
1101+
1102+ Reads the enclosing scope on every call, so re-running it after a
1103+ forced refresh answers against the fresh data. An available platform
1104+ is rendered as a markdown link and a missing one as a bare label, so
1105+ the `[` prefix is what separates a claim of presence from one of
1106+ absence.
1107+ """
1108+ found = set ()
1109+ for candidate , _candidate_date in releases :
1110+ existing = changelog .decompose_version (candidate ).availability_admonition
1111+ parsed_candidate = Version (candidate )
1112+ drops_pypi = (
1113+ _is_platform_gap (
1114+ parsed_candidate ,
1115+ candidate in pypi_data ,
1116+ bool (package ),
1117+ first_pypi_version ,
1118+ )
1119+ and f"[{ PYPI_LABEL } ](" in existing
1120+ )
1121+ drops_github = (
1122+ _is_platform_gap (
1123+ parsed_candidate ,
1124+ candidate in github_releases ,
1125+ bool (repo_url ),
1126+ first_github_version ,
1127+ )
1128+ and f"[{ GITHUB_LABEL } ](" in existing
1129+ )
1130+ if drops_pypi or drops_github :
1131+ found .add (candidate )
1132+ return found
1133+
1134+ # Retraction gate: never demote an existing "is available" claim to
1135+ # "is **not available**" on cached data. Both lookups are TTL-cached for a
1136+ # day, so a snapshot taken before a release landed reports that release as
1137+ # missing, which is indistinguishable here from one that was never
1138+ # published. The sanity gates further down catch only a *wholly* empty
1139+ # result; a stale-but-populated snapshot walks past them, and the window
1140+ # where it lies is exactly the hours after a release, when this command is
1141+ # most likely to run. Adding availability stays cheap and cached; dropping
1142+ # it has to be confirmed live. Running before the date loop means the
1143+ # spurious "not found on PyPI" warning goes away with it.
1144+ retracted = retracted_versions ()
1145+ if retracted :
1146+ logging .warning (
1147+ f"Cached lookups would retract availability for"
1148+ f" { ', ' .join (sorted (retracted ))} ; re-confirming live."
1149+ )
1150+ pypi_data = _load_pypi_releases (
1151+ package , pypi_package_history , force_refresh = True
1152+ )
1153+ first_pypi_version = min (Version (v ) for v in pypi_data ) if pypi_data else None
1154+ use_pypi = bool (pypi_data )
1155+ if repo_url :
1156+ try :
1157+ github_releases = get_github_releases (repo_url , force_refresh = True )
1158+ except GitHubReleasesUnavailable as exc :
1159+ logging .warning (f"Confirming GitHub lookup failed: { exc } " )
1160+ if fix :
1161+ msg = (
1162+ f"Refusing to rewrite changelog: cached data would drop"
1163+ f" the availability of { ', ' .join (sorted (retracted ))} ,"
1164+ f" and the confirming GitHub lookup failed ({ exc } )."
1165+ f" Re-run when the GitHub API is reachable."
1166+ )
1167+ logging .error (msg )
1168+ emit_annotation (AnnotationLevel .ERROR , msg )
1169+ return 2
1170+ else :
1171+ first_github_version = (
1172+ min (Version (v ) for v in github_releases )
1173+ if github_releases
1174+ else None
1175+ )
1176+ still_missing = retracted_versions ()
1177+ if still_missing :
1178+ # Confirmed against the live APIs: the release really is gone (a
1179+ # deleted GitHub release, a removed PyPI file). Retracting is then
1180+ # the correct repair, not a stale-cache artifact.
1181+ logging .warning (
1182+ f"Confirmed live: { ', ' .join (sorted (still_missing ))} no longer"
1183+ " published where the changelog claims."
1184+ )
1185+ else :
1186+ logging .info (
1187+ "Live lookups confirm the existing availability claims;"
1188+ " the cache was stale."
1189+ )
1190+
10541191 # Detect orphaned versions: present in external sources but missing
10551192 # from the changelog.
10561193 tag_versions = get_all_version_tags ()
@@ -1321,17 +1458,11 @@ def lint_changelog_dates(
13211458 # Build the WARNING admonition for platforms where missing.
13221459 # Only warn about gaps: versions that postdate the first
13231460 # release on that platform but are absent from it.
1324- pypi_gap = (
1325- not on_pypi
1326- and bool (package )
1327- and first_pypi_version is not None
1328- and parsed >= first_pypi_version
1461+ pypi_gap = _is_platform_gap (
1462+ parsed , on_pypi , bool (package ), first_pypi_version
13291463 )
1330- github_gap = (
1331- not on_github
1332- and bool (repo_url )
1333- and first_github_version is not None
1334- and parsed >= first_github_version
1464+ github_gap = _is_platform_gap (
1465+ parsed , on_github , bool (repo_url ), first_github_version
13351466 )
13361467 warning = build_unavailable_admonition (
13371468 version ,
0 commit comments