Repository navigation
CI: Lychee reliability improvement ideas #2652
Description
Why this matters
CUDA Python CI already has significant infrastructure and test flakiness
that first-attempt failures and manual reruns are causing significant
friction. Each false red interrupts review, costs triage time, obscures genuine
regressions, and stretches feedback loops, especially during stacked work and
release preparation.
The rendered-docs link check compounds that existing flakiness. A cold sweep
checks roughly 13,000 unique URLs, and currently one transient response from
one external service can turn an otherwise healthy workflow red:
- PR #2641 job 94978076797
checked 13,166 unique links. Its only failure was an HTTP 500 for the valid,
merged CUDA Python PR [pathfinder]RTLD_DI_LINKMAP-based new implementation ofabs_path_for_dynamic_library()#834. - PR #2650 job 95260538160
checked 12,711 unique links. Its only failure was an HTTP 429 for a valid
docs.conda.io page.
Both were cold-cache checks, both failures were unrelated to the pull request
under test, and both became manual-rerun candidates. The important point is
that these were not useful broken-documentation findings: they were isolated
external-service transients that made the entire Docs job look unhealthy.
The two incidents do not establish the overall first-attempt failure rate, but
they identify one source of avoidable friction that seems reasonably easy to address:
A bounded second pass can remove that source without weakening the link check, and recovery
notices will let us measure how often it helps.
Recommendation
Add one conditional, delayed second Lychee pass to the rendered-docs job.
The first pass should remain the normal fast path. If it reports unresolved
links, wait for a bounded cooldown (initially 60 seconds) and run Lychee once
more against the same files and the same on-disk cache.
This is a small, useful reliability improvement:
- A successful first pass adds no delay.
- A transient 429, 5xx, timeout, or network error gets one additional chance
to recover without a manual workflow rerun. - Successful external checks from the first pass are reused; failed external
requests are not persisted and therefore go back to the network. - A persistent broken or unavailable link still makes the job fail.
As a separate small improvement, give every workflow execution a new cache key
generation so a rerun can save refreshed successful results instead of being
stuck with an immutable exact-SHA cache.
Correct cache model
This is not a cache-poisoning problem.
Lychee v0.24.2 does not persist failed responses such as 429 or 500.
CacheStatus::Error entries are discarded when loading and skipped when
storing. The cache saved after a failed sweep therefore contains successful
responses but not the failed URL.
The configured max cache age of one day applies to successful entries:
- An immediate rerun can reuse the successful checks and must re-request the
failed URL. - A rerun after the one-day age limit performs a broader fresh check.
- Both reruns remain meaningful; the failed response is never replayed from
disk.
References:
- Lychee v0.24.2 cache implementation
- Lychee PR #2105: never cache errors on disk
- GitHub Actions cache behavior
Phase 1: one bounded automatic second pass
Desired behavior
- Run Lychee normally, but ask the pinned action to expose rather than enforce
Lychee's first exit code. - If the action itself completed successfully and Lychee reports its
broken-link exit code (2), emit a notice and wait 60 seconds. - Run the same Lychee check once more with normal hard-failure behavior.
- Fail immediately for any other nonzero or missing Lychee exit code.
- Save the cache after either outcome, as the workflow does today.
The pinned lychee-action exposes the raw exit_code output and supports
fail: false. That is preferable to broadly applying continue-on-error:
action download/setup failures still fail immediately, while a normal Lychee
link-check result can drive the conditional retry.
The retry condition should check both facts:
- The first action step outcome is success.
- Its exit_code output is exactly 2, Lychee's broken-link result.
The outcome check prevents a skipped or failed setup step with an empty output
from accidentally triggering the retry. A separate guard must fail the job for
an empty output or Lychee exit code 1 (runtime/input error) or 3
(configuration error). Otherwise fail: false would accidentally turn those
deterministic errors into success.
Illustrative workflow shape
The exact argument list is omitted here; both action invocations should use the
existing arguments unchanged and should share one centrally defined value.
- name: Check rendered docs links
id: check-rendered-docs-links
if: ${{ <existing rendered-docs condition> }}
uses: lycheeverse/lychee-action@<current pinned SHA>
with:
args: &rendered-docs-lychee-args >-
<existing arguments>
fail: false
failIfEmpty: true
output: lychee-rendered-html-first.md
<other existing inputs>
- name: Wait before retrying rendered docs links
if: ${{ steps.check-rendered-docs-links.outcome == 'success' && steps.check-rendered-docs-links.outputs.exit_code == '2' }}
run: |
echo "::notice::Lychee found unresolved links; retrying in 60 seconds."
sleep 60
- name: Retry rendered docs links
id: retry-rendered-docs-links
if: ${{ steps.check-rendered-docs-links.outcome == 'success' && steps.check-rendered-docs-links.outputs.exit_code == '2' }}
uses: lycheeverse/lychee-action@<current pinned SHA>
with:
args: *rendered-docs-lychee-args
fail: true
failIfEmpty: true
output: lychee-rendered-html-retry.md
<other existing inputs>
- name: Fail on non-link-check Lychee errors
if: ${{ steps.check-rendered-docs-links.outcome == 'success' && steps.check-rendered-docs-links.outputs.exit_code != '0' && steps.check-rendered-docs-links.outputs.exit_code != '2' }}
run: |
echo "::error::Lychee failed with exit code '${{ steps.check-rendered-docs-links.outputs.exit_code }}'."
exit 1
- name: Report recovered rendered-docs link check
if: ${{ steps.retry-rendered-docs-links.outcome == 'success' && steps.retry-rendered-docs-links.outputs.exit_code == '0' }}
run: |
echo "::notice::Rendered-docs link check recovered on its second pass."Keep the existing cache-save step after both action invocations and retain its
always() condition. The second pass reads the .lycheecache file directly from
the workspace, so it does not need an intermediate Actions cache save/restore.
The second action invocation still reparses the rendered documents and checks
fast local links. The important optimization is that successful external
requests can be served from the first pass's on-disk cache; only unresolved
external requests need network rechecking.
Use separate first-pass and retry in-workspace output names and log sections.
The files disappear with the runner unless we also upload them as artifacts;
durable reports are optional rather than required for the first change. The
logs should clearly say when a retry happened and whether it recovered.
Otherwise the improvement would reduce failures while hiding the frequency of
the underlying flakiness.
A second lychee-action invocation also repeats action setup and installation.
That small cost and additional setup-failure opportunity occur only after a
first-pass link failure. Calling the installed binary directly could avoid the
cost, but would duplicate more of the action's token, output, and failure
handling; the second pinned action is the safer initial implementation.
Phase 2: make cache generations refreshable
The current primary key is effectively:
docs-rendered-lychee-<PR_NUMBER>-<SHA>
GitHub Actions cache entries are immutable. On a rerun of the same SHA after
Lychee's one-day success-entry age limit:
- GitHub restores the exact old cache.
- Lychee discards its expired entries and checks them again.
- The workflow skips saving because the restore was an exact cache hit.
- A later rerun of that same SHA restores the same stale cache again.
This did not cause either observed first-attempt failure. It is a separate
efficiency/lifecycle weakness.
Use a versioned, per-execution primary key:
key: docs-rendered-lychee-v1-${{ env.PR_NUMBER }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
docs-rendered-lychee-v1-${{ env.PR_NUMBER }}-${{ github.sha }}-
docs-rendered-lychee-v1-${{ env.PR_NUMBER }}-This preserves the preference for the newest cache from the same PR and SHA
while allowing every execution to save a refreshed immutable snapshot.
Including both run_id and run_attempt also avoids collisions between separate
runs of the same SHA, not just attempts within one run. The v1 component gives
us an intentional cache-format invalidation mechanism.
The tradeoff is increased cache churn because every Docs execution uploads a
new snapshot, even when the restored entries remain fresh. Measure the cache
file size and expected run volume before deciding whether that cost is
material. This change is orthogonal to the same-job second pass and could be a
separate commit.
Phase 3: report the 5xx retry gap upstream
There is a relevant Lychee v0.24.2 implementation gap:
- Rejected HTTP 429 responses are considered retryable.
- Ordinary rejected 5xx responses appear not to be retried through the same
error path. - The retry classification for RejectedStatusCode matches only 429, even
though a separate StatusCode helper classifies 5xx as retryable. - Current Lychee upstream still appears to retain this mismatch as of this
analysis.
Therefore the Conda 429 likely received the configured internal retries, while
the generic retry loop likely did not retry the rejected 500 response for the
original GitHub URL. Other GitHub-specific fallback requests may still have
occurred, so this is not a claim about the total number of network requests.
We should avoid saying that both incidents exhausted three retries.
An upstream Lychee issue or PR should propose routing rejected status codes
through the common status-code retry predicate and adding 5xx regression
tests:
The CUDA Python outer retry should not wait for that upstream fix. It covers
429, 5xx, timeouts, network errors, and transients lasting longer than the
built-in retry window while retaining a strict final result.
Conditional ideas, not first choices
Host-specific throttling
Lychee suggested host-specific throttling after the docs.conda.io 429:
[hosts."docs.conda.io"]
concurrency = 1
request_interval = "1s"This syntax is valid, but CUDA Python currently appears to contain only one
unique docs.conda.io URL. Per-host concurrency therefore may not have caused or
prevented this incident; shared runner-IP rate limiting is another plausible
explanation. Add this only if Conda 429s recur or the number of links to that
host grows. It also cannot address unrelated GitHub 5xx responses.
More built-in retries
Increasing max-retries or the wait time could help 429 responses, but it has
lower value than one outer pass:
- The current configuration already retries 429 with increasing waits.
- More retries extend every persistent 429 failure.
- The v0.24.2 5xx classification gap remains.
Warm new PRs from a trusted default-branch cache
Every new PR currently starts cold because the cache key is PR-specific and
pull-request caches are isolated. A scheduled or trusted-main cache warmer
could reduce cold sweeps for new PRs.
Treat this as a later optimization. Main currently skips this rendered-docs
check, and a useful shared cache would need a trusted producer, regular
generations, compatible restore prefixes, and careful freshness/security
review. It must not require pull_request_target or allow untrusted PR code to
write caches consumed by the default branch.
Non-goals
- Do not accept 429 or 5xx responses as successful.
- Do not exclude an entire domain because it occasionally flakes.
- Do not make the final rendered-docs link result advisory.
- Do not add cache-exclude-status for these errors; Lychee already avoids
persisting them. - Do not increase the cache age merely to conceal stale or broken links.
- Do not add an unbounded retry loop.
- Do not claim this addresses all CUDA Python CI flakiness.
Acceptance criteria
- A successful first pass runs no delay and no second pass.
- A first-pass broken-link result (exit code 2) triggers exactly one bounded
delayed retry. - A recovered retry makes the job green and emits a visible recovery notice.
- A persistent bad or unavailable URL leaves the job red with the final URL
and status visible. - Successful external responses from the first pass are reused on the second
pass. - Action setup failures, absent exit-code output, Lychee runtime/configuration
errors, and empty input are not silently converted into success or treated
as transient link failures. - The cache-save step still runs after success or final failure.
- A manual rerun can save refreshed successful results under a new cache key.
- The first-pass and recovered-retry rate can be determined from job logs.
Validation plan
Before merging:
- Run actionlint and the repository YAML checks.
- Confirm that the pinned action sets outputs.exit_code when fail is false.
- Confirm the first-pass success path performs no sleep and no second action
invocation. - Use a deterministic local HTTP server in a test PR, with one route that
fails once and then succeeds and another that always fails. Avoid adding a
new external test dependency. - Use request counters to confirm the second pass reuses successful external
results, rechecks the transient route, and makes the job green. - Confirm the persistent failing route leaves the job red.
- Exercise invalid arguments/configuration, absent exit-code output, and zero
discovered links; confirm each remains red without a link retry. - Rerun the same SHA and confirm the new cache generation restores prior
successes and saves a new refreshed snapshot.
Suggested rollout
- Add the conditional second pass, recovery notice, and distinct reports.
- In the same PR or a separate small commit, add per-execution cache
generations. - Monitor recovery notices for a few weeks to quantify the first-pass failure
rate and the hosts/statuses involved. - File the 5xx retry-classification issue upstream and consume the fix when a
release is available. - Add host-specific throttling or a shared trusted cache only if the measured
results justify the extra policy and complexity.
Activity
somtri commentedon Sep 19, 2026
I'd like to investigate the rendered-docs Lychee retry and make the smallest change that keeps bad links failing.
rwgk commentedon Sep 25, 2026
@somtri I think this needs more thought before we start with implementations.
To add an observation:
I'm increasingly making use of git worktrees. Lychee runs from scratch in each worktree; this slows down development work (see below).
This got me to wonder:
Where does Lychee add real value? — I believe it's mostly that we don't want to release documentation with stale links.
I think a good compromise solution would be:
cuda-python/.github/workflows/ci.yml
Lines 18 to 23 in f9ed2bd
Another idea: in regular development, be smarter about running it only when actually needed. Right now it runs on a fresh worktree without any changes:
somtri commentedon Sep 26, 2026
I'll hold off on implementation until you've settled the direction.
For the nightly/release idea: the rendered-docs link check in build-docs.yml is skipped on
main, onrelease/*, and whenis-releaseis true, which ci.yml sets for tag pushes:cuda-python/.github/workflows/build-docs.yml
Lines 288 to 289 in f9ed2bd
The nightly schedule runs on
main, so right now the check only runs on PR branches. It doesn't run nightly or on release tags.If you go with that approach, I can make the workflow change.
rwgk commentedon Oct 1, 2026
GPT-6.1-Sol ultra (01a0f997-b36e-7030-9090-0e9d708d6f48)
Proposed direction: replace pre-commit.ci with GitHub Actions, and make one dedicated Linux workflow own all CI lychee checks. Run it directly on PR updates and from the existing nightly workflow. Keep rendered-documentation coverage, and use nightly runs to warm a cache that every PR can read. This refines the earlier nightly-only suggestion; local-off-by-default behavior and release-tag gating remain separate decisions.
The local, per-host cache improvement should wait until
--cache-locationis available in a stable lychee release. It is independent of the CI changes and should not hold them up..github/workflows/pre-commit.ymlpull_requestand pushes tomain.github/workflows/lychee.ymlpull_request,workflow_call, and manual dispatch.github/workflows/ci-nightly.yml1. Extract and extend the existing pre-commit job.
Reuse the Windows job's setup and invocation, adding an
ubuntu-latest/windows-latestmatrix withfail-fast: false. Runpre-commit run --all-files; setSKIPexplicitly so lychee runs only in its dedicated workflow. The existingci.skipsetting applies only to pre-commit.ci. Keepsecret-scan-trufflehogcoverage from the Windows job; skip the localcheck-precommit-installedreminder in CI.Use direct
pull_requestevents (opened,reopened,synchronize), including draft PRs. Do not add a push trigger for copy-pr-bot'spull-request/*branches. The lockfile freshness workflow already follows this pattern. Cancel superseded runs per PR, use hosted runners, read-only permissions andpersist-credentials: false, and cache pre-commit environments by OS, Python version, and configuration hash. Keep required checks free of workflow-level path filters.Use
pull_requestto execute PR code with read-only permissions. GitHub's fork-approval settings and merge-conflict restrictions still apply.2. Move all CI lychee invocations into the dedicated workflow.
Check both authored
.md/.rstfiles, retaining the currentqa/exclusion, and generated HTML. Preserve the rendered check's full fragment validation, throttling, retries, and targeted exclusions. Run the full link sweep on each PR update; cached successful external checks supply the optimization. Require nonempty inputs and fail on persistent bad links, setup/configuration errors, or missing exit-code results.The PR workflow must build documentation for its checked-out revision without waiting for wheels or docs artifacts from copy-pr-bot CI. The initial implementation candidate is the cuda_core docs environment with source dependencies: install source metapackage metadata with
--no-deps, then run the existingcuda_python/docs/build_all_docs.sh latest-only. This assembles all four documentation trees. Fetch full Git history/tags for setuptools-scm and setCUDA_PYTHON_DOCS_GITHUB_REFto the checked-out SHA.Validate that source-build path before adopting it. The current root
pixi run docsis insufficient: it omits metapackage docs and assembly, and the bindings docs environment uses a published bindings package. Verify that generated local links/fragments resolve against the assembled tree and that PR source API changes appear in the generated pages.Once the independent checker works, remove lychee and its cache steps from
build-docs.yml. Copied-ref CI continues building and deploying previews, with no second lychee sweep. Add an independent reusable-workflow call inci-nightly.yml; it must not depend on wheel lookup or GPU jobs. Wire its result into the nightly status aggregator, requiring explicit success. Keep manual dispatch available for initial cache warming and diagnosis. The bounded second-pass retry proposal can be implemented separately.3. Publish a shared nightly cache baseline.
GitHub Actions supports a repository-wide baseline, rather than one globally mutable cache. PRs can restore default-branch caches, including fork PRs; their own caches cannot feed sibling PRs or the default branch. Cache entries cannot be updated in place.
Start with nightly writes and PR readers:
lychee-v1-<version>-<policy-hash>-baseline-<run-id>-<run-attempt>; PRs restore the newest matching baseline prefix.--max-cache-age 1d. Keep the saved/restored cache path consistent, since GitHub includes paths in the cache version.Fresh nightly sweeps matter: cache hits retain their original timestamps, so uploading yesterday's cache under a new key does not refresh its contents. Optional PR-local snapshots can follow if repeated pushes with new URLs need further optimization; they benefit only that PR.
4. Complete the service migration after validating the replacement.
Remove the old Windows job from
ci.yml, including its dependency and explicit success check in the status aggregator. UpdateCONTRIBUTING.mdand preserve quarterlypre-commit autoupdate --freezePRs through a separate trusted maintenance workflow. Automatic service autofixes are already disabled.The currently inspected
mainrules explicitly requirepre-commit.ci - pr. Give the replacement stable job names, for examplePre-commit (Linux),Pre-commit (Windows), andDocumentation links. After successful real PR runs, update the rules to require all three, replacing the retired service requirement while retaining the existing heavyweight CI and metadata requirements. Requiring the link check preserves merge gating when lychee leaves the Docs job. Verify passing and deliberately failing checks before disabling the service.5. Defer local host-cache work until a stable release includes
--cache-location.Keep the current local hook behavior while waiting; do not add a v0.24.2 workaround. After a suitable stable release, update pinned hook/action versions together and use a computed absolute cache location under the user's cache directory, namespaced by host, lychee version, and checking policy. Include host identity if home directories are shared. Preserve automatic hook installation and coordinate concurrent writers across worktrees. Upstream's cache-location implementation is the feature to wait for.
Acceptance checks for the CI changes:
pull-request/1234triggers neither replacement workflow.This is a source-reviewed implementation plan. The independent source-based docs build and its runtime have not yet been validated end to end.
somtri commentedon Oct 2, 2026
#2993 and #2994 cover this, so I'm withdrawing my offer to make the workflow change.