Skip to content

Commit db71ea2

Browse files
committed
lint-changelog re-confirms a release live before dropping its availability admonition
1 parent 123f937 commit db71ea2

8 files changed

Lines changed: 370 additions & 70 deletions

File tree

.github/workflows/autofix.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,8 +641,11 @@ jobs:
641641
with:
642642
version: "0.12.1"
643643
# Persist repomatic's HTTP cache (PyPI/GitHub/npm release metadata) across
644-
# runs. Entries are TTL-gated in repomatic.cache, so a restored cache never
645-
# serves a stale "latest" version; this mainly speeds up bursts of pushes.
644+
# runs; this mainly speeds up bursts of pushes. TTL-gating bounds staleness
645+
# at a day rather than preventing it, so a restored cache can miss a
646+
# release published since it was written. Here that only defers a version
647+
# bump by a run. A job that writes availability claims must re-confirm
648+
# live instead: see the retraction gate in repomatic/changelog.py.
646649
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
647650
with:
648651
path: ~/.cache/repomatic

.github/workflows/self-maintenance.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ jobs:
4444
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
4545
with:
4646
version: "0.12.1"
47-
# Persist repomatic's HTTP cache (PyPI/GitHub/npm release metadata) across runs. Entries are TTL-gated in
48-
# repomatic.cache, so a restored cache never serves a stale "latest" version.
47+
# Persist repomatic's HTTP cache (PyPI/GitHub/npm release metadata) across runs. TTL-gating bounds staleness at a
48+
# day rather than preventing it, so a restored cache can miss a release published since it was written. Here that
49+
# only defers a version bump by a run; a job that writes availability claims must re-confirm live, as the
50+
# retraction gate in repomatic/changelog.py does.
4951
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
5052
with:
5153
path: ~/.cache/repomatic

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
> This version is **not released yet** and is under active development.
77
88
- The release lane's `publish-release` job now checks out the repository, so uploading binaries to the release no longer fails with `Failed to spawn: repomatic`. `7.7.0` shipped with no standalone executables because of it.
9+
- `lint-changelog` re-confirms a release live before dropping its availability admonition, so a day-old cache no longer reports a just-published version as missing.
910
- The readme's logo is now an absolute URL, so it renders on the PyPI project page instead of 404ing.
1011
- The install guide's executable table points at the last release that carries binaries, instead of a version whose upload lane failed.
1112

repomatic/changelog.py

Lines changed: 167 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
736794
def _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,

repomatic/github/releases.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ def _cached_release_map(
206206
namespace: str,
207207
repo_url: str,
208208
key_for: Callable[[str], str | None],
209+
*,
210+
force_refresh: bool = False,
209211
) -> dict[str, GitHubRelease]:
210212
"""Fetch a repository's releases as a keyed map, through the HTTP cache.
211213
@@ -218,6 +220,7 @@ def _cached_release_map(
218220
:param repo_url: Repository URL.
219221
:param key_for: Maps a raw tag name to the result key, or `None` to skip
220222
that release.
223+
:param force_refresh: Ignore any cached map and re-fetch every page.
221224
:return: Dict mapping keys to {class}`GitHubRelease` tuples. Empty when
222225
the repository has no releases or *repo_url* does not parse to an
223226
`owner/repo` pair.
@@ -231,7 +234,7 @@ def _cached_release_map(
231234

232235
cache_key = f"{owner}/{repo}"
233236
ttl = load_repomatic_config().cache.github_releases_ttl
234-
cached = get_cached_response(namespace, cache_key, ttl)
237+
cached = None if force_refresh else get_cached_response(namespace, cache_key, ttl)
235238
if cached is not None:
236239
try:
237240
data = json.loads(cached)
@@ -261,7 +264,9 @@ def _cached_release_map(
261264
return result
262265

263266

264-
def get_github_releases(repo_url: str) -> dict[str, GitHubRelease]:
267+
def get_github_releases(
268+
repo_url: str, *, force_refresh: bool = False
269+
) -> dict[str, GitHubRelease]:
265270
"""Get versions and dates for all GitHub releases.
266271
267272
Fetches all releases via the GitHub API with pagination. Extracts
@@ -270,6 +275,9 @@ def get_github_releases(repo_url: str) -> dict[str, GitHubRelease]:
270275
271276
:param repo_url: Repository URL (e.g.
272277
`https://github.com/user/repo`).
278+
:param force_refresh: Ignore any cached map and re-fetch. A cached map
279+
predating a release reports it as absent, so callers acting on an
280+
absence around release time should re-confirm live.
273281
:return: Dict mapping version strings to {class}`GitHubRelease`
274282
tuples. Empty dict only when the repository genuinely has no
275283
releases (the API returned an empty page) or when `repo_url`
@@ -283,6 +291,7 @@ def get_github_releases(repo_url: str) -> dict[str, GitHubRelease]:
283291
"github-releases",
284292
repo_url,
285293
lambda tag: tag[1:] if tag.startswith("v") else None,
294+
force_refresh=force_refresh,
286295
)
287296

288297

repomatic/http.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def get_cached_json(
116116
*,
117117
ttl: int,
118118
log_label: str,
119+
force_refresh: bool = False,
119120
) -> Any | None:
120121
"""GET *url* as JSON through the raw-response cache.
121122
@@ -124,14 +125,23 @@ def get_cached_json(
124125
positive), and returned parsed. The caller keeps the caching policy: it
125126
picks the namespace, the cache key, and the TTL.
126127
128+
```{note}
129+
*force_refresh* skips the cache **read** but keeps the write, which is
130+
what separates it from `ttl=0`: the latter also skips the store, so a
131+
caller using it to bypass a stale entry would leave that entry in place
132+
for the next reader. A forced refresh replaces it.
133+
```
134+
127135
:param namespace: Cache namespace (like `"pypi"` or `"npm"`).
128136
:param key: Cache key within the namespace, usually the package name.
129137
:param url: The URL to fetch on a cache miss.
130138
:param ttl: Freshness TTL in seconds; `0` disables caching.
131139
:param log_label: Human-readable label for the debug log on failure.
140+
:param force_refresh: Ignore any cached body and re-fetch, then store
141+
the fresh response.
132142
:return: The parsed JSON value, or `None` on any fetch failure.
133143
"""
134-
cached = get_cached_response(namespace, key, ttl)
144+
cached = None if force_refresh else get_cached_response(namespace, key, ttl)
135145
if cached is not None:
136146
try:
137147
return json.loads(cached)

0 commit comments

Comments
 (0)