diff --git a/.github/actions/check-doc-links/action.yml b/.github/actions/check-doc-links/action.yml deleted file mode 100644 index b835c744423..00000000000 --- a/.github/actions/check-doc-links/action.yml +++ /dev/null @@ -1,114 +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}" - 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/actions/lychee-links/action.yml b/.github/actions/lychee-links/action.yml new file mode 100644 index 00000000000..d38796444e1 --- /dev/null +++ b/.github/actions/lychee-links/action.yml @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Check documentation links +description: Run a configured Lychee pre-commit hook with the shared URL cache and retained reports + +inputs: + kind: + description: "Which hook to run: authored or rendered" + required: true + policy: + description: "check accepts up to 10 rate-limited URLs; cache-warm reports every unresolved link as a warning" + default: check + refresh: + description: "Check links afresh and publish the successful results as the new baseline" + default: "false" + +# Callers provide Python and the checked-out repository; the build of any +# rendered documentation must already be complete. +runs: + using: composite + steps: + - name: Describe the link cache + id: cache + shell: bash --noprofile --norc -euo pipefail {0} + env: + KIND: ${{ inputs.kind }} + # Anything that can change a check result or the cache's meaning. The + # hook configuration carries the native wheel pin and every hook option. + POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py') }} + run: | + # Authored and rendered results stay apart: only rendered checks validate + # fragments, and Lychee's cache does not record which validation ran. + case "${KIND}" in + authored) path=.lycheecache ;; + rendered) path=.lychee-rendered/.lycheecache ;; + *) echo "::error::Unknown link-check kind: ${KIND}"; exit 1 ;; + esac + prefix="lychee-v1-${KIND}-${POLICY_HASH}-baseline-" + echo "path=${path}" >> "${GITHUB_OUTPUT}" + echo "prefix=${prefix}" >> "${GITHUB_OUTPUT}" + echo "key=${prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}" + + - name: Restore successful link checks + # Refresh mode deliberately skips earlier URL results. + if: ${{ inputs.refresh != 'true' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.cache.outputs.path }} + key: ${{ steps.cache.outputs.key }} + restore-keys: ${{ steps.cache.outputs.prefix }} + enableCrossOsArchive: true + + - name: Check links through the configured pre-commit hook + shell: bash --noprofile --norc -euo pipefail {0} + env: + KIND: ${{ inputs.kind }} + POLICY: ${{ inputs.policy }} + GITHUB_TOKEN: ${{ github.token }} + run: | + # Install into the interpreter that runs the driver, whatever shell + # profile the caller's other steps use. + python -m pip install pre-commit + python ci/tools/run_lychee_precommit.py "${KIND}" --policy "${POLICY}" --max-attempts 3 \ + --report "${RUNNER_TEMP}/lychee-reports/lychee-${KIND}.json" + + - name: Publish fresh successful link checks + # Only nightly refreshes publish, so every pull request restores the same + # baseline. Saving keeps original timestamps and never caches failures. + if: ${{ !cancelled() && inputs.refresh == 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.cache.outputs.path }} + key: ${{ steps.cache.outputs.key }} + enableCrossOsArchive: true + + - name: Upload link-check reports + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: lychee-${{ inputs.kind }}-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lychee-reports/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1936de84b58..72fe9d76db7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,10 +36,6 @@ updates: # Match hook repositories, leaving additional_dependencies pins unchanged. allow: - dependency-name: "https://github.com/*" - # Lychee's hook revision, LYCHEE_VERSION argument, and CI binary pin must - # be upgraded together in a coordinated manual update. - ignore: - - dependency-name: "https://github.com/lycheeverse/lychee" groups: pre-commit-monthly: diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 4558992deda..322d772f1f0 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -284,19 +284,21 @@ 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 + # Check links and anchors in the rendered HTML built above. + # Links in authored Markdown and reST files are checked separately by the + # ordinary Linux/Windows pre-commit jobs. + # The link-check action runs in a non-login shell, so give it a known + # interpreter instead of the conda environment used above. + - name: Set up Python for link checks if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} - timeout-minutes: 60 - uses: ./.github/actions/check-doc-links + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - kind: authored + python-version: "3.14" - - 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 + uses: ./.github/actions/lychee-links with: kind: rendered diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index 1f0c46fae44..6a3f519ce9b 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -2,9 +2,10 @@ # # 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, reports unresolved links as warnings, and is +# the only producer of both shared baselines. name: "CI: Documentation links" @@ -50,6 +51,10 @@ jobs: fail-fast: false matrix: kind: [authored, rendered] + env: + PYTHONUTF8: "1" + # Reuse the same minor version for Python setup and cache fallback. + PYTHON_VERSION: "3.14" steps: - name: Checkout checked revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,9 +67,20 @@ jobs: submodules: ${{ matrix.kind == 'rendered' }} - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.14" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Set pre-commit cache location + run: echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${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 }}-py${{ env.PYTHON_VERSION }} - name: Setup pixi if: ${{ matrix.kind == 'rendered' }} @@ -74,9 +90,9 @@ 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: Check documentation links through pre-commit + uses: ./.github/actions/lychee-links with: kind: ${{ matrix.kind }} - refresh-cache: ${{ inputs.refresh-cache || false }} - failure-policy: ${{ inputs.refresh-cache && 'cache-warm' || 'check' }} + policy: ${{ inputs.refresh-cache && 'cache-warm' || 'check' }} + refresh: ${{ inputs.refresh-cache || false }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index becaaf6feec..d493c212879 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -10,7 +10,7 @@ on: push: # PR events cover draft and fork PRs without also checking copy-pr-bot's # pull-request/N mirrors. Only the default branch needs a push check, and - # it is the only ref that refreshes the saved cache (see the save step). + # it is the only ref that saves the pre-commit environment cache. branches: - "main" - "ctk-next" @@ -41,9 +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. - 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,7 +139,19 @@ jobs: LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} run: python ci/tools/smoke_test_lychee_precommit.py --cleanup "$LYCHEE_SMOKE_DIR" + # Warm only Lychee from the shared nightly baseline, retaining successful + # checks between up to three 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 + uses: ./.github/actions/lychee-links + with: + kind: authored + policy: cache-warm + - name: Run pre-commit + env: + GITHUB_TOKEN: ${{ github.token }} run: pre-commit run --all-files --show-diff-on-failure - name: Save pre-commit cache 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..d5153b18312 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,21 +108,45 @@ repos: - repo: local hooks: - id: lychee - name: lychee + name: lychee (authored documentation) entry: python ./ci/tools/run_lychee.py language: python - additional_dependencies: - - lychee-bin==0.24.2 # Must match check-doc-links/action.yml + # Update this shared pin manually until Dependabot releases support for + # local-hook dependencies: https://github.com/dependabot/dependabot-core/issues/15489 + # Once supported, add lychee-bin to the allowlist in .github/dependabot.yml. + additional_dependencies: &lychee_dependencies + - lychee-bin==0.24.2 types: [text] require_serial: true args: - --cache + - --max-cache-age=3d - --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=3d + - --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/README.md b/ci/README.md index be94dfb035d..d364dc40c67 100644 --- a/ci/README.md +++ b/ci/README.md @@ -24,6 +24,50 @@ The pinned checkout action's [`README`](https://github.com/actions/checkout/blob/3d3c42e5aac5ba805825da76410c181273ba90b1/README.md#fetch-all-history-for-all-tags-and-branches) documents `fetch-depth: 0` for fetching all history, branches, and tags. +## Documentation Link Checks + +Two pre-commit hooks check links with one pinned native wheel (`lychee-bin`, +see `.pre-commit-config.yaml`) through the wrapper `ci/tools/run_lychee.py`: +`lychee` checks authored Markdown and reST (fragments unchecked), and the +manual-stage `lychee-rendered` checks the generated HTML in `artifacts/docs`, +including fragments. Developers run the same hooks locally. + +CI runs a hook through `ci/tools/run_lychee_precommit.py `, +wrapped with the shared cache and report handling in +`.github/actions/lychee-links`. The driver runs the real hook and merges the +native JSON report of every invocation (pre-commit may split long file lists). +It makes up to three attempts with 60/120-second cooldowns, but only while all +remaining failures are transient HTTP trouble: timeouts, 408, 429, or 5xx. +Missing, malformed, or inconsistent reports, and scans that check no links, +fail explicitly. Per-attempt reports and a summary are kept for seven days. + +| Where | What runs | Final result | +| --- | --- | --- | +| Linux/Windows pre-commit | Offline hook smoke check, then authored link warm-up from the shared cache, then the full suite once | The strict pre-commit result, including unresolved HTTP 429s | +| Full-CI documentation build | Rendered link check of the HTML it just built | Unresolved links fail, except that up to ten distinct URLs may remain at HTTP 429 as a warning | +| Nightly (`lychee.yml`) | Afresh authored and rendered checks | Unresolved links are warnings; setup, execution, and report errors fail | + +The warm-up only fills the cache so the full suite, whose formatter changes must +stay failures, mostly reuses successful checks. + +### Shared URL Caches + +Nightly is the only producer. It skips restoring, checks afresh, and publishes +successful results; every other job restores the newest baseline and never +publishes. Failed URLs and filesystem targets are never cached. Successes keep +their original timestamps and expire after three days, so a missed nightly +refresh does not break reuse, but a link that breaks after a nightly success +can stay unnoticed for up to 72 hours. + +The action keys each baseline by checker kind and a hash of +`.pre-commit-config.yaml` (hook options and wheel pin), `lychee.toml`, and +`ci/tools/run_lychee.py`, `run_lychee_precommit.py`, and `retry_lychee.py`, so +editing any of them starts a cold cache. +Authored and rendered caches stay apart because only rendered checks validate +fragments and Lychee's cache does not record which validation ran. URL results +are shared across operating systems; pre-commit's installed environments keep +their OS- and Python-specific caches. + ## Temporary Pixi Lockfile Maintenance Pause Pixi lockfile freshness enforcement and automated refreshes are temporarily diff --git a/ci/tools/prepare_lychee_inputs.py b/ci/tools/prepare_lychee_inputs.py deleted file mode 100644 index 9c723398016..00000000000 --- a/ci/tools/prepare_lychee_inputs.py +++ /dev/null @@ -1,70 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Write deterministic input lists for authored or rendered documentation links.""" - -from __future__ import annotations - -import argparse -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def authored_inputs(root: Path) -> list[Path]: - """Select tracked Markdown and reStructuredText outside qa/, skipping symlinks.""" - result = subprocess.run( - ["git", "ls-files", "-z", "--", "*.md", "*.rst"], # noqa: S607 - cwd=root, - check=True, - capture_output=True, - ) - paths = [] - for name in result.stdout.decode("utf-8").split("\0"): - if not name or name.startswith("qa/"): - continue - path = root / name - if path.is_file() and not path.is_symlink(): - paths.append(path) - return sorted(paths) - - -def rendered_inputs(docs_root: Path) -> list[Path]: - """Select built HTML, excluding theme assets under _static directories.""" - return sorted( - path - for path in docs_root.rglob("*.html") - if path.is_file() and "_static" not in path.relative_to(docs_root).parts - ) - - -def write_inputs(paths: list[Path], output: Path) -> None: - """Reject an empty or ambiguous file list before invoking lychee.""" - if not paths: - raise ValueError("No documentation inputs found for lychee") - lines = [str(path.absolute()) for path in paths] - if any("\n" in line or "\r" in line for line in lines): - raise ValueError("Lychee input paths must not contain newlines") - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text("\n".join(lines) + "\n", encoding="utf-8") - print(f"Wrote {len(lines)} lychee inputs to {output}") - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("kind", choices=("authored", "rendered")) - parser.add_argument("--repo-root", type=Path, default=ROOT) - parser.add_argument("--docs-root", type=Path, default=Path("artifacts/docs")) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - root = args.repo_root.resolve() - if args.kind == "authored": - paths = authored_inputs(root) - else: - paths = rendered_inputs((root / args.docs_root).absolute()) - write_inputs(paths, args.output) - - -if __name__ == "__main__": - main() diff --git a/ci/tools/retry_lychee.py b/ci/tools/retry_lychee.py index 5d50f5187c6..59e90257e69 100644 --- a/ci/tools/retry_lychee.py +++ b/ci/tools/retry_lychee.py @@ -5,15 +5,13 @@ from __future__ import annotations -import argparse import html import json import os import re -import shlex 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 +60,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 +71,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}") @@ -135,16 +133,11 @@ def attempt_report(report: Path, attempt: int) -> Path: return report.with_name(f"{report.stem}-attempt-{attempt}{report.suffix}") -def finish(report: Path, data: Report | None, exit_codes: list[int], message: str, *, warning: bool = False) -> None: +def finish(report: Path, data: Report, exit_codes: list[int], message: str, *, warning: bool = False) -> None: """Publish the final result without presenting recovered failures as current.""" summary = f"## Documentation links\n\n{message}\n\n| Attempt | Exit code |\n| --- | --- |\n" summary += "".join(f"| {attempt} | {code} |\n" for attempt, code in enumerate(exit_codes, start=1)) - if data is not None: - summary += f"\n### Final attempt\n\n{render_report(data)}\n" - else: - summary += ( - "\nThe final attempt did not produce a link-check report. Earlier reports are retained as artifacts.\n" - ) + summary += f"\n### Final attempt\n\n{render_report(data)}\n" report.with_suffix(".md").write_text(summary, encoding="utf-8") print(f"::warning::{message}" if warning else message, flush=True) if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"): @@ -173,22 +166,30 @@ 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 require_link_check_exit(code: int, attempt: int) -> None: + """Native execution failures raise before this point; only 0 and 2 describe link results.""" + if code not in (0, LINK_CHECK_FAILURE): + raise ValueError(f"Unexpected lychee exit code on attempt {attempt}: {code}") + + +def retry_lychee( + initial_exit_code: int, + report: Path, + max_attempts: int = 3, + *, + policy: str = "check", + run_attempt: Callable[[Path], int], +) -> 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}") + require_link_check_exit(initial_exit_code, 1) if max_attempts < 1: raise ValueError("max_attempts must be positive") if policy not in ("check", "cache-warm"): raise ValueError(f"Unknown lychee failure policy: {policy}") exit_codes = [initial_exit_code] - if initial_exit_code not in (0, LINK_CHECK_FAILURE): - finish(report, None, exit_codes, f"Lychee stopped on attempt 1 with exit code {initial_exit_code}; no retry.") - return initial_exit_code data = read_report(report, initial_exit_code) shutil.copyfile(report, attempt_report(report, 1)) - lychee_args = None while True: attempt = len(exit_codes) if exit_codes[-1] == 0: @@ -210,11 +211,6 @@ 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: - 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") - cooldown = min(60 * attempt, 120) print( f"Lychee attempt {attempt} had transient failures; waiting {cooldown}s before attempt {attempt + 1}/{max_attempts}.", @@ -222,36 +218,8 @@ 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): - finish( - report, - None, - exit_codes, - f"Lychee stopped on attempt {attempt + 1} with exit code {result.returncode}; no retry.", - ) - return result.returncode if result.returncode > 0 else 1 - data = read_report(current_report, result.returncode) + code = run_attempt(current_report) + require_link_check_exit(code, attempt + 1) + exit_codes.append(code) + data = read_report(current_report, code) shutil.copyfile(current_report, report) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--initial-exit-code", type=int, required=True) - parser.add_argument("--report", type=Path, required=True) - parser.add_argument("--max-attempts", type=int, default=3) - parser.add_argument("--policy", choices=("check", "cache-warm"), default="check") - args = parser.parse_args() - try: - return retry_lychee(args.initial_exit_code, 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/run_lychee.py b/ci/tools/run_lychee.py index 0d8be24d06d..84fb8684c60 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -13,11 +13,36 @@ from __future__ import annotations +import json +import os import subprocess import sys import sysconfig +import uuid from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] + + +def rendered_inputs(docs_root: Path) -> list[Path]: + """Select built HTML, excluding theme assets under _static directories.""" + return sorted( + path + for path in docs_root.rglob("*.html") + if path.is_file() and "_static" not in path.relative_to(docs_root).parts + ) + + +def write_inputs(paths: list[Path], output: Path) -> None: + """Reject an empty or ambiguous file list before invoking lychee.""" + if not paths: + raise ValueError("No documentation inputs found for lychee") + lines = [str(path.absolute()) for path in paths] + if any("\n" in line or "\r" in line for line in lines): + raise ValueError("Lychee input paths must not contain newlines") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + def main(arguments: list[str] | None = None) -> int: # Resolve the executable from this hook's environment to honor the pin. @@ -26,8 +51,41 @@ 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), *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) + # Lychee writes .lycheecache into its working directory; keep the + # fragment-aware rendered results apart from the authored cache in ROOT. + cache = ROOT / ".lychee-rendered" + 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 can split long file lists even with require_serial enabled. + # Give each native invocation a unique report so one chunk's report + # cannot overwrite another. + report = directory / f"{uuid.uuid4().hex}.json" + arguments = ["--format", "json", "--output", str(report), *arguments] + + code = subprocess.run([str(executable), *arguments], check=False, **options).returncode # noqa: S603 + 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_prepare_lychee_inputs.py b/ci/tools/tests/test_prepare_lychee_inputs.py deleted file mode 100644 index 15bfb945dc2..00000000000 --- a/ci/tools/tests/test_prepare_lychee_inputs.py +++ /dev/null @@ -1,83 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) -from prepare_lychee_inputs import authored_inputs, rendered_inputs, write_inputs - - -@pytest.mark.agent_authored(model="gpt-6") -def test_authored_inputs_use_tracked_documents_and_exclude_qa(tmp_path): - subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) # noqa: S603, S607 - tracked = ["README.md", "docs/a guide.rst", "qa/README.md", "src/worker.py", "gone.md"] - for name in tracked: - path = tmp_path / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("content", encoding="utf-8") - subprocess.run(["git", "add", "--", *tracked], cwd=tmp_path, check=True) # noqa: S603, S607 - (tmp_path / "gone.md").unlink() - (tmp_path / "untracked.md").write_text("content", encoding="utf-8") - - assert authored_inputs(tmp_path) == [tmp_path / "README.md", tmp_path / "docs/a guide.rst"] - - -@pytest.mark.agent_authored(model="gpt-6") -def test_authored_inputs_skip_tracked_symlinks_and_keep_their_real_target(tmp_path): - subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) # noqa: S603, S607 - target = tmp_path / "README.md" - target.write_text("[Guide](docs/guide.rst)\n", encoding="utf-8") - package = tmp_path / "cuda_python" - package.mkdir() - (package / "README.md").symlink_to("../README.md") - subprocess.run(["git", "add", "--", "README.md", "cuda_python/README.md"], cwd=tmp_path, check=True) # noqa: S607 - - assert authored_inputs(tmp_path) == [target] - - -@pytest.mark.agent_authored(model="gpt-6") -def test_rendered_inputs_include_all_components_and_exclude_static_assets(tmp_path): - expected = ["cuda-bindings/latest/api.html", "cuda-core/latest/guide.html", "latest/index.html"] - for name in [*expected, "cuda-core/latest/_static/theme.html", "_static/vendor/embed.html", "versions.json"]: - path = tmp_path / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("content", encoding="utf-8") - (tmp_path / "directory.html").mkdir() - - assert rendered_inputs(tmp_path) == [tmp_path / name for name in expected] - - -@pytest.mark.agent_authored(model="gpt-6") -def test_write_inputs_keeps_spaces_and_writes_absolute_paths(tmp_path): - output = tmp_path / "lists/files.txt" - paths = [tmp_path / "docs/a guide.rst", tmp_path / "README.md"] - - write_inputs(paths, output) - - assert output.read_text(encoding="utf-8") == "".join(f"{path}\n" for path in paths) - - -@pytest.mark.agent_authored(model="gpt-6") -def test_write_inputs_rejects_empty_inputs_without_publishing_list(tmp_path): - output = tmp_path / "files.txt" - - with pytest.raises(ValueError, match="No documentation inputs found"): - write_inputs([], output) - - assert not output.exists() - - -@pytest.mark.agent_authored(model="gpt-6") -def test_write_inputs_rejects_paths_that_would_split_into_multiple_inputs(tmp_path): - output = tmp_path / "files.txt" - - with pytest.raises(ValueError, match="must not contain newlines"): - write_inputs([tmp_path / "two\ninputs.md"], output) - - assert not output.exists() diff --git a/ci/tools/tests/test_retry_lychee.py b/ci/tools/tests/test_retry_lychee.py index cb374c97496..4cd2a07492b 100644 --- a/ci/tools/tests/test_retry_lychee.py +++ b/ci/tools/tests/test_retry_lychee.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import subprocess import sys from pathlib import Path @@ -37,6 +36,10 @@ def write_report(path, data): path.write_text(json.dumps(data), encoding="utf-8") +def unexpected_retry(_report): + pytest.fail("Unexpected retry") + + @pytest.mark.agent_authored(model="gpt-6") def test_initial_success_never_retries_and_preserves_json_and_final_markdown(tmp_path, monkeypatch): report = tmp_path / "lychee-authored.json" @@ -44,10 +47,9 @@ def test_initial_success_never_retries_and_preserves_json_and_final_markdown(tmp write_report(report, data) summary = tmp_path / "summary.md" monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) - monkeypatch.setattr(retry_lychee.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Unexpected retry")) monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) - assert retry_lychee.retry_lychee(0, report) == 0 + assert retry_lychee.retry_lychee(0, report, run_attempt=unexpected_retry) == 0 assert json.loads((tmp_path / "lychee-authored-attempt-1.json").read_text(encoding="utf-8")) == data text = summary.read_text(encoding="utf-8") assert "passed on attempt 1/3" in text @@ -56,7 +58,7 @@ def test_initial_success_never_retries_and_preserves_json_and_final_markdown(tmp @pytest.mark.agent_authored(model="gpt-6") -def test_retries_reuse_cache_and_identical_arguments_until_success(tmp_path, monkeypatch): +def test_retries_reuse_cache_until_success(tmp_path, monkeypatch): report = tmp_path / "lychee-rendered.json" initial = report_data(errors=[response()]) write_report(report, initial) @@ -66,31 +68,25 @@ def test_retries_reuse_cache_and_identical_arguments_until_success(tmp_path, mon summary.write_text("Earlier step\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) - monkeypatch.setenv("LYCHEE_ARGS", '--files-from "input files.txt" --cache --config "link config.toml"') delays = [] monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) - commands = [] + outputs = [] - def run(command, *, check): - assert check is False - commands.append(command) - assert command[:6] == ["lychee", "--files-from", "input files.txt", "--cache", "--config", "link config.toml"] - assert command[6:10] == ["--mode", "task", "--format", "json"] + def run(output): + outputs.append(output) assert cache.read_text(encoding="utf-8") == "first-success\n" + ( - "second-success\n" if len(commands) == 2 else "" + "second-success\n" if len(outputs) == 2 else "" ) - code = 2 if len(commands) == 1 else 0 - write_report(Path(command[-1]), report_data(timeouts=[response(code=None)]) if code == 2 else report_data()) + code = 2 if len(outputs) == 1 else 0 + write_report(output, report_data(timeouts=[response(code=None)]) if code == 2 else report_data()) if code == 2: with cache.open("a", encoding="utf-8") as stream: stream.write("second-success\n") - return subprocess.CompletedProcess(command, code) + return code - monkeypatch.setattr(retry_lychee.subprocess, "run", run) - - assert retry_lychee.retry_lychee(2, report) == 0 + assert retry_lychee.retry_lychee(2, report, run_attempt=run) == 0 assert delays == [60, 120] - assert len(commands) == 2 + assert len(outputs) == 2 assert json.loads(report.read_text(encoding="utf-8")) == report_data() assert json.loads((tmp_path / "lychee-rendered-attempt-1.json").read_text(encoding="utf-8")) == initial assert json.loads((tmp_path / "lychee-rendered-attempt-2.json").read_text(encoding="utf-8"))["timeouts"] == 1 @@ -137,14 +133,12 @@ def test_http_timeout_map_entries_are_retryable(tmp_path, code): ], ) @pytest.mark.agent_authored(model="gpt-6") -def test_permanent_or_unknown_failures_stop_without_sleep_or_shared_arguments(tmp_path, monkeypatch, failure): +def test_permanent_or_unknown_failures_stop_without_sleep_or_retry(tmp_path, monkeypatch, failure): report = tmp_path / "lychee.json" write_report(report, report_data(errors=[failure])) - monkeypatch.delenv("LYCHEE_ARGS", raising=False) - monkeypatch.setattr(retry_lychee.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Unexpected retry")) monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) - assert retry_lychee.retry_lychee(2, report) == 2 + assert retry_lychee.retry_lychee(2, report, run_attempt=unexpected_retry) == 2 assert "permanent or unclassified" in report.with_suffix(".md").read_text(encoding="utf-8") @@ -152,20 +146,18 @@ def test_permanent_or_unknown_failures_stop_without_sleep_or_shared_arguments(tm def test_local_timeouts_are_not_retryable(tmp_path, monkeypatch): report = tmp_path / "lychee.json" write_report(report, report_data(timeouts=[response("file:///docs/guide.html", code=None)])) - monkeypatch.delenv("LYCHEE_ARGS", raising=False) monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) - assert retry_lychee.retry_lychee(2, report) == 2 + assert retry_lychee.retry_lychee(2, report, run_attempt=unexpected_retry) == 2 @pytest.mark.agent_authored(model="gpt-6") def test_mixed_permanent_and_transient_failures_stop_immediately(tmp_path, monkeypatch): report = tmp_path / "lychee.json" write_report(report, report_data(errors=[response(), response(code=404)], timeouts=[response(code=None)])) - monkeypatch.delenv("LYCHEE_ARGS", raising=False) monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) - assert retry_lychee.retry_lychee(2, report) == 2 + assert retry_lychee.retry_lychee(2, report, run_attempt=unexpected_retry) == 2 @pytest.mark.agent_authored(model="gpt-6") @@ -173,19 +165,16 @@ def test_transient_failure_turning_into_mixed_failures_stops_on_that_attempt(tmp report = tmp_path / "lychee.json" write_report(report, report_data(errors=[response()])) mixed = report_data(errors=[response(code=503), response(code=404)]) - monkeypatch.setenv("LYCHEE_ARGS", "--cache --files-from inputs.txt") delays = [] monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) calls = [] - def run(command, *, check): - calls.append(command) - write_report(Path(command[-1]), mixed) - return subprocess.CompletedProcess(command, 2) - - monkeypatch.setattr(retry_lychee.subprocess, "run", run) + def run(output): + calls.append(output) + write_report(output, mixed) + return 2 - assert retry_lychee.retry_lychee(2, report) == 2 + assert retry_lychee.retry_lychee(2, report, run_attempt=run) == 2 assert len(calls) == 1 assert delays == [60] assert json.loads(report.read_text(encoding="utf-8")) == mixed @@ -215,19 +204,16 @@ def test_three_transient_attempts_exhausted_follow_failure_policy(tmp_path, monk report = tmp_path / "lychee.json" data = report_data(errors=[response(code=503)]) write_report(report, data) - monkeypatch.setenv("LYCHEE_ARGS", "--cache --files-from inputs.txt") delays = [] monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) calls = [] - def run(command, *, check): - calls.append(command) - write_report(Path(command[-1]), data) - return subprocess.CompletedProcess(command, 2) - - monkeypatch.setattr(retry_lychee.subprocess, "run", run) + def run(output): + calls.append(output) + write_report(output, data) + return 2 - assert retry_lychee.retry_lychee(2, report, policy=policy) == expected + assert retry_lychee.retry_lychee(2, report, policy=policy, run_attempt=run) == expected assert len(calls) == 2 assert delays == [60, 120] assert len(list(tmp_path.glob("lychee-attempt-*.json"))) == 3 @@ -240,42 +226,6 @@ def run(command, *, check): assert "remain unverified" in text -@pytest.mark.parametrize("code", [1, 3]) -@pytest.mark.parametrize("policy", ["check", "cache-warm"]) -@pytest.mark.agent_authored(model="gpt-6") -def test_initial_non_link_failure_stops_without_retry(tmp_path, monkeypatch, code, policy): - monkeypatch.setattr(retry_lychee.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Unexpected retry")) - monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) - - assert retry_lychee.retry_lychee(code, tmp_path / "missing.json", policy=policy) == code - - -@pytest.mark.parametrize("code", [1, 3, -15]) -@pytest.mark.parametrize("policy", ["check", "cache-warm"]) -@pytest.mark.agent_authored(model="gpt-6") -def test_retry_non_link_failure_does_not_claim_stale_report(tmp_path, monkeypatch, code, policy): - report = tmp_path / "lychee.json" - write_report(report, report_data(errors=[response()])) - monkeypatch.setenv("LYCHEE_ARGS", "--cache --files-from inputs.txt") - delays = [] - monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) - calls = [] - - def run(command, *, check): - calls.append(command) - return subprocess.CompletedProcess(command, code) - - monkeypatch.setattr(retry_lychee.subprocess, "run", run) - - assert retry_lychee.retry_lychee(2, report, policy=policy) == (code if code > 0 else 1) - assert len(calls) == 1 - assert delays == [60] - text = report.with_suffix(".md").read_text(encoding="utf-8") - assert "did not produce a link-check report" in text - assert "### Final attempt" not in text - assert "| Total |" not in text - - @pytest.mark.parametrize( "name,value", [("total", True), ("errors", False), ("timeouts", -1), ("errors", 1.0), ("total", "5")] ) @@ -315,15 +265,14 @@ def test_invalid_counts_are_rejected(tmp_path, name, value): report_data(errors=[{"url": "https://example.test/", "status": {"text": "Unknown error"}}]), ], ) -@pytest.mark.parametrize("policy", ["check", "cache-warm"]) @pytest.mark.agent_authored(model="gpt-6") -def test_malformed_or_contradictory_json_cannot_pass_or_trigger_retries(tmp_path, monkeypatch, data, policy): +def test_malformed_or_contradictory_json_cannot_pass_or_trigger_retries(tmp_path, monkeypatch, data): report = tmp_path / "lychee.json" write_report(report, data) monkeypatch.setattr(retry_lychee.time, "sleep", lambda _delay: pytest.fail("Unexpected sleep")) with pytest.raises(ValueError, match="Lychee"): - retry_lychee.retry_lychee(2, report, policy=policy) + retry_lychee.retry_lychee(2, report, run_attempt=unexpected_retry) @pytest.mark.parametrize( @@ -336,25 +285,23 @@ def test_report_must_agree_with_exit_code(tmp_path, data, code): write_report(report, data) with pytest.raises(ValueError, match="disagree with exit code"): - retry_lychee.retry_lychee(code, report) + retry_lychee.retry_lychee(code, report, run_attempt=unexpected_retry) @pytest.mark.parametrize("contents", ["", "not json", "{broken}"]) -@pytest.mark.parametrize("policy", ["check", "cache-warm"]) @pytest.mark.agent_authored(model="gpt-6") -def test_invalid_json_cannot_pass(tmp_path, contents, policy): +def test_invalid_json_cannot_pass(tmp_path, contents): report = tmp_path / "lychee.json" report.write_text(contents, encoding="utf-8") with pytest.raises(json.JSONDecodeError): - retry_lychee.retry_lychee(0, report, policy=policy) + retry_lychee.retry_lychee(0, report, run_attempt=unexpected_retry) -@pytest.mark.parametrize("policy", ["check", "cache-warm"]) @pytest.mark.agent_authored(model="gpt-6") -def test_missing_initial_report_cannot_pass(tmp_path, policy): +def test_missing_initial_report_cannot_pass(tmp_path): with pytest.raises(FileNotFoundError): - retry_lychee.retry_lychee(0, tmp_path / "missing.json", policy=policy) + retry_lychee.retry_lychee(0, tmp_path / "missing.json", run_attempt=unexpected_retry) @pytest.mark.agent_authored(model="gpt-6") @@ -370,7 +317,7 @@ def test_final_markdown_escapes_untrusted_report_fields_and_retains_raw_json(tmp data["error_map"] = {"docs/[source]|file\n# heading": [failure]} write_report(report, data) - assert retry_lychee.retry_lychee(2, report) == 2 + assert retry_lychee.retry_lychee(2, report, run_attempt=unexpected_retry) == 2 text = report.with_suffix(".md").read_text(encoding="utf-8") assert "