ci: exclude own item links and cache verdicts in docs:links

What

Two changes to docs:links, which are complementary:

  1. lychee.toml stops checking this project's own merge requests, work items, and issues.
  2. The job runs lychee --cache and persists .lycheecache between pipelines, logging the cache's row count either side of the run.

Why

The job re-requested every external URL in the repository on every pipeline. Across 38 docs:links runs on main sampled on 2026-08-27 the median is 9.2 min, ranging 6.0 to 16.8 min. It runs about 180 times a day, roughly 28 runner hours spent re-asking the same questions.

The finding that ties the two changes together

lychee does not persist error verdicts. Verified against the 0.24.2 the pinned image ships, on a page holding one 200, one 404 and one 503:

--- .lycheecache after run 1 ---
https://www.rust-lang.org/,200,1787832618

Only the 200 is written. Both failures are re-requested on the next run. (0.21.0 does persist a 404, so the behavior changed between those releases; the comment in the job says to re-check on a bump.)

An earlier revision of this description claimed the opposite, that "a transient 5xx sticks for the cache lifetime". That was wrong for the version that runs here, and it was the wrong way round: the problem is not that errors stick, it is that they never stick, so every failing URL costs its full retry budget on every run, cache or no cache.

Which URLs fail here? All of them are gitlab.com, 503-ing under the [hosts."gitlab.com"] throttle this repo needs to avoid rate limiting:

[503] https://gitlab.com/gitlab-org/gitlab/-/merge_requests/247731 | Rejected status code: 503
[503] https://gitlab.com/gitlab-org/gitlab/-/work_items/543045    | Rejected status code: 503

That is why the cache first measured as a no-op in CI while the identical command on a laptop, where nothing 503s, is 51x faster:

COLD: 6404 Total (in 5m 40s) | 2017 Unique | 6378 OK | 0 Errors
      .lycheecache written: 879 rows, 76K
WARM: 6404 Total (in 6s 624ms) | 2017 Unique | 6378 OK | 0 Errors

The cache is good at verdicts that succeed. The CI run is dominated by verdicts that fail. Caching alone could never have closed that.

What the exclusion does

lychee --dump '**/*.md' counts 1988 requestable links without the three new exclusion lines and 1519 with. All 469 removed are gitlab.com, taking that host from 649 requests to 180:

Before After
requestable links 1988 1519
gitlab.com requests 649 180
floor at the throttle's 4/s ~162 s ~45 s

It also removes the URLs the 503s land on, which is what made the job's error count swing between 0 and 23 on an unchanged commit.

The trade-off, stated plainly. A mistyped item number in one of our own references stops being checked. That check was theoretical rather than actual: the job is allow_failure and had been reporting up to 23 rate-limit errors a run that nobody read. Links to other projects' items are still checked, since those can rot in ways this repository cannot see.

Measured end to end

Two pipelines three minutes apart, same tree, same exclusions, the cache the only variable:

Pipeline Config lychee Job
2796411436 exclusions, no cache 8m 07s 508.8s
2796441091 exclusions, cache 2m 52s 198.8s

Both report identical link accounting (7337 total, 2203 unique, 6154 OK, 1183 excluded, 0 errors), so nothing else moved. The cache cuts the job by 61%.

The two changes are not separable by simply subtracting. The exclusions run on their own lands at 8m 07s against a 9.2 min median (n=38), which is inside the noise band rather than a clear win on its own. What they do is remove the uncacheable failing traffic, which is what lets the cache work at all. The saving then shows up on the cache line. At about 180 runs a day this takes roughly 28 runner hours down to 10.

One gap is still open. CI floors at 172s of lychee time where the identical tree warms to between 2s and 9s on a laptop. The throttle arithmetic above predicts about 45s once the exclusions are in, so roughly two minutes on the runner is unaccounted for. That is upside on top of the result above rather than a blocker, and it is worth a look before anyone quotes a further target.

What this MR does not claim

docs:links was the longest job in 0 of 28 recent main pipelines. The longest is always test:integration or gitlab-advanced-sast, both two to three times the link job. Median queued_duration across 214 jobs is 0.2s.

The saving is runner minutes, not developer wall clock. No pipeline finishes sooner because of this MR.

Four details worth a reviewer's attention

  • cache:when: always is load-bearing, and is validated. allow_failure does not change the job's exit status, and cache:when defaults to on_success. A run on this branch exited non-zero and its trace shows Saving cache for failed job followed by Created cache.
  • The cache key carries the pinned image tag, so a bump hands the new lychee an empty cache rather than one the old wrote. cache:key forbids /, which the image reference has four of, so the tag is the key's variable part. This matches the garage-${GARAGE_VERSION} slot in .gitlab-ci.yml.
  • --max-cache-age is 7d, not the 1d default. The cliff either side of the limit is steep and one-sided: on an unchanged tree with only the cache timestamps moved, a cache written minutes earlier finishes in 2s and the same cache backdated two days takes 5m13s. This branch hit exactly that, and it is why the first measurements showed the cache saving nothing: a cache written on 20 August was discarded whole on 27 August. The cost is that the run which first notices a link has rotted can be up to a week late.
  • The row-count diagnostic stays. GitLab logs Successfully extracted cache whatever the archive holds, so the trace cannot otherwise separate a cache written nearly empty from one never consulted. With the 503 traffic gone, this is what will show whether the restored cache is finally being read.

Conflicts

.gitlab/ci/docs.gitlab-ci.yml is also touched by !1782 (merged), which adds interruptible: false to .review-docs at line 110. The hunks are far apart and git merge-tree reports no conflict either way. An earlier revision of this section said "None", which was written before !1782 (merged) was opened and not corrected when it was.

lychee.toml is touched by no other open MR.

Testing

CI plumbing with no Go surface. The exclusion effect is measured with lychee --dump above, and the error-caching behavior with a three-URL fixture against both 0.21.0 and the pinned 0.24.2. The docs:links script was verified under sh -e for cache present, cache absent, and lychee exiting non-zero. --max-cache-age 7d was confirmed accepted by the pinned 0.24.2 and exercised against a warm cache on the CI tree.

Related to #751

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading