From 9dcdca530d911229ac275b9c20262dd725b1cd27 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 16:57:42 -0700 Subject: [PATCH 1/9] ci: run documentation link checks through pre-commit Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/actions/check-doc-links/action.yml | 114 ------------ .github/workflows/build-docs.yml | 44 ++++- .github/workflows/lychee.yml | 67 ++++++- .github/workflows/pre-commit.yml | 51 +++++- .gitignore | 1 + .pre-commit-config.yaml | 25 ++- ci/README.md | 74 ++++++++ ci/tools/lychee_cache.py | 59 ++++++ ci/tools/retry_lychee.py | 56 ++---- ci/tools/run_lychee.py | 40 +++- ci/tools/run_lychee_precommit.py | 104 +++++++++++ ci/tools/tests/test_lychee_cache.py | 147 +++++++++++++++ ci/tools/tests/test_retry_lychee.py | 192 ++++++-------------- ci/tools/tests/test_run_lychee.py | 92 ++++++++++ ci/tools/tests/test_run_lychee_precommit.py | 173 ++++++++++++++++++ lychee.toml | 4 +- 16 files changed, 923 insertions(+), 320 deletions(-) delete mode 100644 .github/actions/check-doc-links/action.yml 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 create mode 100644 ci/tools/tests/test_run_lychee.py create mode 100644 ci/tools/tests/test_run_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 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/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 becaaf6feec..32e858e2f20 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -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,9 +139,53 @@ 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 + - 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 # own duplicate copies. !cancelled() keeps a push that fails lint from 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/README.md b/ci/README.md index be94dfb035d..8ea3e6eb432 100644 --- a/ci/README.md +++ b/ci/README.md @@ -24,6 +24,80 @@ 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 + +Authored Markdown and reST are checked through the same `lychee` pre-commit +hook in development and in Linux/Windows PR CI. The hook installs the official +PyPI `lychee-bin==0.24.2` native wheel in its isolated Python environment; +`ci/tools/run_lychee.py` invokes that executable and preserves its exit status. +Authored checks retain their existing policy of not validating fragments. +Repository URLs are checked as written. + +### 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. + +The permanent offline smoke check runs first, isolating hook setup and shell +invocation failures from network failures. Standalone CI-tool unit tests run +in nightly CI. The full-CI documentation job checks generated HTML after its +build, avoiding a duplicate authored scan. + +### 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. + +All rendered attempts use the same inputs. 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 the default branch 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. + ## Temporary Pixi Lockfile Maintenance Pause Pixi lockfile freshness enforcement and automated refreshes are temporarily 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..b75d0460f57 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}") @@ -173,7 +171,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], +) -> 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}") @@ -188,7 +193,6 @@ def retry_lychee(initial_exit_code: int, report: Path, max_attempts: int = 3, *, 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 +214,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 +221,15 @@ 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): + 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) - - -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..e778a76a406 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -13,11 +13,18 @@ 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 main(arguments: list[str] | None = None) -> int: # Resolve the executable from this hook's environment to honor the pin. @@ -26,8 +33,37 @@ 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) + 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([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_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_retry_lychee.py b/ci/tools/tests/test_retry_lychee.py index cb374c97496..c44601f1730 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) - - monkeypatch.setattr(retry_lychee.subprocess, "run", run) + return code - 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 @@ -244,10 +230,11 @@ def run(command, *, check): @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 + assert ( + retry_lychee.retry_lychee(code, tmp_path / "missing.json", policy=policy, run_attempt=unexpected_retry) == code + ) @pytest.mark.parametrize("code", [1, 3, -15]) @@ -256,18 +243,15 @@ def test_initial_non_link_failure_stops_without_retry(tmp_path, monkeypatch, cod 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) + def run(output): + calls.append(output) + return code - monkeypatch.setattr(retry_lychee.subprocess, "run", run) - - assert retry_lychee.retry_lychee(2, report, policy=policy) == (code if code > 0 else 1) + assert retry_lychee.retry_lychee(2, report, policy=policy, run_attempt=run) == (code if code > 0 else 1) assert len(calls) == 1 assert delays == [60] text = report.with_suffix(".md").read_text(encoding="utf-8") @@ -323,7 +307,7 @@ def test_malformed_or_contradictory_json_cannot_pass_or_trigger_retries(tmp_path 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, policy=policy, run_attempt=unexpected_retry) @pytest.mark.parametrize( @@ -336,7 +320,7 @@ 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}"]) @@ -347,14 +331,14 @@ def test_invalid_json_cannot_pass(tmp_path, contents, policy): 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, policy=policy, 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): with pytest.raises(FileNotFoundError): - retry_lychee.retry_lychee(0, tmp_path / "missing.json", policy=policy) + retry_lychee.retry_lychee(0, tmp_path / "missing.json", policy=policy, run_attempt=unexpected_retry) @pytest.mark.agent_authored(model="gpt-6") @@ -370,7 +354,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 "