ci: exclude own item links and cache verdicts in docs:links
What
Two changes to docs:links, which are complementary:
lychee.tomlstops checking this project's own merge requests, work items, and issues.- The job runs
lychee --cacheand persists.lycheecachebetween 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,1787832618Only 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: 503That 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 ErrorsThe 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: alwaysis load-bearing, and is validated.allow_failuredoes not change the job's exit status, andcache:whendefaults toon_success. A run on this branch exited non-zero and its trace showsSaving cache for failed jobfollowed byCreated 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:keyforbids/, which the image reference has four of, so the tag is the key's variable part. This matches thegarage-${GARAGE_VERSION}slot in.gitlab-ci.yml. --max-cache-ageis7d, not the1ddefault. 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 cachewhatever 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