diff --git a/.github/actions/lychee-links/action.yml b/.github/actions/lychee-links/action.yml index d38796444e1..670c7ad074d 100644 --- a/.github/actions/lychee-links/action.yml +++ b/.github/actions/lychee-links/action.yml @@ -10,17 +10,58 @@ inputs: 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" + description: >- + check accepts up to 10 rate-limited URLs; cache-warm warns for any unresolved + link; allow-429 accepts only HTTP 429 failures under the maintainer override default: check refresh: description: "Check links afresh and publish the successful results as the new baseline" default: "false" + pr-number: + description: "PR whose current maintainer override label should be read; empty skips the lookup" + default: "" + +outputs: + allow-429: + description: "Whether the current PR label explicitly enables the HTTP 429 override" + value: ${{ steps.lychee-policy.outputs.allow-429 }} # Callers provide Python and the checked-out repository; the build of any # rendered documentation must already be complete. runs: using: composite steps: + - name: Read the PR's Lychee rate-limit override + id: lychee-policy + shell: bash --noprofile --norc -euo pipefail {0} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + run: | + allow_429=false + if [[ -n "$PR_NUMBER" ]]; then + # Reruns keep the original event's labels, so query the current decision. + # Only an exact boolean response can enable the maintainer override. + lookup_ok=false + for attempt in 1 2 3; do + if result="$(gh pr view "$PR_NUMBER" --json labels --jq '.labels | any(.name == "ci: lychee-non-blocking")')"; then + case "$result" in + true|false) allow_429="$result"; lookup_ok=true; break ;; + esac + fi + if (( attempt < 3 )); then + sleep $((attempt * 5)) + fi + done + if [[ "$lookup_ok" != true ]]; then + echo "::warning::Could not read a valid PR label decision; using the default link policy. Rerun to apply ci: lychee-non-blocking." + elif [[ "$allow_429" == true ]]; then + echo "::notice::ci: lychee-non-blocking permits only HTTP 429 failures after retries." + fi + fi + echo "allow-429=$allow_429" >> "$GITHUB_OUTPUT" + - name: Describe the link cache id: cache shell: bash --noprofile --norc -euo pipefail {0} @@ -56,7 +97,7 @@ runs: shell: bash --noprofile --norc -euo pipefail {0} env: KIND: ${{ inputs.kind }} - POLICY: ${{ inputs.policy }} + POLICY: ${{ steps.lychee-policy.outputs.allow-429 == 'true' && 'allow-429' || inputs.policy }} GITHUB_TOKEN: ${{ github.token }} run: | # Install into the interpreter that runs the driver, whatever shell diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 322d772f1f0..6e52bc71b15 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -301,6 +301,8 @@ jobs: uses: ./.github/actions/lychee-links with: kind: rendered + # Full CI's pull-request/N mirror supplies the PR number. + pr-number: ${{ env.PR_NUMBER }} - name: Upload docs GitHub Pages artifact if: ${{ inputs.deploy-docs }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index d493c212879..a0cf2aa0b49 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -22,6 +22,7 @@ concurrency: permissions: contents: read + pull-requests: read defaults: run: @@ -142,16 +143,25 @@ jobs: # 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. + # the first pass then checks afresh. With the maintainer label, this is + # also the final 429-only link gate. - name: Warm the Lychee cache + id: lychee-check uses: ./.github/actions/lychee-links with: kind: authored policy: cache-warm + pr-number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} - name: Run pre-commit + # Run the remaining hooks even if the override's link gate fails. + # That earlier failure still makes the job fail. + if: ${{ !cancelled() && (success() || (steps.lychee-check.outputs.allow-429 == 'true' && steps.lychee-check.outcome == 'failure')) }} env: GITHUB_TOKEN: ${{ github.token }} + # The override uses the reported link result above. All other hooks + # still run once; normal runs also invoke Lychee in the full suite. + SKIP: ${{ steps.lychee-check.outputs.allow-429 == 'true' && 'check-precommit-installed,lychee' || 'check-precommit-installed' }} run: pre-commit run --all-files --show-diff-on-failure - name: Save pre-commit cache diff --git a/ci/README.md b/ci/README.md index d364dc40c67..dcd5047b05a 100644 --- a/ci/README.md +++ b/ci/README.md @@ -48,7 +48,41 @@ fail explicitly. Per-attempt reports and a summary are kept for seven days. | 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. +stay failures, mostly reuses successful checks. With the maintainer override, +the driver's report-based result becomes the final link gate, and the full +suite skips its duplicate Lychee invocation. Every other hook still runs once. + +### Maintainer Override For HTTP 429 + +When rate limiting persists, maintainers can apply `ci: lychee-non-blocking` +to the PR and rerun the affected Linux/Windows pre-commit jobs or rendered +documentation job. Applying or removing the label does not itself start a +new workflow run. The shared link-check action queries the PR's current labels, +so a rerun sees the current decision rather than the original event's snapshot. +The lookup makes up to three attempts, waiting 5 and 10 seconds between them. +If every attempt fails or returns an invalid response, the action warns and +keeps the caller's default policy without enabling the override: authored +checks remain strict in the full pre-commit suite, and rendered checks keep +their allowance for at most ten distinct HTTP 429 URLs. Rerun the affected job +to retry the lookup and apply the label; other pre-commit hooks still run. + +The label selects the driver's `allow-429` policy for both authored and +rendered checks. Lychee still runs and retries normally; only a final report +whose remaining failures are all HTTP 429 responses from external HTTP(S) +URLs can pass with a warning. This explicit override has no URL-count limit. +The warning and retained reports identify the URLs that remain unverified. +Successful checks retain their usual cache behavior; failures are never cached. + +Any other failure still blocks, including a mixture of HTTP 429s and other +errors, broken local files or fragments, timeouts, and HTTP 403, 404, or 5xx +responses. Hook setup, invalid or missing reports, and the offline smoke +checks remain blocking. Other exceptional failures require +the normal admin-merge decision. + +The label applies only to PR checks. Local pre-commit runs, default-branch +checks, and nightly cache production keep their existing policies. Without +the label, authored checks remain strict and rendered checks retain their +existing allowance for at most ten distinct HTTP 429 URLs. ### Shared URL Caches diff --git a/ci/tools/retry_lychee.py b/ci/tools/retry_lychee.py index 59e90257e69..524933dcbd7 100644 --- a/ci/tools/retry_lychee.py +++ b/ci/tools/retry_lychee.py @@ -157,7 +157,10 @@ def finish_link_failures(report: Path, data: Report, exit_codes: list[int], mess warning = True elif all(failure.rate_limited for failure in data.failures): message += f" {distinct_urls} distinct HTTP 429 URLs remain unverified" - if distinct_urls <= MAX_RATE_LIMITED_URLS: + if policy == "allow-429": + message += "; the maintainer override accepts only these rate limits with a warning." + warning = True + elif distinct_urls <= MAX_RATE_LIMITED_URLS: message += f" (limit: {MAX_RATE_LIMITED_URLS}); accepting these rate limits with a warning." warning = True else: @@ -184,7 +187,7 @@ def retry_lychee( 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"): + if policy not in ("check", "cache-warm", "allow-429"): raise ValueError(f"Unknown lychee failure policy: {policy}") exit_codes = [initial_exit_code] diff --git a/ci/tools/run_lychee_precommit.py b/ci/tools/run_lychee_precommit.py index fbc4fa5f3ac..6b8f81077e6 100644 --- a/ci/tools/run_lychee_precommit.py +++ b/ci/tools/run_lychee_precommit.py @@ -1,7 +1,7 @@ # 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.""" +"""Check documentation links or warm caches through the configured pre-commit hooks.""" from __future__ import annotations @@ -89,7 +89,7 @@ def run_checks(kind: str, report: Path, max_attempts: int = 3, *, policy: str = 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("--policy", choices=("cache-warm", "check", "allow-429"), default="check") parser.add_argument("--max-attempts", type=int, default=3) parser.add_argument("--report", type=Path, required=True) args = parser.parse_args(arguments) diff --git a/ci/tools/tests/test_retry_lychee.py b/ci/tools/tests/test_retry_lychee.py index 4cd2a07492b..8bada184e9f 100644 --- a/ci/tools/tests/test_retry_lychee.py +++ b/ci/tools/tests/test_retry_lychee.py @@ -350,10 +350,10 @@ def test_one_attempt_limit_keeps_transient_failure_without_retry(tmp_path, monke assert retry_lychee.retry_lychee(2, report, max_attempts=1, run_attempt=unexpected_retry) == 2 -@pytest.mark.parametrize("url_count,expected", [(10, 0), (11, 2)]) +@pytest.mark.parametrize("policy,url_count,expected", [("check", 10, 0), ("check", 11, 2), ("allow-429", 11, 0)]) @pytest.mark.agent_authored(model="gpt-6") def test_rate_limit_allowance_counts_distinct_urls_after_all_attempts( - tmp_path, monkeypatch, capsys, url_count, expected + tmp_path, monkeypatch, capsys, policy, url_count, expected ): report = tmp_path / "lychee.json" data = report_data(errors=[response(f"https://example.test/{number}") for number in range(url_count)], total=100) @@ -365,13 +365,16 @@ def run(output): write_report(output, data) return 2 - assert retry_lychee.retry_lychee(2, report, run_attempt=run) == expected + assert retry_lychee.retry_lychee(2, report, policy=policy, run_attempt=run) == expected assert delays == [60, 120] assert json.loads(report.read_text(encoding="utf-8")) == data text = report.with_suffix(".md").read_text(encoding="utf-8") assert f"{url_count} distinct HTTP 429 URLs remain unverified" in text assert "| 1 | 2 |\n| 2 | 2 |\n| 3 | 2 |" in text assert ("::warning::" in capsys.readouterr().out) == (expected == 0) + if policy == "allow-429": + assert "maintainer" in text.lower() + assert len(list(tmp_path.glob("lychee-attempt-*.json"))) == 3 for number in range(url_count): assert f"https://example.test/{number}" in text @@ -393,23 +396,27 @@ def test_rate_limit_allowance_deduplicates_exact_urls_across_sources(tmp_path, c "other_failure,timed_out", [ (response(code=404), False), + (response(code=403), False), (response(code=503), False), (response(code=None), True), (response(code=429), True), (response("mailto:someone@example.test"), False), (response("https:missing-host"), False), (response("https://[malformed"), False), + (response("file:///docs/guide.html", code=429), False), + (response("file:///docs/guide.html#missing", code=None, details="Anchor not found"), False), ], ) +@pytest.mark.parametrize("policy", ["check", "allow-429"]) @pytest.mark.agent_authored(model="gpt-6") -def test_429_allowance_never_accepts_other_failures(tmp_path, capsys, other_failure, timed_out): +def test_429_allowance_never_accepts_other_failures(tmp_path, capsys, other_failure, timed_out, policy): report = tmp_path / "lychee.json" data = report_data( errors=[response(), *([] if timed_out else [other_failure])], timeouts=[other_failure] if timed_out else [] ) write_report(report, data) - assert retry_lychee.retry_lychee(2, report, max_attempts=1, run_attempt=unexpected_retry) == 2 + assert retry_lychee.retry_lychee(2, report, max_attempts=1, policy=policy, run_attempt=unexpected_retry) == 2 assert "::warning::" not in capsys.readouterr().out diff --git a/ci/tools/tests/test_run_lychee_precommit.py b/ci/tools/tests/test_run_lychee_precommit.py index 1f9134cdacd..2425be06738 100644 --- a/ci/tools/tests/test_run_lychee_precommit.py +++ b/ci/tools/tests/test_run_lychee_precommit.py @@ -128,7 +128,6 @@ def run_hook(kind, output): monkeypatch.setattr(run_lychee_precommit, "run_hook", run_hook) monkeypatch.setattr(retry_lychee.time, "sleep", delays.append) - monkeypatch.delenv("LYCHEE_ARGS", raising=False) assert run_lychee_precommit.run_checks("authored", report, policy="cache-warm") == 0 assert len(attempts) == 2 assert delays == [60] @@ -136,18 +135,42 @@ def run_hook(kind, output): assert "example.test" not in report.with_suffix(".md").read_text() +@pytest.mark.parametrize("policy", ["cache-warm", "allow-429"]) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_missing_native_status_is_fatal_even_for_nightly_cache_warming(tmp_path, monkeypatch, capsys): +def test_missing_native_status_is_fatal_even_under_relaxed_link_policies(tmp_path, monkeypatch, capsys, policy): monkeypatch.setattr( run_lychee_precommit.subprocess, "run", lambda *_args, **_kwargs: subprocess.CompletedProcess([], 1) ) - assert ( - run_lychee_precommit.main(["authored", "--policy", "cache-warm", "--report", str(tmp_path / "report.json")]) - == 1 - ) + assert run_lychee_precommit.main(["authored", "--policy", policy, "--report", str(tmp_path / "report.json")]) == 1 assert "no native status" in capsys.readouterr().out +@pytest.mark.parametrize("kind", ["authored", "rendered"]) +@pytest.mark.agent_authored(model="gpt-6") +def test_cli_maintainer_override_accepts_only_rate_limits_after_retrying(tmp_path, monkeypatch, kind): + report = tmp_path / "report.json" + calls = [] + + def run_hook(selected_kind, output): + calls.append(selected_kind) + data = native_report(code=2, total=20, status=429) + data["errors"] = 11 + data["error_map"]["guide.md"] = [ + {"url": f"https://example.test/{number}", "status": {"text": "Rate limited", "code": 429}} + for number in range(11) + ] + output.write_text(json.dumps(data)) + return 2 + + monkeypatch.setattr(run_lychee_precommit, "run_hook", run_hook) + monkeypatch.setattr(retry_lychee.time, "sleep", lambda _: None) + + assert run_lychee_precommit.main([kind, "--policy", "allow-429", "--report", str(report)]) == 0 + assert calls == [kind] * 3 + assert len(list(tmp_path.glob("report-attempt-*.json"))) == 3 + assert "maintainer" in report.with_suffix(".md").read_text().lower() + + @pytest.mark.agent_authored(model="gpt-6.1-sol") def test_zero_attempts_rejected_before_running_precommit(tmp_path, monkeypatch): monkeypatch.setattr(run_lychee_precommit, "run_hook", lambda *_args: pytest.fail("Unexpected invocation"))