From 87a791a1c0ce09cd8803a8cded174bbc6a339f4b Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 08:48:26 -0700 Subject: [PATCH 1/2] ci: share native Lychee hooks and retry reporting through pre-commit [skip ci] Use the configured native hooks for authored and rendered checks, collecting strict native reports across pre-commit chunks and applying the existing retry policies. Signed-off-by: Ralf W. Grosse-Kunstleve --- .gitignore | 1 + .pre-commit-config.yaml | 25 ++- ci/tools/lychee_cache.py | 59 ++++++ ci/tools/retry_lychee.py | 37 ++-- ci/tools/run_lychee.py | 60 +++++- ci/tools/run_lychee_precommit.py | 104 +++++++++++ ci/tools/tests/test_lychee_cache.py | 147 +++++++++++++++ ...eck_action.py => test_repo_link_remaps.py} | 53 +----- ci/tools/tests/test_run_lychee.py | 110 ++++++++++- ci/tools/tests/test_run_lychee_precommit.py | 173 ++++++++++++++++++ 10 files changed, 703 insertions(+), 66 deletions(-) create mode 100644 ci/tools/lychee_cache.py create mode 100644 ci/tools/run_lychee_precommit.py create mode 100644 ci/tools/tests/test_lychee_cache.py rename ci/tools/tests/{test_repo_link_check_action.py => test_repo_link_remaps.py} (50%) create mode 100644 ci/tools/tests/test_run_lychee_precommit.py diff --git a/.gitignore b/.gitignore index 5616c05cc44..1c86c9c3179 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ .cython-stdlib .cython-bindings .lycheecache +.lychee-rendered/ .pytest_cache/ .benchmarks/ *.cpp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3d4f99ae24..b134650f7c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -111,18 +111,39 @@ repos: name: lychee entry: python ./ci/tools/run_lychee.py language: python - additional_dependencies: - - lychee-bin==0.24.2 # Must match check-doc-links/action.yml + additional_dependencies: &lychee_dependencies + - lychee-bin==0.24.2 types: [text] require_serial: true args: - --cache + - --max-cache-age=1d - --max-concurrency=4 - --max-retries=3 - --no-progress files: '\.(md|rst)$' exclude: ^qa/ + # Generated HTML is checked explicitly after the documentation build. + - id: lychee-rendered + name: lychee (rendered documentation) + entry: python ./ci/tools/run_lychee.py --rendered + language: python + additional_dependencies: *lychee_dependencies + stages: [manual] + pass_filenames: false + always_run: true + args: + - --cache + - --max-cache-age=1d + - --max-concurrency=16 + - --host-concurrency=2 + - --host-request-interval=250ms + - --max-retries=3 + - --retry-wait-time=5 + - --timeout=30 + - --no-progress + # Standard hooks - repo: https://github.com/pre-commit/pre-commit-hooks rev: "3e8a8703264a2f4a69428a0aa4dcb512790b2c8c" # frozen: v6.0.0 diff --git a/ci/tools/lychee_cache.py b/ci/tools/lychee_cache.py new file mode 100644 index 00000000000..be341ff0345 --- /dev/null +++ b/ci/tools/lychee_cache.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Describe the URL-result caches shared by pre-commit and documentation CI.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +POLICY_FILES = ( + ".pre-commit-config.yaml", + "lychee.toml", + "ci/tools/run_lychee.py", + "ci/tools/run_lychee_precommit.py", + "ci/tools/retry_lychee.py", + "ci/tools/prepare_lychee_inputs.py", + "ci/tools/lychee_cache.py", +) + + +def cache_prefix(kind: str, root: Path = ROOT) -> str: + # The hook configuration includes the native wheel pin. Separate kinds + # prevent authored successes from bypassing rendered fragment checks. + digest = hashlib.sha256() + for relative in POLICY_FILES: + digest.update(relative.encode("utf-8") + b"\0") + digest.update((root / relative).read_bytes() + b"\0") + return f"lychee-precommit-v1-{kind}-{digest.hexdigest()[:24]}-baseline-" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("authored", "rendered")) + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args() + prefix = cache_prefix(args.kind) + cache_path = ( + Path(".lycheecache") + if args.kind == "authored" + else Path(os.environ.get("LYCHEE_CACHE_DIR", str(ROOT / ".lychee-rendered"))) / ".lycheecache" + ) + outputs = { + "prefix": prefix, + "key": f"{prefix}{os.environ['GITHUB_RUN_ID']}-{os.environ.get('GITHUB_RUN_ATTEMPT', '1')}", + "path": str(cache_path), + } + with args.github_output.open("a", encoding="utf-8") as stream: + for name, value in outputs.items(): + if "\n" in value or "\r" in value: + raise ValueError(f"Cache output {name} contains a newline") + stream.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/ci/tools/retry_lychee.py b/ci/tools/retry_lychee.py index 5d50f5187c6..c1b6bdc470d 100644 --- a/ci/tools/retry_lychee.py +++ b/ci/tools/retry_lychee.py @@ -14,6 +14,7 @@ import shutil import subprocess import time +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from urllib.parse import urlsplit @@ -62,7 +63,7 @@ def transient(self) -> bool: return bool(self.failures) and all(failure.transient for failure in self.failures) -def read_report(path: Path, exit_code: int) -> Report: +def read_report(path: Path, exit_code: int, *, allow_empty: bool = False) -> Report: """Validate the pinned lychee JSON schema and its agreement with the exit code.""" data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): @@ -73,7 +74,7 @@ def read_report(path: Path, exit_code: int) -> Report: if type(value) is not int or value < 0: raise ValueError(f"Lychee report {name} must be a nonnegative integer: {path}") counts[name] = value - if counts["total"] == 0: + if counts["total"] == 0 and not allow_empty: raise ValueError(f"Lychee checked no links: {path}") if counts["errors"] + counts["timeouts"] > counts["total"]: raise ValueError(f"Lychee failure counts exceed the total: {path}") @@ -173,7 +174,14 @@ def finish_link_failures(report: Path, data: Report, exit_codes: list[int], mess return 0 if warning else LINK_CHECK_FAILURE -def retry_lychee(initial_exit_code: int, report: Path, max_attempts: int = 3, *, policy: str = "check") -> int: +def retry_lychee( + initial_exit_code: int, + report: Path, + max_attempts: int = 3, + *, + policy: str = "check", + run_attempt: Callable[[Path], int] | None = None, +) -> int: """Retry only if every remaining failure is a recognized transient HTTP error.""" if initial_exit_code not in (0, 1, 2, 3): raise ValueError(f"Unexpected initial lychee exit code: {initial_exit_code}") @@ -210,7 +218,7 @@ def retry_lychee(initial_exit_code: int, report: Path, max_attempts: int = 3, *, f"Lychee still has transient link failures after {max_attempts} attempts.", policy, ) - if lychee_args is None: + if run_attempt is None and lychee_args is None: raw_args = os.environ.get("LYCHEE_ARGS") if not raw_args or not (lychee_args := shlex.split(raw_args)): raise ValueError("LYCHEE_ARGS must contain the same arguments used by the first lychee pass") @@ -222,20 +230,23 @@ def retry_lychee(initial_exit_code: int, report: Path, max_attempts: int = 3, *, ) time.sleep(cooldown) current_report = attempt_report(report, attempt + 1) - result = subprocess.run( # noqa: S603 - ["lychee", *lychee_args, "--mode", "task", "--format", "json", "--output", str(current_report)], # noqa: S607 - check=False, - ) - exit_codes.append(result.returncode) - if result.returncode not in (0, LINK_CHECK_FAILURE): + if run_attempt is None: + code = subprocess.run( # noqa: S603 + ["lychee", *lychee_args, "--mode", "task", "--format", "json", "--output", str(current_report)], # noqa: S607 + check=False, + ).returncode + else: + code = run_attempt(current_report) + exit_codes.append(code) + if code not in (0, LINK_CHECK_FAILURE): finish( report, None, exit_codes, - f"Lychee stopped on attempt {attempt + 1} with exit code {result.returncode}; no retry.", + f"Lychee stopped on attempt {attempt + 1} with exit code {code}; no retry.", ) - return result.returncode if result.returncode > 0 else 1 - data = read_report(current_report, result.returncode) + return code if code > 0 else 1 + data = read_report(current_report, code) shutil.copyfile(current_report, report) diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py index 7e9f2a598fa..37fe33067d0 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -13,20 +13,37 @@ from __future__ import annotations +import json +import os import subprocess import sys import sysconfig +import uuid from pathlib import Path +from prepare_lychee_inputs import rendered_inputs, write_inputs + ROOT = Path(__file__).resolve().parents[2] -def repo_link_remaps(root: Path = ROOT) -> list[str]: - """Apply the same URL-prefix boundaries as the dedicated CI link check.""" - local_prefix = root.resolve().as_uri() + "/" +def repo_link_remaps(root: Path = ROOT, *, rendered: bool = False) -> list[str]: + """Check proposed main sources locally, retaining GitHub views for HTML anchors.""" + if rendered: + commit = subprocess.run( # noqa: S603 + ["git", "-C", str(root), "rev-parse", "HEAD"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout.strip() + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") + repository = os.environ.get("GITHUB_REPOSITORY", "NVIDIA/cuda-python") + prefix = f"{server}/{repository}" + else: + prefix = root.resolve().as_uri() arguments = [] for view in ("blob", "tree"): - arguments.extend(["--remap", f"^https://github[.]com/NVIDIA/cuda-python/{view}/main/ {local_prefix}"]) + replacement = f"{prefix}/{view}/{commit}/" if rendered else f"{prefix}/" + arguments.extend(["--remap", f"^https://github[.]com/NVIDIA/cuda-python/{view}/main/ {replacement}"]) return arguments @@ -37,8 +54,39 @@ def main(arguments: list[str] | None = None) -> int: raise SystemExit( f"lychee was not found at {executable}; run this script through the configured pre-commit hook" ) - arguments = sys.argv[1:] if arguments is None else arguments - return subprocess.run([str(executable), *repo_link_remaps(), *arguments], check=False).returncode # noqa: S603 + arguments = list(sys.argv[1:] if arguments is None else arguments) + rendered = bool(arguments and arguments[0] == "--rendered") + options = {} + if rendered: + arguments.pop(0) + cache = Path(os.environ.get("LYCHEE_CACHE_DIR", ROOT / ".lychee-rendered")).resolve() + cache.mkdir(parents=True, exist_ok=True) + inputs = cache / "inputs.txt" + write_inputs(rendered_inputs(ROOT / "artifacts/docs"), inputs) + arguments = [ + "--files-from", + str(inputs), + "--include-fragments=full", + "--config", + str(ROOT / "lychee.toml"), + *arguments, + ] + options["cwd"] = cache + + report = None + if report_dir := os.environ.get("LYCHEE_REPORT_DIR"): + directory = Path(report_dir).resolve() + directory.mkdir(parents=True, exist_ok=True) + # pre-commit may split a long file list into several native invocations. + report = directory / f"{uuid.uuid4().hex}.json" + arguments = ["--format", "json", "--output", str(report), *arguments] + + code = subprocess.run( # noqa: S603 + [str(executable), *repo_link_remaps(ROOT, rendered=rendered), *arguments], check=False, **options + ).returncode + if report is not None: + report.with_suffix(".status.json").write_text(json.dumps({"exit_code": code}), encoding="utf-8") + return code if __name__ == "__main__": diff --git a/ci/tools/run_lychee_precommit.py b/ci/tools/run_lychee_precommit.py new file mode 100644 index 00000000000..fbc4fa5f3ac --- /dev/null +++ b/ci/tools/run_lychee_precommit.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Warm link caches or check rendered HTML through the configured pre-commit hooks.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path + +from retry_lychee import LINK_CHECK_FAILURE, read_report, retry_lychee + +ROOT = Path(__file__).resolve().parents[2] + + +def collect_reports(directory: Path, output: Path, precommit_code: int) -> int: + """Distinguish native link failures from pre-commit setup or hook failures.""" + statuses = sorted(directory.glob("*.status.json")) + reports = set(directory.glob("*.json")) - set(statuses) + expected = {path.with_name(path.name.removesuffix(".status.json") + ".json") for path in statuses} + if not statuses: + raise ValueError("The configured Lychee hook produced no native status; pre-commit setup or execution failed") + merged = {"total": 0, "errors": 0, "timeouts": 0, "error_map": {}, "timeout_map": {}} + native_codes = [] + for status in statuses: + metadata = json.loads(status.read_text(encoding="utf-8")) + if not isinstance(metadata, dict) or type(metadata.get("exit_code")) is not int: + raise ValueError(f"Invalid native Lychee status: {status}") + code = metadata["exit_code"] + if code not in (0, LINK_CHECK_FAILURE): + raise ValueError(f"Native Lychee execution failed with exit code {code}: {status}") + native_codes.append(code) + report = status.with_name(status.name.removesuffix(".status.json") + ".json") + # A pre-commit chunk can contain no links; the complete scan must not be empty. + read_report(report, code, allow_empty=True) + data = json.loads(report.read_text(encoding="utf-8")) + for name in ("total", "errors", "timeouts"): + merged[name] += data[name] + for name in ("error_map", "timeout_map"): + for source, responses in data[name].items(): + merged[name].setdefault(source, []).extend(responses) + if reports != expected: + raise ValueError("Native Lychee reports and completed invocation statuses do not match") + code = LINK_CHECK_FAILURE if LINK_CHECK_FAILURE in native_codes else 0 + if precommit_code != int(code != 0): + raise ValueError(f"pre-commit exit {precommit_code} disagrees with native Lychee exit {code}") + output.write_text(json.dumps(merged), encoding="utf-8") + read_report(output, code) + return code + + +def run_hook(kind: str, report: Path) -> int: + """Run the real hook, retaining every native chunk report for diagnostics.""" + directory = report.parent / f"{report.stem}-parts" / uuid.uuid4().hex + directory.mkdir(parents=True) + hook = "lychee" if kind == "authored" else "lychee-rendered" + environment = os.environ.copy() + environment["LYCHEE_REPORT_DIR"] = str(directory) + environment["PYTHONUTF8"] = "1" + environment["SKIP"] = ",".join( + name.strip() for name in environment.get("SKIP", "").split(",") if name.strip() and name.strip() != hook + ) + command = [sys.executable, "-m", "pre_commit", "run", hook, "--all-files", "--show-diff-on-failure"] + if kind == "rendered": + command.extend(["--hook-stage", "manual"]) + result = subprocess.run(command, cwd=ROOT, env=environment, check=False) # noqa: S603 + return collect_reports(directory, report, result.returncode) + + +def run_checks(kind: str, report: Path, max_attempts: int = 3, *, policy: str = "check") -> int: + if max_attempts < 1: + raise ValueError("max-attempts must be positive") + report = report.resolve() + report.parent.mkdir(parents=True, exist_ok=True) + return retry_lychee( + run_hook(kind, report), + report, + max_attempts, + policy=policy, + run_attempt=lambda path: run_hook(kind, path), + ) + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("authored", "rendered")) + parser.add_argument("--policy", choices=("cache-warm", "check"), default="check") + parser.add_argument("--max-attempts", type=int, default=3) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args(arguments) + try: + return run_checks(args.kind, args.report, args.max_attempts, policy=args.policy) + except (OSError, ValueError) as error: + print(f"::error::{error}", flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/tools/tests/test_lychee_cache.py b/ci/tools/tests/test_lychee_cache.py new file mode 100644 index 00000000000..ec48722dda0 --- /dev/null +++ b/ci/tools/tests/test_lychee_cache.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import lychee_cache + + +@pytest.fixture +def policy_checkout(tmp_path): + root = tmp_path / "checkout with spaces" + for relative in lychee_cache.POLICY_FILES: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + content = "lychee-bin==0.24.2\n" if relative == ".pre-commit-config.yaml" else f"policy: {relative}\n" + path.write_text(content, encoding="utf-8") + return root + + +@pytest.fixture +def invoke_cache_helper(monkeypatch, policy_checkout): + cache_prefix = lychee_cache.cache_prefix + monkeypatch.setattr(lychee_cache, "ROOT", policy_checkout) + monkeypatch.setattr(lychee_cache, "cache_prefix", lambda kind: cache_prefix(kind, policy_checkout)) + monkeypatch.setenv("GITHUB_RUN_ID", "12345") + monkeypatch.delenv("GITHUB_RUN_ATTEMPT", raising=False) + monkeypatch.delenv("LYCHEE_CACHE_DIR", raising=False) + + def invoke(kind, output): + monkeypatch.setattr(sys, "argv", ["lychee_cache.py", kind, "--github-output", str(output)]) + lychee_cache.main() + return dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) + + return invoke + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_cache_prefix_is_shared_across_checkout_locations(policy_checkout, tmp_path): + other_checkout = tmp_path / "different runner" / "cuda-python" + for relative in lychee_cache.POLICY_FILES: + path = other_checkout / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((policy_checkout / relative).read_bytes()) + + assert lychee_cache.cache_prefix("authored", policy_checkout) == lychee_cache.cache_prefix( + "authored", other_checkout + ) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_fragment_policy_has_a_separate_cache_namespace(policy_checkout): + assert lychee_cache.cache_prefix("authored", policy_checkout) != lychee_cache.cache_prefix( + "rendered", policy_checkout + ) + + +@pytest.mark.parametrize("relative", lychee_cache.POLICY_FILES) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_policy_changes_invalidate_existing_cache(policy_checkout, relative): + before = lychee_cache.cache_prefix("authored", policy_checkout) + path = policy_checkout / relative + path.write_bytes(path.read_bytes() + b"changed policy\n") + + assert lychee_cache.cache_prefix("authored", policy_checkout) != before + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_native_wheel_upgrade_invalidates_existing_cache(policy_checkout): + before = lychee_cache.cache_prefix("authored", policy_checkout) + config = policy_checkout / ".pre-commit-config.yaml" + config.write_text("lychee-bin==0.24.3\n", encoding="utf-8") + + assert lychee_cache.cache_prefix("authored", policy_checkout) != before + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_document_changes_keep_reusable_external_url_results(policy_checkout): + before = lychee_cache.cache_prefix("authored", policy_checkout) + (policy_checkout / "README.md").write_text("[New document](docs/new.md)\n", encoding="utf-8") + (policy_checkout / ".lycheecache").write_text("successful URL checks\n", encoding="utf-8") + + assert lychee_cache.cache_prefix("authored", policy_checkout) == before + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_authored_output_is_relative_and_preserves_existing_step_outputs(tmp_path, monkeypatch, invoke_cache_helper): + output = tmp_path / "github-output" + output.write_text("previous-step=preserved\n", encoding="utf-8") + monkeypatch.setenv("LYCHEE_CACHE_DIR", str(tmp_path / "rendered cache")) + + values = invoke_cache_helper("authored", output) + + assert values["previous-step"] == "preserved" + assert values["path"] == ".lycheecache" + assert values["key"] == f"{values['prefix']}12345-1" + + +@pytest.mark.parametrize("custom_directory", [False, True]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_rendered_output_uses_its_own_cache_directory( + tmp_path, monkeypatch, policy_checkout, invoke_cache_helper, custom_directory +): + directory = tmp_path / "rendered cache with spaces" if custom_directory else policy_checkout / ".lychee-rendered" + if custom_directory: + monkeypatch.setenv("LYCHEE_CACHE_DIR", str(directory)) + + values = invoke_cache_helper("rendered", tmp_path / "github-output") + + assert values["path"] == str(directory / ".lycheecache") + assert values["prefix"].startswith("lychee-precommit-v1-rendered-") + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_concurrent_runs_and_retries_get_distinct_immutable_snapshot_keys(tmp_path, monkeypatch, invoke_cache_helper): + keys = set() + prefixes = set() + for run_id, attempt in [(12345, 1), (12345, 2), (12346, 1)]: + monkeypatch.setenv("GITHUB_RUN_ID", str(run_id)) + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", str(attempt)) + values = invoke_cache_helper("authored", tmp_path / f"output-{run_id}-{attempt}") + assert values["key"] == f"{values['prefix']}{run_id}-{attempt}" + prefixes.add(values["prefix"]) + keys.add(values["key"]) + + assert len(keys) == 3 + assert len(prefixes) == 1 + + +@pytest.mark.parametrize("variable", ["GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "LYCHEE_CACHE_DIR"]) +@pytest.mark.parametrize("separator", ["\n", "\r"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_github_outputs_reject_values_that_could_inject_another_output( + tmp_path, monkeypatch, invoke_cache_helper, variable, separator +): + output = tmp_path / "github-output" + monkeypatch.setenv(variable, f"valid{separator}injected=value") + + with pytest.raises(ValueError, match="contains a newline"): + invoke_cache_helper("rendered", output) + + assert "injected=value" not in output.read_text(encoding="utf-8") diff --git a/ci/tools/tests/test_repo_link_check_action.py b/ci/tools/tests/test_repo_link_remaps.py similarity index 50% rename from ci/tools/tests/test_repo_link_check_action.py rename to ci/tools/tests/test_repo_link_remaps.py index 37f5b1be55a..3d461cfa64e 100644 --- a/ci/tools/tests/test_repo_link_check_action.py +++ b/ci/tools/tests/test_repo_link_remaps.py @@ -3,28 +3,21 @@ from __future__ import annotations -import os import re -import shlex -import shutil import subprocess import sys from pathlib import Path import pytest -import yaml -ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(Path(__file__).parent.parent)) +from run_lychee import repo_link_remaps @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_action_prepares_remaps_for_actual_checkout_commit(tmp_path): - action = yaml.safe_load((ROOT / ".github/actions/check-doc-links/action.yml").read_text(encoding="utf-8")) - prepare = next(step for step in action["runs"]["steps"] if step.get("id") == "inputs") +def test_rendered_remaps_use_actual_checkout_commit(tmp_path, monkeypatch): checkout = tmp_path / "checkout with spaces" - helper = checkout / "ci/tools/prepare_lychee_inputs.py" - helper.parent.mkdir(parents=True) - shutil.copyfile(ROOT / "ci/tools/prepare_lychee_inputs.py", helper) + checkout.mkdir() readme = checkout / "README.md" readme.write_text("# Smoke test\n", encoding="utf-8") for arguments in ( @@ -46,44 +39,16 @@ def test_action_prepares_remaps_for_actual_checkout_commit(tmp_path): ): subprocess.run(["git", *arguments], cwd=checkout, check=True) # noqa: S603, S607 head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=checkout, text=True).strip() # noqa: S607 - output = tmp_path / "action-output" - input_list = checkout / "lychee-authored-files.txt" - env = dict( - os.environ, - PATH=f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}", - KIND="authored", - GITHUB_WORKSPACE=str(checkout), - RUNNER_TEMP=str(tmp_path / "runner temp"), - LYCHEE_VERSION="v0.24.2", - POLICY_HASH="test-policy", - GITHUB_OUTPUT=str(output), - LYCHEE_ARGS=f'--files-from "{input_list}" --cache', - GITHUB_RUN_ID="123", - GITHUB_RUN_ATTEMPT="1", - GITHUB_SHA="0" * 40, - GITHUB_SERVER_URL="https://github.com", - GITHUB_REPOSITORY="example-org/cuda-python-mirror", - ) - - subprocess.run( # noqa: S603 - ["bash", "--noprofile", "--norc", "-euo", "pipefail", "-c", prepare["run"]], # noqa: S607 - cwd=checkout, - env=env, - check=True, - capture_output=True, - text=True, - ) - - outputs = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) - args = shlex.split(outputs["args"]) - assert args[:3] == ["--files-from", str(input_list), "--cache"] - remaps = [args[index + 1] for index, argument in enumerate(args) if argument == "--remap"] + monkeypatch.setenv("GITHUB_SHA", "0" * 40) + monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com") + monkeypatch.setenv("GITHUB_REPOSITORY", "example-org/cuda-python-mirror") + args = repo_link_remaps(checkout, rendered=True) + remaps = args[1::2] assert remaps == [ f"^https://github[.]com/NVIDIA/cuda-python/{view}/main/ " f"https://github.com/example-org/cuda-python-mirror/{view}/{head}/" for view in ("blob", "tree") ] - assert input_list.read_text(encoding="utf-8") == f"{readme}\n" for remap in remaps: pattern, replacement = remap.split() view = "blob" if "/blob/" in pattern else "tree" diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py index 2d15f14f49e..fc496009c33 100644 --- a/ci/tools/tests/test_run_lychee.py +++ b/ci/tools/tests/test_run_lychee.py @@ -3,14 +3,18 @@ from __future__ import annotations +import json import re +import subprocess import sys from pathlib import Path +from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) -from run_lychee import repo_link_remaps +import run_lychee +from run_lychee import main, repo_link_remaps @pytest.mark.agent_authored(model="gpt-6.1-sol") @@ -47,3 +51,107 @@ def test_remaps_leave_other_targets_and_nested_urls_unchanged(tmp_path, url): for index in (1, 3): pattern, replacement = arguments[index].split() assert re.sub(pattern, replacement, url) == url + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkeypatch): + executable = tmp_path / "lychee" + executable.touch() + monkeypatch.setenv("LYCHEE_REPORT_DIR", str(tmp_path / "reports")) + with ( + patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), + patch("run_lychee.sys.platform", "linux"), + patch( + "run_lychee.subprocess.run", + side_effect=[subprocess.CompletedProcess([], 2), subprocess.CompletedProcess([], 0)], + ) as run, + ): + assert main(["one.md"]) == 2 + assert main(["two.rst"]) == 0 + reports = [] + for call, code in zip(run.call_args_list, (2, 0), strict=True): + command = call.args[0] + assert command[5:8] == ["--format", "json", "--output"] + report = Path(command[8]) + assert report.parent == tmp_path / "reports" + assert json.loads(report.with_suffix(".status.json").read_text()) == {"exit_code": code} + reports.append(report) + assert reports[0] != reports[1] + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_rendered_hook_checks_untracked_html_in_its_own_cache(tmp_path, monkeypatch): + (tmp_path / "lychee").touch() + docs = tmp_path / "artifacts/docs" + docs.mkdir(parents=True) + (docs / "index.html").write_text('Guide') + (docs / "guide.html").write_text('

Guide

') + (docs / "_static").mkdir() + (docs / "_static/theme.html").write_text("ignored theme") + cache = tmp_path / "rendered cache" + monkeypatch.setenv("LYCHEE_CACHE_DIR", str(cache)) + monkeypatch.setattr(run_lychee, "ROOT", tmp_path) + monkeypatch.delenv("LYCHEE_REPORT_DIR", raising=False) + with ( + patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), + patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=["--remap", "rendered mapping"]) as remaps, + patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run, + ): + assert main(["--rendered", "--cache"]) == 0 + assert (cache / "inputs.txt").read_text().splitlines() == [str(docs / "guide.html"), str(docs / "index.html")] + run.assert_called_once_with( + [ + str(tmp_path / "lychee"), + "--remap", + "rendered mapping", + "--files-from", + str(cache / "inputs.txt"), + "--include-fragments=full", + "--config", + str(tmp_path / "lychee.toml"), + "--cache", + ], + check=False, + cwd=cache, + ) + remaps.assert_called_once_with(tmp_path, rendered=True) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_rendered_hook_keeps_github_views_and_fragments_at_checked_commit(tmp_path, monkeypatch): + commit = "a" * 40 + monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com") + monkeypatch.setenv("GITHUB_REPOSITORY", "NVIDIA-dev/cuda-python-private") + with patch( + "run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=commit + "\n") + ) as run: + arguments = repo_link_remaps(tmp_path, rendered=True) + run.assert_called_once_with( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + for index, view in enumerate(("blob", "tree")): + pattern, replacement = arguments[2 * index + 1].split() + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/README.md#heading" + assert ( + re.sub(pattern, replacement, url) + == f"https://github.com/NVIDIA-dev/cuda-python-private/{view}/{commit}/README.md#heading" + ) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_rendered_hook_rejects_missing_build_before_invoking_native_checker(tmp_path, monkeypatch): + (tmp_path / "lychee").touch() + monkeypatch.setattr(run_lychee, "ROOT", tmp_path) + monkeypatch.setenv("LYCHEE_CACHE_DIR", str(tmp_path / "cache")) + with ( + patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), + patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.subprocess.run") as run, + pytest.raises(ValueError, match="No documentation inputs"), + ): + main(["--rendered", "--cache"]) + run.assert_not_called() diff --git a/ci/tools/tests/test_run_lychee_precommit.py b/ci/tools/tests/test_run_lychee_precommit.py new file mode 100644 index 00000000000..ffcbda133c6 --- /dev/null +++ b/ci/tools/tests/test_run_lychee_precommit.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import retry_lychee +import run_lychee_precommit + + +def native_report(*, code=0, total=5, status=503): + return { + "total": total, + "errors": int(code == 2), + "timeouts": 0, + "error_map": { + "guide.md": [{"url": "https://example.test/guide", "status": {"text": "HTTP error", "code": status}}] + } + if code == 2 + else {}, + "timeout_map": {}, + } + + +def write_part(directory, name, *, code=0, total=5, status=503): + (directory / f"{name}.status.json").write_text(json.dumps({"exit_code": code})) + (directory / f"{name}.json").write_text(json.dumps(native_report(code=code, total=total, status=status))) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_collects_all_chunks_including_an_empty_chunk(tmp_path): + directory = tmp_path / "parts" + directory.mkdir() + write_part(directory, "one", total=0) + write_part(directory, "two", code=2) + write_part(directory, "three") + output = tmp_path / "report.json" + + assert run_lychee_precommit.collect_reports(directory, output, 1) == 2 + data = json.loads(output.read_text()) + assert data["total"] == 10 + assert data["errors"] == 1 + assert len(data["error_map"]["guide.md"]) == 1 + + +@pytest.mark.parametrize( + "problem", + [ + "missing-status", + "native-config-error", + "boolean-exit", + "missing-report", + "bad-json", + "empty-scan", + "extra-incomplete-report", + "outer-failure", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_machine_failures_never_become_advisory_link_failures(tmp_path, problem): + write_part(tmp_path, "one") + if problem == "missing-status": + (tmp_path / "one.status.json").unlink() + elif problem == "native-config-error": + (tmp_path / "one.status.json").write_text('{"exit_code": 1}') + elif problem == "boolean-exit": + (tmp_path / "one.status.json").write_text('{"exit_code": false}') + elif problem == "missing-report": + (tmp_path / "one.json").unlink() + elif problem == "bad-json": + (tmp_path / "one.json").write_text("not JSON") + elif problem == "empty-scan": + write_part(tmp_path, "one", total=0) + elif problem == "extra-incomplete-report": + (tmp_path / "incomplete.json").write_text("{}") + with pytest.raises((OSError, ValueError)): + run_lychee_precommit.collect_reports(tmp_path, tmp_path / "report.json", int(problem == "outer-failure")) + + +@pytest.mark.parametrize("kind", ["authored", "rendered"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_runs_actual_selected_hook_and_unskips_only_that_hook(tmp_path, monkeypatch, kind): + monkeypatch.setattr(run_lychee_precommit, "ROOT", tmp_path) + monkeypatch.setenv("SKIP", "lychee, check-precommit-installed, lychee-rendered") + commands = [] + hook = "lychee" if kind == "authored" else "lychee-rendered" + + def run(command, *, cwd, env, check): + assert check is False + assert cwd == tmp_path + assert hook not in env["SKIP"].split(",") + assert "check-precommit-installed" in env["SKIP"] + assert env["PYTHONUTF8"] == "1" + write_part(Path(env["LYCHEE_REPORT_DIR"]), "native") + commands.append(command) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(run_lychee_precommit.subprocess, "run", run) + report = tmp_path / "report.json" + assert run_lychee_precommit.run_hook(kind, report) == 0 + expected = [sys.executable, "-m", "pre_commit", "run", hook, "--all-files", "--show-diff-on-failure"] + assert commands == [expected + (["--hook-stage", "manual"] if kind == "rendered" else [])] + assert json.loads(report.read_text())["total"] == 5 + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_retries_real_hook_with_cache_intact_and_stops_after_recovery(tmp_path, monkeypatch): + report = tmp_path / "lychee-authored.json" + cache = tmp_path / ".lycheecache" + cache.write_text("already verified") + attempts = [] + delays = [] + + def run_hook(kind, output): + assert kind == "authored" + assert cache.read_text() == "already verified" + attempts.append(output) + code = 2 if len(attempts) == 1 else 0 + output.write_text(json.dumps(native_report(code=code))) + return code + + monkeypatch.setattr(run_lychee_precommit, "run_hook", run_hook) + monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) + monkeypatch.delenv("LYCHEE_ARGS", raising=False) + assert run_lychee_precommit.run_checks("authored", report, policy="cache-warm") == 0 + assert len(attempts) == 2 + assert delays == [60] + assert "passed on attempt 2/3" in report.with_suffix(".md").read_text() + assert "example.test" not in report.with_suffix(".md").read_text() + + +@pytest.mark.parametrize("policy, expected", [("cache-warm", 0), ("check", 2)]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_permanent_link_failure_warns_only_for_cache_warming(tmp_path, monkeypatch, policy, expected): + report = tmp_path / "report.json" + calls = [] + + def run_hook(kind, output): + calls.append(kind) + output.write_text(json.dumps(native_report(code=2, status=404))) + return 2 + + monkeypatch.setattr(run_lychee_precommit, "run_hook", run_hook) + monkeypatch.setattr(retry_lychee.time, "sleep", lambda _: pytest.fail("Unexpected retry")) + assert run_lychee_precommit.run_checks("authored", report, policy=policy) == expected + assert calls == ["authored"] + assert ("Cache warming is advisory" in report.with_suffix(".md").read_text()) == (policy == "cache-warm") + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_missing_native_status_is_fatal_even_for_nightly_cache_warming(tmp_path, monkeypatch, capsys): + monkeypatch.setattr( + run_lychee_precommit.subprocess, "run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 1) + ) + assert ( + run_lychee_precommit.main(["authored", "--policy", "cache-warm", "--report", str(tmp_path / "report.json")]) + == 1 + ) + assert "no native status" in capsys.readouterr().out + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_zero_attempts_rejected_before_running_precommit(tmp_path, monkeypatch): + monkeypatch.setattr(run_lychee_precommit, "run_hook", lambda *_args: pytest.fail("Unexpected invocation")) + with pytest.raises(ValueError, match="positive"): + run_lychee_precommit.run_checks("authored", tmp_path / "report.json", 0) From fd213fe97e186e3e533cdb97163ee14e07f417e3 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 08:48:30 -0700 Subject: [PATCH 2/2] ci: exercise real Lychee hooks in PR checks and nightly [skip ci] Restore shared URL baselines, warm only Lychee with bounded retries, and run the full suite once. Nightly builds authored and rendered caches through the same hooks; remove the separate action and synthetic fixture runner. Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/actions/check-doc-links/action.yml | 121 --------------------- .github/workflows/build-docs.yml | 44 ++++++-- .github/workflows/lychee.yml | 67 ++++++++++-- .github/workflows/pre-commit.yml | 59 ++++++++-- ci/README.md | 106 +++++++++++++----- ci/tools/test_lychee_precommit.py | 95 ---------------- lychee.toml | 4 +- 7 files changed, 224 insertions(+), 272 deletions(-) delete mode 100644 .github/actions/check-doc-links/action.yml delete mode 100644 ci/tools/test_lychee_precommit.py diff --git a/.github/actions/check-doc-links/action.yml b/.github/actions/check-doc-links/action.yml deleted file mode 100644 index 8b7f37ca819..00000000000 --- a/.github/actions/check-doc-links/action.yml +++ /dev/null @@ -1,121 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -name: Check documentation links -description: Check authored or rendered documentation with shared nightly caches and bounded retries - -inputs: - kind: - description: "Input kind: authored or rendered" - required: true - refresh-cache: - description: Check links afresh and publish a new cache baseline - default: "false" - failure-policy: - description: "check allows up to 10 rate-limited URLs; cache-warm warns on all link failures" - default: check - -# Callers provide Python 3.11+ on PATH; the helpers use only its standard library. -runs: - using: composite - steps: - - name: Prepare link-check inputs and cache policy - id: inputs - shell: bash --noprofile --norc -euo pipefail {0} - env: - KIND: ${{ inputs.kind }} - LYCHEE_VERSION: v0.24.2 # Must match lychee-bin in .pre-commit-config.yaml - POLICY_HASH: ${{ hashFiles('lychee.toml', '.github/actions/check-doc-links/action.yml', 'ci/tools/prepare_lychee_inputs.py', 'ci/tools/retry_lychee.py') }} - # The first pass and all retries use the same inputs and cache policy. - LYCHEE_ARGS: >- - --files-from "${{ github.workspace }}/lychee-${{ inputs.kind }}-files.txt" - ${{ inputs.kind == 'rendered' && '--include-fragments=full' || '' }} - --cache - --max-cache-age 1d - --max-concurrency 16 - --host-concurrency 2 - --host-request-interval 250ms - --max-retries 3 - --retry-wait-time 5 - --timeout 30 - --no-progress - --config "${{ github.workspace }}/lychee.toml" - run: | - python "${GITHUB_WORKSPACE}/ci/tools/prepare_lychee_inputs.py" "${KIND}" \ - --output "${GITHUB_WORKSPACE}/lychee-${KIND}-files.txt" - # Keep authored checks separate from rendered checks, which also - # validate fragments. Both callers must use the same cache paths. - mkdir -p "${RUNNER_TEMP}/lychee-${KIND}" - prefix="lychee-v1-${LYCHEE_VERSION}-${KIND}-${POLICY_HASH}-baseline-" - echo "prefix=${prefix}" >> "${GITHUB_OUTPUT}" - echo "key=${prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" - echo "version=${LYCHEE_VERSION}" >> "${GITHUB_OUTPUT}" - # Validate main-branch source links against the actual checkout. Keep - # GitHub's file/directory views so rendered checks retain UI fragments. - checked_commit="$(git rev-parse HEAD)" - for view in blob tree; do - LYCHEE_ARGS+=" --remap \"^https://github[.]com/NVIDIA/cuda-python/${view}/main/ ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/${view}/${checked_commit}/\"" - done - echo "Checking repository source links at ${checked_commit}" - echo "args=${LYCHEE_ARGS}" >> "${GITHUB_OUTPUT}" - - - name: Restore successful checks from the nightly baseline - if: ${{ inputs.refresh-cache != 'true' }} - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}/lychee-${{ inputs.kind }}/.lycheecache - key: ${{ steps.inputs.outputs.key }} - restore-keys: ${{ steps.inputs.outputs.prefix }} - - - name: Check documentation links - id: lychee - uses: lycheeverse/lychee-action@6da1d14f3a43098a294b7696d93d938aa8d20fc0 # supports v0.24.x archive layout - with: - args: ${{ steps.inputs.outputs.args }} - # The next step owns bounded retries and the final success gate. - fail: false - # The action's empty-report guard only understands Markdown. - # Our helper validates the JSON report and rejects zero checked links. - failIfEmpty: false - format: json - jobSummary: false - lycheeVersion: ${{ steps.inputs.outputs.version }} - workingDirectory: ${{ runner.temp }}/lychee-${{ inputs.kind }} - output: ${{ github.workspace }}/lychee-${{ inputs.kind }}.json - token: ${{ github.token }} - - - name: Retry transient link failures using cached successful checks - if: ${{ steps.lychee.outcome == 'success' }} - shell: bash --noprofile --norc -euo pipefail {0} - working-directory: ${{ runner.temp }}/lychee-${{ inputs.kind }} - env: - EXIT_CODE: ${{ steps.lychee.outputs.exit_code }} - REPORT: ${{ github.workspace }}/lychee-${{ inputs.kind }}.json - LYCHEE_ARGS: ${{ steps.inputs.outputs.args }} - FAILURE_POLICY: ${{ inputs.failure-policy }} - GITHUB_TOKEN: ${{ github.token }} - run: | - python "${GITHUB_WORKSPACE}/ci/tools/retry_lychee.py" \ - --initial-exit-code "${EXIT_CODE}" --report "${REPORT}" \ - --max-attempts 3 --policy "${FAILURE_POLICY}" - - - name: Publish fresh successful checks - # Manual branch tests publish isolated branch caches. Nightly callers - # on the default branch publish the baseline that all PRs can restore. - if: ${{ always() && inputs.refresh-cache == 'true' && steps.inputs.outputs.key != '' }} - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}/lychee-${{ inputs.kind }}/.lycheecache - key: ${{ steps.inputs.outputs.key }} - - - name: Upload link-check report - if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: lychee-${{ inputs.kind }}-${{ github.run_id }}-${{ github.run_attempt }} - path: | - ${{ github.workspace }}/lychee-${{ inputs.kind }}*.json - ${{ github.workspace }}/lychee-${{ inputs.kind }}*.md - if-no-files-found: ignore - retention-days: 7 diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 4558992deda..441d6ec2f36 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -284,21 +284,47 @@ jobs: fi mv ${COMPONENT}/docs/build/html/* artifacts/docs/${TARGET} - # Keep link checks in the existing PR docs build, using the same revision - # as its wheels. Nightly supplies shared caches for both kinds of inputs. - - name: Check authored documentation links + # Authored sources are checked by the ordinary Linux/Windows pre-commit + # jobs. Generated HTML and anchors need this completed documentation build. + - name: Install pre-commit for rendered link checks if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} - timeout-minutes: 60 - uses: ./.github/actions/check-doc-links + run: | + echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" + echo "LYCHEE_CACHE_DIR=${RUNNER_TEMP}/lychee-rendered" >> "${GITHUB_ENV}" + python -m pip install pre-commit + + - name: Describe the rendered link cache + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} + id: lychee-cache + run: python ci/tools/lychee_cache.py rendered --github-output "${GITHUB_OUTPUT}" + + - name: Restore successful rendered link checks + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - kind: authored + path: ${{ steps.lychee-cache.outputs.path }} + key: ${{ steps.lychee-cache.outputs.key }} + restore-keys: ${{ steps.lychee-cache.outputs.prefix }} + enableCrossOsArchive: true - - name: Check rendered documentation links + - name: Check rendered documentation links through pre-commit if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} timeout-minutes: 60 - uses: ./.github/actions/check-doc-links + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python ci/tools/run_lychee_precommit.py rendered + --policy check --max-attempts 3 + --report "${RUNNER_TEMP}/lychee-reports/lychee-rendered.json" + + - name: Upload rendered link-check reports + if: ${{ always() && !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - kind: rendered + name: lychee-rendered-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lychee-reports/ + if-no-files-found: ignore + retention-days: 7 - name: Upload docs GitHub Pages artifact if: ${{ inputs.deploy-docs }} diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index 1f0c46fae44..4b81fb38dc1 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -2,9 +2,9 @@ # # SPDX-License-Identifier: Apache-2.0 -# Build documentation nightly and warm the link cache, reporting unresolved links -# as warnings. Full PR CI reuses successful checks in build-docs.yml. -# Ordinary PR updates run pre-commit without Lychee and do not build docs here. +# Warm authored and rendered link caches through the configured pre-commit +# hooks. Ordinary PRs reuse authored results; full docs CI reuses rendered +# results. Nightly starts afresh and reports unresolved links as warnings. name: "CI: Documentation links" @@ -50,6 +50,8 @@ jobs: fail-fast: false matrix: kind: [authored, rendered] + env: + PYTHONUTF8: "1" steps: - name: Checkout checked revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,10 +64,26 @@ jobs: submodules: ${{ matrix.kind == 'rendered' }} - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" + - name: Set link-check cache locations + run: | + echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" + echo "LYCHEE_CACHE_DIR=${RUNNER_TEMP}/lychee-rendered" >> "${GITHUB_ENV}" + + - name: Restore pre-commit environments + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: pre-commit-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit-${{ runner.os }}-py3.14 + + - name: Install pre-commit + run: python -m pip install pre-commit + - name: Setup pixi if: ${{ matrix.kind == 'rendered' }} uses: ./.github/actions/setup-pixi @@ -74,9 +92,42 @@ jobs: if: ${{ matrix.kind == 'rendered' }} run: pixi run --manifest-path cuda_core/pixi.toml -e docs docs-build-all-latest - - name: Check documentation links - uses: ./.github/actions/check-doc-links + - name: Describe the link cache + id: lychee-cache + run: python ci/tools/lychee_cache.py "${{ matrix.kind }}" --github-output "${GITHUB_OUTPUT}" + + - name: Restore successful link checks + # Refresh mode deliberately skips yesterday's URL results. + if: ${{ !inputs.refresh-cache }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.lychee-cache.outputs.path }} + key: ${{ steps.lychee-cache.outputs.key }} + restore-keys: ${{ steps.lychee-cache.outputs.prefix }} + enableCrossOsArchive: true + + - name: Check documentation links through pre-commit + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python ci/tools/run_lychee_precommit.py "${{ matrix.kind }}" + --policy "${{ inputs.refresh-cache && 'cache-warm' || 'check' }}" + --max-attempts 3 + --report "${RUNNER_TEMP}/lychee-reports/lychee-${{ matrix.kind }}.json" + + - name: Publish fresh successful link checks + if: ${{ !cancelled() && inputs.refresh-cache && steps.lychee-cache.outputs.key != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.lychee-cache.outputs.path }} + key: ${{ steps.lychee-cache.outputs.key }} + enableCrossOsArchive: true + + - name: Upload link-check reports + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - kind: ${{ matrix.kind }} - refresh-cache: ${{ inputs.refresh-cache || false }} - failure-policy: ${{ inputs.refresh-cache && 'cache-warm' || 'check' }} + name: lychee-${{ matrix.kind }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lychee-reports/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 8b64306ba35..ece79eeef3e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -41,10 +41,10 @@ jobs: - name: Windows os: windows-latest env: - # Full-CI docs builds and nightly check links. The installation reminder - # is for developer machines; CI does not install a Git commit hook. - # A separate bounded check below exercises Lychee with local fixtures. - SKIP: lychee,check-precommit-installed + # The installation reminder is for developer machines; CI does not + # install a Git commit hook. All other hooks, including Lychee, run. + SKIP: check-precommit-installed + PYTHONUTF8: "1" # Minor version used by setup-python and as the cache restore-key prefix. PYTHON_VERSION: "3.14" steps: @@ -138,17 +138,52 @@ jobs: LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} run: python ci/tools/smoke_test_lychee_precommit.py --cleanup "$LYCHEE_SMOKE_DIR" + - name: Describe the authored link cache + id: lychee-cache + run: python ci/tools/lychee_cache.py authored --github-output "${GITHUB_OUTPUT}" + + - name: Restore successful link checks from the shared baseline + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.lychee-cache.outputs.path }} + key: ${{ steps.lychee-cache.outputs.key }} + restore-keys: ${{ steps.lychee-cache.outputs.prefix }} + enableCrossOsArchive: true + + # Warm only Lychee, retaining successful checks between attempts. The + # full suite below runs once: formatter changes must remain failures. + # A missing baseline is supported; the first pass then checks afresh. + - name: Warm the Lychee cache with up to three hook attempts + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python ci/tools/run_lychee_precommit.py authored + --policy cache-warm --max-attempts 3 + --report "${RUNNER_TEMP}/lychee-reports/lychee-authored.json" + - name: Run pre-commit + env: + GITHUB_TOKEN: ${{ github.token }} run: pre-commit run --all-files --show-diff-on-failure - # Full link scans generate external network traffic and need cache and - # retry handling; documentation CI provides that. These local fixtures - # keep per-PR coverage focused on the installed hook on each platform. - - name: Exercise the configured Lychee hook with local fixtures - env: - SKIP: check-precommit-installed - PYTHONUTF8: "1" - run: python ci/tools/test_lychee_precommit.py + - name: Save successful authored link checks from main + # Nightly and main pushes supply the shared baseline. PR runs only + # restore it; saving preserves timestamps and never caches failures. + if: ${{ !cancelled() && runner.os == 'Linux' && github.ref_name == github.event.repository.default_branch && steps.lychee-cache.outputs.key != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.lychee-cache.outputs.path }} + key: ${{ steps.lychee-cache.outputs.key }} + enableCrossOsArchive: true + + - name: Upload Lychee warm-up reports + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: lychee-precommit-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lychee-reports/ + if-no-files-found: ignore + retention-days: 7 - name: Save pre-commit cache # Only the default branch saves, so per-PR merge refs don't store their diff --git a/ci/README.md b/ci/README.md index 6fdc6ac6b28..704760d3b75 100644 --- a/ci/README.md +++ b/ci/README.md @@ -36,31 +36,87 @@ at the start of a URL, leaving URLs embedded in another URL unchanged. Relative links and links to tags, commit SHAs, other branches, and other repositories are checked as written. -CI remaps these links to the CI repository at the actual checked-out commit -using `--remap`, retaining GitHub's file/directory views and fragment behavior. -The initial check and all retries use the same remaps. Missing targets in that -commit still fail. - -Local pre-commit runs the Python hook `ci/tools/run_lychee.py`, which computes -the checkout's file URI and passes anchored native `--remap` rules to Lychee. -This checks paths in the working tree, including uncommitted and unpushed -targets. The hook installs the official PyPI `lychee-bin==0.24.2` package in -its own Python environment and uses the platform's native wheel. Local checks -retain their existing policy of not checking fragments. - -The Linux and Windows pre-commit CI jobs run a bounded smoke check of the -installed hook against local Markdown and reST fixtures. They check valid -files and directories and missing targets in paths containing spaces, and -verify that the source files are unchanged. This smoke check does not scan all -external documentation links; full authored and rendered checks run in -documentation CI. - -The `check-doc-links` action is not currently present on `12.9.x`, `13.4.x`, -`12.2.x`, or `11.8.x`; merging changes into `main` does not update those -branches' workflows. A maintenance branch's `/main/` URLs may intentionally -refer to a different tree from its checkout. Before adopting this hook or -action there, choose an explicit validation policy for those links instead of -assuming that they refer to the maintenance checkout. +Authored Markdown and reST are checked through the same `lychee` pre-commit +hook in development and in Linux/Windows PR CI. The Python wrapper +`ci/tools/run_lychee.py` computes the checkout's file URI and passes anchored +native `--remap` rules to Lychee. This checks working-tree paths, including +uncommitted and unpushed targets, without changing the documentation sources. +The hook installs the official PyPI `lychee-bin==0.24.2` native wheel in its +isolated Python environment. Authored checks do not validate fragments. + +### PR Checks And Cache Warming + +PR CI restores successful URL checks from the shared baseline, then runs +`pre-commit run lychee --all-files` through `ci/tools/run_lychee_precommit.py`. +The helper allows up to three hook attempts, with 60/120-second cooldowns +when all remaining failures are transient HTTP failures. Successful checks +accumulate in the local cache; permanent link failures stop additional +warm-up attempts. A missing baseline simply starts with uncached checks. + +After warming, `pre-commit run --all-files --show-diff-on-failure` runs once. +Its normal strict result is the final gate, including for unresolved HTTP +429s. This can make four total Lychee invocations, but successful external +checks are reused. Formatter changes remain failures because the complete +suite is not retried. A missing or invalid native report, empty check, or +hook installation/execution error fails the warm-up step. + +This replaces the fixture-only hook step and the duplicate authored scan in +the full-CI documentation job. Standalone CI-tool unit tests remain in nightly +CI; they are not added as a new per-PR gate. + +### Rendered Documentation + +After building the documentation, full CI runs the manual hook through the +same driver: + +```bash +python ci/tools/run_lychee_precommit.py rendered --policy check \ + --report /path/to/reports/lychee-rendered.json +``` + +The driver invokes `pre-commit run lychee-rendered --hook-stage manual +--all-files`. This hook enumerates generated HTML under `artifacts/docs`, +excluding `_static` assets, and enables full fragment validation. Generated +HTML is selected explicitly because it is not part of pre-commit's tracked +source file list. The hook uses the same pinned wheel and shared wrapper as +the authored hook, without a separate Lychee action or binary installation. + +Rendered checks remap main-branch source URLs to the CI repository at the +actual checked-out commit, retaining GitHub's file/directory views and their +fragment behavior. All attempts use the same inputs and remaps. This mode +retains up to three attempts and the existing warning allowance for at most +ten distinct URLs that still return HTTP 429; other unresolved links fail. + +### Shared URL Caches + +Nightly invokes both configured hooks through the driver. Refresh mode skips +restoring yesterday's URL cache, checks afresh, and publishes the successful +results. Genuine link failures are advisory during cache warming; build, +installation, execution, and report errors remain failures. Linux pre-commit +runs on `main` also publish their accumulated authored results. PR jobs +restore caches and do not publish them. + +`ci/tools/lychee_cache.py` gives all callers the same cache policy and unique +run-ID/attempt snapshot keys. The namespace includes the hook configuration +(and native wheel pin), checker configuration, and shared helpers. Authored +and rendered caches remain separate because only rendered checks validate +fragments. Only the URL-result file is shared across operating systems; +pre-commit's installed environments retain their OS/Python-specific caches. + +Successful external checks retain their original timestamps and expire after +one day. Failed URLs are not persisted, and filesystem targets are checked +afresh. The rendered hook uses its own cache directory, selected by +`LYCHEE_CACHE_DIR` (default `.lychee-rendered`), so its fragment-aware results +cannot mix with authored results in the checkout's `.lycheecache`. Per-pass +JSON reports and a Markdown summary are retained as CI artifacts. + +### Maintenance Branches + +Merging these changes into `main` does not update maintenance branches' +workflows. A maintenance branch's `/main/` URLs may intentionally refer to a +different tree from its checkout. Before adopting these hooks there, choose +an explicit validation policy for those links instead of assuming that they +refer to the maintenance checkout. ## Temporary Pixi Lockfile Maintenance Pause diff --git a/ci/tools/test_lychee_precommit.py b/ci/tools/test_lychee_precommit.py deleted file mode 100644 index 50f73d0c30f..00000000000 --- a/ci/tools/test_lychee_precommit.py +++ /dev/null @@ -1,95 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Exercise the configured Lychee pre-commit hook without scanning external links.""" - -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path -from tempfile import TemporaryDirectory -from urllib.parse import quote - -ROOT = Path(__file__).resolve().parents[2] - - -def run_hook(sources: dict[Path, bytes], missing: Path | None = None) -> None: - result = subprocess.run( # noqa: S603 - [ - sys.executable, - "-m", - "pre_commit", - "run", - "lychee", - "--verbose", - "--color", - "never", - "--files", - *(source.relative_to(ROOT).as_posix() for source in sources), - ], - cwd=ROOT, - env=dict(os.environ, SKIP=""), - capture_output=True, - encoding="utf-8", - timeout=120, - ) - output = result.stdout + result.stderr - print(output, end="", flush=True) - for source, original in sources.items(): - if source.read_bytes() != original: - raise RuntimeError(f"The Lychee hook changed {source}") - if "- hook id: lychee" not in output or "Skipped" in output: - raise RuntimeError("The configured Lychee hook did not run") - if missing is None: - if result.returncode != 0: - raise RuntimeError("The Lychee hook rejected existing checkout targets") - elif ( - result.returncode == 0 - or "File not found" not in output - or missing.as_uri() not in output - or "- exit code: 2" not in output - ): - raise RuntimeError(f"Expected Lychee to report the missing local target {missing}") - - -def main() -> None: - # Generate sources inside the checkout so pre-commit applies the real file filters. - with TemporaryDirectory(prefix="lychee hook smoke ", dir=ROOT) as temporary: - fixture = Path(temporary) - directory = fixture / "new directory" - directory.mkdir() - target = fixture / "new source #$.py" - target.write_text("value = 1\n", encoding="utf-8") - relative = fixture / "relative guide.rst" - relative.write_text("Relative target\n", encoding="utf-8") - prefix = "https://github.com/NVIDIA/cuda-python" - file_url = f"{prefix}/blob/main/{quote(target.relative_to(ROOT).as_posix())}" - directory_url = f"{prefix}/tree/main/{quote(directory.relative_to(ROOT).as_posix())}/" - sources = { - fixture / "source guide.md": ( - f"[File]({file_url})\r\n[Directory]({directory_url})\r\n[Relative](relative%20guide.rst)\r\n" - ).encode(), - fixture / "source guide.rst": ( - f"`File <{file_url}>`_\n`Directory <{directory_url}>`_\n`Relative `_\n" - ).encode(), - } - for source, content in sources.items(): - source.write_bytes(content) - - print("Checking existing local file, directory, and relative targets", flush=True) - run_hook(sources) - target.unlink() - print("Checking that a previously valid file is rejected after deletion", flush=True) - run_hook(sources, missing=target) - target.write_text("value = 1\n", encoding="utf-8") - directory.rmdir() - print("Checking that a previously valid directory is rejected after deletion", flush=True) - run_hook(sources, missing=directory) - - print("Configured Lychee hook checks passed; documentation sources were unchanged.") - - -if __name__ == "__main__": - main() diff --git a/lychee.toml b/lychee.toml index 8b9dfd5398c..4ad1995e377 100644 --- a/lychee.toml +++ b/lychee.toml @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Configuration for lychee, the doc link-checker invoked from -# .github/actions/check-doc-links. See https://lychee.cli.rs/usage/config/. +# Configuration shared by the authored and rendered Lychee pre-commit hooks. +# See https://lychee.cli.rs/guides/config/. exclude = [ # PR previews are not published yet during link checking.