Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions .github/actions/lychee-links/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ concurrency:

permissions:
contents: read
pull-requests: read

defaults:
run:
Expand Down Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions ci/tools/retry_lychee.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]

Expand Down
4 changes: 2 additions & 2 deletions ci/tools/run_lychee_precommit.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 12 additions & 5 deletions ci/tools/tests/test_retry_lychee.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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


Expand Down
35 changes: 29 additions & 6 deletions ci/tools/tests/test_run_lychee_precommit.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,26 +128,49 @@ 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]
assert "passed on attempt 2/3" in report.with_suffix(".md").read_text()
assert "example.test" not in report.with_suffix(".md").read_text()


@pytest.mark.parametrize("policy", ["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"))
Expand Down
Loading