Skip to content

Commit 64ef97a

Browse files
committed
lint-repo now warns about a release download URL in docs/install.md
1 parent db71ea2 commit 64ef97a

8 files changed

Lines changed: 196 additions & 4 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- `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.
1010
- The readme's logo is now an absolute URL, so it renders on the PyPI project page instead of 404ing.
1111
- The install guide's executable table points at the last release that carries binaries, instead of a version whose upload lane failed.
12+
- `lint-repo` now warns when a release download URL in `docs/install.md` names a file its release does not carry.
1213

1314
## [`7.7.0` (2026-08-09)](https://github.com/kdeldycke/repomatic/compare/v7.6.0...v7.7.0)
1415

claude.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ What a short ship does leave behind is three artifacts still claiming binaries t
487487

488488
- The version's **changelog section**, which takes a `> [!WARNING]` naming the gap. Write it as a hand-written admonition (anything not starting with `` > `X.Y.Z` is ``), which lands in the editorial slot `fix-changelog` preserves rather than the availability slot it regenerates.
489489
- The **GitHub release body**, rebuilt from that section. Immutability locks the assets and the tag, not the notes, so this stays editable after publishing. `sync-github-releases` is the mechanism, but it skips drafts and caches the release list for 24h, so a same-day fix goes through `gh release edit --notes-file` with the body `build_expected_body` renders. Both converge on the same text, so a later CI sync is a no-op rather than a clobber.
490-
- **`docs/install.md`**, whose download URLs the freeze pins to the version being released, optimistically: the freeze commit is what triggers the build, so it cannot know whether the binaries will land. Re-point them at the last release that carries binaries via `PrepareRelease.freeze_install_download_urls`, and the next release's freeze ratchets them forward again.
490+
- **`docs/install.md`**, whose download URLs the freeze pins to the version being released, optimistically: the freeze commit is what triggers the build, so it cannot know whether the binaries will land. Re-point them at the last release that carries binaries via `PrepareRelease.freeze_install_download_urls`, and the next release's freeze ratchets them forward again. `lint-repo`'s `check_install_guide_downloads` reports the gap but never repairs it, since an automated rewrite driven by a single API read could downgrade a healthy install page on a flaky response.
491491

492492
```{note}
493493
Upstream only: the release PR is rebase-merged, so its freeze and unfreeze commits arrive in a single push, and GitHub Actions reads workflow files from that push's head. `kdeldycke/repomatic`'s own release lane therefore always runs the **unfrozen** workflow content, whatever the freeze wrote into the release commit. A job that assumes the frozen `uvx 'repomatic==X.Y.Z'` form is what executes (and drops its checkout on that basis) dies on `Failed to spawn: repomatic`. Only downstream repos, which call the reusable workflow at its tag, ever run the frozen form.

docs/workflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ None of these jobs read a label config committed to the repository. `labels.toml
393393
#### 🏠 Lint repository metadata (`lint-repo`)
394394

395395
- Validates repository metadata (package name, Sphinx docs, project description) and Dependabot configuration using [`repomatic lint-repo`](https://github.com/kdeldycke/repomatic/blob/main/repomatic/cli.py). Reads `pyproject.toml` directly. When `REPOMATIC_PAT` is configured, also validates PAT capabilities (contents, issues, pull requests, Dependabot alerts, workflows permissions). Warns when the fork PR workflow approval policy is weaker than `first_time_contributors`. Warns about missing `VIRUSTOTAL_API_KEY` when Nuitka binary compilation is active. Warns about missing `REPOMATIC_NOTIFICATIONS_PAT` when the unsubscribe workflow is enabled.
396+
- Warns when a release download URL in `docs/install.md` names a file its release does not carry. The release freeze pins those URLs before the binaries exist, so a failed build lane leaves the guide advertising 404s until the next release moves past it: this is the check that surfaces the gap instead of leaving it for a user to hit
396397
- **Requires**:
397398
- Python package (with a `pyproject.toml` file)
398399

repomatic/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3065,6 +3065,7 @@ def lint_repo(
30653065
- GitHub topics subset of pyproject.toml keywords (warning).
30663066
- Funding file present when owner has GitHub Sponsors (warning).
30673067
- Stale draft releases (non-.dev0 drafts) (warning).
3068+
- Install guide download URLs resolve to real release assets (warning).
30683069
- Repository-local PR body templates sit in .github/pr-templates/
30693070
and carry valid frontmatter (warning).
30703071
- Fork PR workflow approval policy strict enough (warning).

repomatic/lint_repo.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
get_trusted_publishers,
5353
pypi_trusted_publisher_settings_url,
5454
)
55-
from .registry import DEFAULT_REPO, WORKFLOW_TARGET_ROOT
55+
from .registry import DEFAULT_REPO, INSTALL_GUIDE_PATH, WORKFLOW_TARGET_ROOT
5656
from .version_sync import find_upstream_ref_versions
5757

5858
WORKFLOW_DIR = Path(WORKFLOW_TARGET_ROOT)
@@ -62,6 +62,15 @@
6262
checks and the generator can never disagree about where a workflow lives.
6363
"""
6464

65+
RELEASE_DOWNLOAD_RE = re.compile(r"/releases/download/([^/\s\")]+)/([^/\s\")]+)")
66+
"""A GitHub release asset URL, capturing its tag and filename.
67+
68+
Matches any release download link, not only a binary one, so the install
69+
guide's whole download surface is verified with a single pattern. Both groups
70+
stop at a quote, whitespace or a closing parenthesis, covering an HTML `src`,
71+
a Markdown link target and a bare URL in prose alike.
72+
"""
73+
6574
PR_TEMPLATE_DIR = Path(".github/pr-templates")
6675
"""Canonical home for a repository's own `pr-body --template-file` templates.
6776
@@ -316,6 +325,75 @@ def check_stale_draft_releases(repo: str) -> CheckResult:
316325
return CheckResult(True, "No stale draft releases.")
317326

318327

328+
def check_install_guide_downloads(repo: str) -> CheckResult:
329+
"""Check the install guide's release download URLs still resolve.
330+
331+
The release freeze pins those URLs to the version being released, but it
332+
runs *before* the binaries exist: the freeze commit is what triggers the
333+
build. So the pin is optimistic, and a release whose binary lane fails
334+
leaves the guide advertising files that 404 until the next release
335+
ratchets past it. `7.7.0` shipped that way, with all six links dead.
336+
337+
Nothing static can catch this: the URLs are well-formed and correct on
338+
disk, and only the release's actual asset list settles whether they
339+
resolve. Hence a lint check against the API rather than a conformance
340+
test.
341+
342+
Reports rather than repairs, per `claude.md` § Skip and move forward:
343+
the fix is a one-liner
344+
({meth}`~repomatic.prepare_release.PrepareRelease.freeze_install_download_urls`
345+
re-pointed at the last release that carries binaries), while an automated
346+
rewrite driven by one API read could downgrade a healthy install page on
347+
a flaky response.
348+
349+
:param repo: Repository in 'owner/repo' format.
350+
:return: A `CheckResult`.
351+
"""
352+
guide = Path(INSTALL_GUIDE_PATH)
353+
if not guide.is_file():
354+
return CheckResult(None, "Install guide downloads: skipped (no install guide).")
355+
356+
# Group the referenced filenames by the release tag they are served from.
357+
referenced: dict[str, set[str]] = {}
358+
for tag, filename in RELEASE_DOWNLOAD_RE.findall(guide.read_text(encoding="UTF-8")):
359+
referenced.setdefault(tag, set()).add(filename)
360+
if not referenced:
361+
return CheckResult(
362+
None, "Install guide downloads: skipped (no release download URLs)."
363+
)
364+
365+
missing: list[str] = []
366+
for tag, filenames in sorted(referenced.items()):
367+
assets = gh_api_json([
368+
"release",
369+
"view",
370+
tag,
371+
"--json",
372+
"assets",
373+
"--repo",
374+
repo,
375+
])
376+
if assets is None:
377+
# An unreadable release is indistinguishable from a missing one
378+
# here, and a false alarm on a transient API failure is worse
379+
# than a silent pass: the next run re-checks.
380+
return CheckResult(
381+
None, f"Install guide downloads: skipped (could not read {tag})."
382+
)
383+
published = {asset["name"] for asset in assets.get("assets", [])}
384+
missing.extend(f"{tag}/{name}" for name in sorted(filenames - published))
385+
386+
if missing:
387+
listed = ", ".join(missing)
388+
return CheckResult(
389+
False,
390+
f"Install guide links {len(missing)} missing release file(s): "
391+
f"{listed}. Re-point the guide at the last release carrying them "
392+
"(PrepareRelease.freeze_install_download_urls).",
393+
)
394+
return CheckResult(True, "Install guide download URLs all resolve.")
395+
396+
319397
def check_topics_subset_of_keywords(
320398
repo: str,
321399
keywords: list[str] | None = None,
@@ -1778,6 +1856,10 @@ def run_repo_lint(
17781856
if repo:
17791857
_report_result(check_stale_draft_releases(repo))
17801858

1859+
# Check 7b: Install guide download URLs still resolve (warning).
1860+
if repo:
1861+
_report_result(check_install_guide_downloads(repo))
1862+
17811863
# Check 8: Tag protection rules (warning).
17821864
if repo:
17831865
_report_result(check_tag_protection_rules(repo))

repomatic/prepare_release.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
from .config import load_repomatic_config
7575
from .metadata import Metadata
7676
from .plugin import ARCHIVE_NAME, MARKETPLACE_PATH
77-
from .registry import UPSTREAM_PACKAGE
77+
from .registry import INSTALL_GUIDE_PATH, UPSTREAM_PACKAGE
7878
from .version_sync import frozen_cli_invocation
7979

8080
SELF_PIN_COOLDOWN_EXEMPTION = f"--exclude-newer-package {UPSTREAM_PACKAGE}=P0D"
@@ -140,7 +140,7 @@ def __init__(
140140
)
141141
self.citation_path = citation_path or Path("./citation.cff").resolve()
142142
self.workflow_dir = workflow_dir or Path("./.github/workflows").resolve()
143-
self.install_path = install_path or Path("./docs/install.md").resolve()
143+
self.install_path = install_path or Path(INSTALL_GUIDE_PATH).resolve()
144144
self.marketplace_path = marketplace_path or Path(MARKETPLACE_PATH).resolve()
145145
self.default_branch = default_branch
146146
self.modified_files: list[Path] = []

repomatic/registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,14 @@ class RemovedAsset:
514514
WORKFLOW_TARGET_ROOT = ".github/workflows"
515515
"""Directory GitHub reads workflow files from. Not configurable."""
516516

517+
INSTALL_GUIDE_PATH = "docs/install.md"
518+
"""Install guide the release freeze pins download URLs in.
519+
520+
Shared by {class}`~repomatic.prepare_release.PrepareRelease`, which rewrites
521+
those URLs, and {func}`~repomatic.lint_repo.check_install_guide_downloads`,
522+
which verifies the release they name actually carries the files.
523+
"""
524+
517525

518526
def _agent_target(agent_id: str) -> str:
519527
"""Build the default target path for an agent file from the Config default."""

tests/test_lint_repo.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from __future__ import annotations
2020

21+
from pathlib import Path
2122
from unittest.mock import patch
2223

2324
import pytest
@@ -29,6 +30,7 @@
2930
check_funding_file,
3031
check_immutable_releases,
3132
check_inline_pins_match_upstream,
33+
check_install_guide_downloads,
3234
check_package_name_vs_repo,
3335
check_pat_stale_statuses_permission,
3436
check_pr_templates,
@@ -46,6 +48,7 @@
4648
)
4749
from repomatic.matrix_axes import UNSTABLE_PYTHON_VERSIONS
4850
from repomatic.pypi import TrustedPublisher
51+
from repomatic.registry import INSTALL_GUIDE_PATH
4952
from tests.conftest import metadata_from_pyproject, pat_results
5053

5154

@@ -658,6 +661,102 @@ def test_stale_drafts_none():
658661
assert "No stale" in result.message
659662

660663

664+
# --- Install guide download URL check unit tests ---
665+
666+
667+
def _write_install_guide(root: Path, body: str) -> None:
668+
"""Materialize an install guide under *root*, directories included."""
669+
guide = root / INSTALL_GUIDE_PATH
670+
guide.parent.mkdir(parents=True, exist_ok=True)
671+
guide.write_text(body, encoding="UTF-8")
672+
673+
674+
GUIDE_WITH_DOWNLOADS = (
675+
"[Download `papaya-1.2.3-linux-x64.bin`]"
676+
"(https://github.com/owner/repo/releases/download/v1.2.3/"
677+
"papaya-1.2.3-linux-x64.bin)\n"
678+
"[Download `papaya-1.2.3-macos-arm64.bin`]"
679+
"(https://github.com/owner/repo/releases/download/v1.2.3/"
680+
"papaya-1.2.3-macos-arm64.bin)\n"
681+
)
682+
683+
684+
def test_install_guide_downloads_all_present(tmp_path, monkeypatch):
685+
"""Pass when every referenced file is attached to its release."""
686+
monkeypatch.chdir(tmp_path)
687+
_write_install_guide(tmp_path, GUIDE_WITH_DOWNLOADS)
688+
with patch("repomatic.lint_repo.gh_api_json") as mock_gh:
689+
mock_gh.return_value = {
690+
"assets": [
691+
{"name": "papaya-1.2.3-linux-x64.bin"},
692+
{"name": "papaya-1.2.3-macos-arm64.bin"},
693+
]
694+
}
695+
result = check_install_guide_downloads("owner/repo")
696+
assert result.passed is True
697+
698+
699+
def test_install_guide_downloads_missing_asset(tmp_path, monkeypatch):
700+
"""Warn naming each referenced file the release does not carry."""
701+
monkeypatch.chdir(tmp_path)
702+
_write_install_guide(tmp_path, GUIDE_WITH_DOWNLOADS)
703+
with patch("repomatic.lint_repo.gh_api_json") as mock_gh:
704+
mock_gh.return_value = {"assets": [{"name": "papaya-1.2.3-linux-x64.bin"}]}
705+
result = check_install_guide_downloads("owner/repo")
706+
assert result.passed is False
707+
assert "v1.2.3/papaya-1.2.3-macos-arm64.bin" in result.message
708+
# The healthy link is not reported as a problem.
709+
assert "v1.2.3/papaya-1.2.3-linux-x64.bin" not in result.message
710+
711+
712+
def test_install_guide_downloads_release_without_assets(tmp_path, monkeypatch):
713+
"""A release carrying no assets at all reports every referenced file.
714+
715+
This is the `7.7.0` shape: the release published, so the API answers, but
716+
the binary upload never ran.
717+
"""
718+
monkeypatch.chdir(tmp_path)
719+
_write_install_guide(tmp_path, GUIDE_WITH_DOWNLOADS)
720+
with patch("repomatic.lint_repo.gh_api_json") as mock_gh:
721+
mock_gh.return_value = {"assets": []}
722+
result = check_install_guide_downloads("owner/repo")
723+
assert result.passed is False
724+
assert "2 missing release file(s)" in result.message
725+
726+
727+
@pytest.mark.parametrize(
728+
("body", "needle"),
729+
(
730+
pytest.param(None, "no install guide", id="no-guide"),
731+
pytest.param(
732+
"Install it with `uv tool install papaya`.", "no release", id="no-urls"
733+
),
734+
),
735+
)
736+
def test_install_guide_downloads_skipped(tmp_path, monkeypatch, body, needle):
737+
"""Skip when there is no guide, or no download URL to verify."""
738+
monkeypatch.chdir(tmp_path)
739+
if body is not None:
740+
_write_install_guide(tmp_path, body)
741+
result = check_install_guide_downloads("owner/repo")
742+
assert result.passed is None
743+
assert needle in result.message
744+
745+
746+
def test_install_guide_downloads_unreadable_release(tmp_path, monkeypatch):
747+
"""Skip rather than warn when the release cannot be read.
748+
749+
A transient API failure must not be reported as a broken install guide.
750+
"""
751+
monkeypatch.chdir(tmp_path)
752+
_write_install_guide(tmp_path, GUIDE_WITH_DOWNLOADS)
753+
with patch("repomatic.lint_repo.gh_api_json") as mock_gh:
754+
mock_gh.return_value = None
755+
result = check_install_guide_downloads("owner/repo")
756+
assert result.passed is None
757+
assert "skipped" in result.message
758+
759+
661760
def test_stale_drafts_unreadable_payload():
662761
"""Skip gracefully when the release list cannot be read."""
663762
with patch("repomatic.lint_repo.gh_api_json") as mock_gh:

0 commit comments

Comments
 (0)