From c2fe4df74570603abe3b180f67a5e5e957210cca Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:27:25 +0530 Subject: [PATCH 1/3] ci: add checker for broken links introduced by PRs --- scripts/check_new_link_failures.py | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 scripts/check_new_link_failures.py diff --git a/scripts/check_new_link_failures.py b/scripts/check_new_link_failures.py new file mode 100644 index 0000000000..0dba000a9a --- /dev/null +++ b/scripts/check_new_link_failures.py @@ -0,0 +1,83 @@ +"""Fail when Sphinx reports a URL added by the current PR as broken.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path + +URL_RE = re.compile(r"https?://[^\s<>()\[\]{}\"']+") +TRAILING_PUNCTUATION = ".,;:!?`" + + +def _added_urls(base: str) -> set[str]: + result = subprocess.run( + [ + "git", + "diff", + "--unified=0", + "--diff-filter=AM", + f"{base}...HEAD", + "--", + "source", + ], + check=True, + capture_output=True, + text=True, + ) + urls: set[str] = set() + for line in result.stdout.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + continue + for match in URL_RE.findall(line[1:]): + urls.add(match.rstrip(TRAILING_PUNCTUATION)) + return urls + + +def _broken_urls(report: Path) -> dict[str, str]: + if not report.is_file(): + raise SystemExit(f"linkcheck report not found: {report}") + + broken: dict[str, str] = {} + for number, raw_line in enumerate(report.read_text(encoding="utf-8").splitlines(), 1): + if not raw_line.strip(): + continue + try: + record = json.loads(raw_line) + except json.JSONDecodeError as exc: + raise SystemExit(f"invalid JSON in {report} line {number}: {exc}") from exc + if record.get("status") != "broken": + continue + uri = record.get("uri") + if isinstance(uri, str): + broken[uri] = str(record.get("info") or "broken") + return broken + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True, help="PR base commit SHA") + parser.add_argument("--report", type=Path, default=Path("build/output.json")) + args = parser.parse_args() + + added = _added_urls(args.base) + if not added: + print("No newly added HTTP(S) links found in source/. ") + return 0 + + broken = _broken_urls(args.report) + failures = {url: broken[url] for url in sorted(added & broken.keys())} + if not failures: + print(f"All {len(added)} newly added HTTP(S) link(s) passed Sphinx linkcheck.") + return 0 + + print("Broken links introduced by this PR:") + for url, info in failures.items(): + print(f"- {url}: {info}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 977be507613d7176e2646ce7d9afc12e45ba9514 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:27:43 +0530 Subject: [PATCH 2/3] ci: block PRs only on newly broken links --- .github/workflows/test.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81ea4f0541..b77742ce7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,23 +25,17 @@ jobs: if: ${{ github.repository_owner == 'pypa' || github.event_name != 'schedule' }} runs-on: ubuntu-latest timeout-minutes: 20 - continue-on-error: >- - ${{ fromJSON(matrix.continue-on-error) }} strategy: matrix: noxenv: - build - continue-on-error: - - false - include: - - noxenv: linkcheck - continue-on-error: >- # Don't block PRs on linkcheck unrelated failures - ${{ toJSON(github.event_name == 'pull_request') }} + - linkcheck steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false + fetch-depth: 0 # needed to compare PR links against the base commit - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 @@ -55,12 +49,22 @@ jobs: python -m pip install --upgrade nox virtualenv - name: Nox ${{ matrix.noxenv }} + continue-on-error: >- + ${{ matrix.noxenv == 'linkcheck' && github.event_name == 'pull_request' }} env: # Authenticate github.com requests during linkcheck to avoid rate limits. GITHUB_TOKEN: ${{ matrix.noxenv == 'linkcheck' && github.token || '' }} run: | python -m nox -s ${{ matrix.noxenv }} + - name: Check links introduced by the PR + if: >- + ${{ always() && matrix.noxenv == 'linkcheck' && github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + python scripts/check_new_link_failures.py --base "$BASE_SHA" --report build/output.json + check: # This job does nothing and is only used for the branch protection From 94d5133fb8f32777b6d6da4fa3c8e56d480a64da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:58:13 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- scripts/check_new_link_failures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check_new_link_failures.py b/scripts/check_new_link_failures.py index 0dba000a9a..5e73737076 100644 --- a/scripts/check_new_link_failures.py +++ b/scripts/check_new_link_failures.py @@ -41,7 +41,9 @@ def _broken_urls(report: Path) -> dict[str, str]: raise SystemExit(f"linkcheck report not found: {report}") broken: dict[str, str] = {} - for number, raw_line in enumerate(report.read_text(encoding="utf-8").splitlines(), 1): + for number, raw_line in enumerate( + report.read_text(encoding="utf-8").splitlines(), 1 + ): if not raw_line.strip(): continue try: