Skip to content

Commit b7a86ea

Browse files
hotlongclaude
andauthored
fix(devx): retry the lychee binary download and say when the link check did not run (#8253)
`Check Documentation Links` went red three times on 2026-08-12 without examining a single link. lychee-action@v2's `lychee-setup` step fetches the release tarball with a bare `curl -sfLO` -- no retry -- and when that one request loses, curl exits 22, the action's `Install lychee` and `Run Lychee` steps both report `skipped`, and the job fails in ~9 seconds. All three job logs read directly, byte-identical in shape: #8128 17:23:49Z run 31622391000 job 94200196323 exit 22, 497ms #8205 20:08:47Z run 31636151516 job 94246838620 exit 22, 177ms #8225 21:21:28Z run 31642129140 job 94266966755 exit 22 Transient, not systemic: each cleared on the next attempt, and a sibling PR went green six minutes after the last failure. Three changes, none of them a weakening -- `fail: true` stays on every path and a retry that exhausts still fails the job. Retry. The action is invoked twice: attempt 1 defers its verdict via `continue-on-error`, a 15s wait follows, then an identical retry that carries no such escape. A back-to-back retry would retry inside the same blip; every observed recovery was tens of seconds to tens of minutes later. Zero cost on a green run -- all three added steps are `skipped` when attempt 1 passes. Legibility. A new step distinguishes "links are broken" from "the link check never ran" and says so in the job summary and as an error annotation. The discriminator is exact, not heuristic: the action's entrypoint.sh writes `exit_code` to $GITHUB_OUTPUT *before* it exits, so a genuine broken-link failure carries a value while a setup failure skips `Run Lychee` and leaves it unset. It exits 1 on its own so the case stays red even if someone later makes the retry lenient. Version pin. Three comments in this file reasoned about "the pinned lychee 0.24.2" -- the `--offline` argument turns on which version runs -- but nothing here pinned it; the version came from the action's own default, which moves when the `v2` tag moves. `lycheeVersion: v0.24.2` asserts at the invocation site what `--offline` already depends on. The argv moves to a job-level `env` so the two invocations cannot drift. Verified byte-identical to origin/main's inline args by parsing both. Caching (the card's shape 1) is NOT included, on measurement: the action `rm -rf`s its download directory and re-downloads unconditionally, so `actions/cache` cannot reach it -- a cache step here would read as coverage while doing nothing. Part of #8238 Claude-Session: https://claude.ai/code/session_01BytmXbyC9R2Wvpg2uW14fc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e5af5c commit b7a86ea

1 file changed

Lines changed: 151 additions & 44 deletions

File tree

.github/workflows/check-links.yml

Lines changed: 151 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,67 @@ jobs:
3636
permissions:
3737
contents: read
3838

39+
# The lychee argv lives here, not inline on the step, because the step is
40+
# invoked TWICE (attempt + retry, see #8238 below) and two copies of a
41+
# 9-line argv is a drift hazard: an edit to one copy silently changes what
42+
# the retry checks relative to the first attempt. One definition, two
43+
# readers. `github.workspace` is available in a job-level `env`.
44+
env:
45+
# `--offline` is the internal-only mechanism, and it lives HERE rather
46+
# than in lychee.toml on purpose: the equivalent `offline = true`
47+
# config key is silently ignored by older lychee (measured: ignored on
48+
# 0.19.1, honoured on the 0.24.2 pinned below). A determinism guarantee
49+
# must not depend on which lychee the action happens to install, so it is
50+
# asserted at the invocation site.
51+
#
52+
# Offline means only `file://` targets are resolved -- every http(s)
53+
# link is reported EXCLUDED, never requested. That is what makes this
54+
# gate deterministic and free of external-network flake.
55+
#
56+
# --root-dir is what makes ROOT-RELATIVE links checkable. Most internal
57+
# links in content/** are site routes (`/docs/permissions`), and lychee
58+
# hard-errors on those unless it is told which directory `/` means.
59+
# The Fumadocs content root is `content/`, so `/docs/x` resolves to
60+
# content/docs/x -- and --fallback-extensions supplies the .mdx/.md
61+
# suffix that a site route omits. Without this pair the gate cannot go
62+
# green at all: 1286 root-relative links fail as "Cannot resolve
63+
# root-relative link ... provide a root dir".
64+
#
65+
# ⛔ Do NOT add `docs/adr/**/*.md` here. It looks like the one-line fix
66+
# for #6592 and it is not: measured on the pinned lychee 0.24.2, that
67+
# glob reports 8 broken links today, every one a pre-existing ADR →
68+
# source-tree link whose target moved out of this repo. This job would
69+
# be red on every PR from the moment it merged, which is how an
70+
# advisory lane becomes a lane nobody reads (#6028 landed it
71+
# advisory-first specifically to earn a green streak). `docs/adr/` is
72+
# checked by the `Check ADR cross-links` step below instead, which can
73+
# freeze those 8 on a shrink-only baseline and fail on a NEW one --
74+
# something neither `exclude` nor `.lycheeignore` can express, because
75+
# neither ever tells you an entry stopped being needed.
76+
# `ARCHITECTURE.md` joined this glob in #6867: unlike `docs/adr/**`, it
77+
# carried no pre-existing rot once its 10 dead links + 2 stale path
78+
# references were fixed in the same PR, so -- unlike the ADR directory
79+
# above -- it needs no KNOWN_DEAD_TARGETS-style baseline to land clean.
80+
LYCHEE_ARGS: >-
81+
--offline
82+
--root-dir ${{ github.workspace }}/content
83+
--fallback-extensions mdx,md
84+
--config lychee.toml
85+
'content/**/*.md'
86+
'content/**/*.mdx'
87+
'README.md'
88+
'ARCHITECTURE.md'
89+
90+
# ⛔ Pin the lychee binary explicitly rather than inheriting the action's
91+
# default (#8238). Three comments in this file already reason about "the
92+
# pinned lychee 0.24.2" -- the `--offline` argument above turns on which
93+
# version runs -- but nothing here pinned it: the version came from
94+
# `lycheeverse/lychee-action@v2`'s own `lycheeVersion` default, which
95+
# moves whenever the `v2` tag moves. The determinism claim was true only
96+
# by coincidence. Asserting it at the invocation site is the same rule
97+
# `--offline` is held to, applied to the thing `--offline` depends on.
98+
LYCHEE_VERSION: v0.24.2
99+
39100
steps:
40101
- name: Checkout repository
41102
uses: actions/checkout@v7
@@ -53,52 +114,98 @@ jobs:
53114
- name: Check ADR cross-links
54115
run: node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs
55116

117+
# ── #8238: the action's binary download is a single-shot curl ───────────
118+
#
119+
# `lychee-action@v2`'s `lychee-setup` step fetches the release tarball
120+
# with a bare `curl -sfLO` -- no `--retry`, no cache, and a `rm -rf` of
121+
# its download dir on every run, so `actions/cache` cannot reach it (the
122+
# action re-downloads unconditionally whatever is already on disk; a
123+
# cache step here would read as coverage while doing nothing).
124+
#
125+
# When that one curl loses, `curl` exits 22 ("HTTP page not retrieved"),
126+
# the action's `Install lychee` and `Run Lychee` steps both report
127+
# `skipped`, and this job goes red having examined ZERO links. Measured
128+
# on three unrelated PRs in one afternoon (2026-08-12), all three job
129+
# logs read directly and byte-identical in shape:
130+
#
131+
# #8128 17:23:49Z run 31622391000 job 94200196323 exit 22, 497ms
132+
# #8205 20:08:47Z run 31636151516 job 94246838620 exit 22, 177ms
133+
# #8225 21:21:28Z run 31642129140 job 94266966755 exit 22
134+
#
135+
# Transient, not systemic: every one cleared on the next attempt, and
136+
# PR #8227 went green at 21:27:53Z, six minutes after #8225's failure.
137+
#
138+
# ⛔ This is NOT gate weakening and must not become it. `fail: true`
139+
# stays on both attempts and neither is allowed to fail open: a retry
140+
# that exhausts still fails the job -- it just now says WHY.
56141
- name: Check links with lychee
142+
id: lychee
143+
# Attempt 1 only. `continue-on-error` here hands the verdict to the
144+
# retry below; it does NOT let a failure through, because the retry
145+
# step carries no such escape and its failure fails the job.
146+
continue-on-error: true
57147
uses: lycheeverse/lychee-action@v2
58148
with:
59-
# `--offline` is the internal-only mechanism, and it lives HERE rather
60-
# than in lychee.toml on purpose: the equivalent `offline = true`
61-
# config key is silently ignored by older lychee (measured: ignored on
62-
# 0.19.1, honoured on the 0.24.2 this action pins). A determinism
63-
# guarantee must not depend on which lychee the action happens to
64-
# install, so it is asserted at the invocation site.
65-
#
66-
# Offline means only `file://` targets are resolved -- every http(s)
67-
# link is reported EXCLUDED, never requested. That is what makes this
68-
# gate deterministic and free of external-network flake.
69-
#
70-
# --root-dir is what makes ROOT-RELATIVE links checkable. Most internal
71-
# links in content/** are site routes (`/docs/permissions`), and lychee
72-
# hard-errors on those unless it is told which directory `/` means.
73-
# The Fumadocs content root is `content/`, so `/docs/x` resolves to
74-
# content/docs/x -- and --fallback-extensions supplies the .mdx/.md
75-
# suffix that a site route omits. Without this pair the gate cannot go
76-
# green at all: 1286 root-relative links fail as "Cannot resolve
77-
# root-relative link ... provide a root dir".
78-
#
79-
# ⛔ Do NOT add `docs/adr/**/*.md` here. It looks like the one-line fix
80-
# for #6592 and it is not: measured on the pinned lychee 0.24.2, that
81-
# glob reports 8 broken links today, every one a pre-existing ADR →
82-
# source-tree link whose target moved out of this repo. This job would
83-
# be red on every PR from the moment it merged, which is how an
84-
# advisory lane becomes a lane nobody reads (#6028 landed it
85-
# advisory-first specifically to earn a green streak). `docs/adr/` is
86-
# checked by the `Check ADR cross-links` step above instead, which can
87-
# freeze those 8 on a shrink-only baseline and fail on a NEW one --
88-
# something neither `exclude` nor `.lycheeignore` can express, because
89-
# neither ever tells you an entry stopped being needed.
90-
# `ARCHITECTURE.md` joined this glob in #6867: unlike `docs/adr/**`, it
91-
# carried no pre-existing rot once its 10 dead links + 2 stale path
92-
# references were fixed in the same PR, so -- unlike the ADR directory
93-
# above -- it needs no KNOWN_DEAD_TARGETS-style baseline to land clean.
94-
args: >-
95-
--offline
96-
--root-dir ${{ github.workspace }}/content
97-
--fallback-extensions mdx,md
98-
--config lychee.toml
99-
'content/**/*.md'
100-
'content/**/*.mdx'
101-
'README.md'
102-
'ARCHITECTURE.md'
149+
lycheeVersion: ${{ env.LYCHEE_VERSION }}
150+
args: ${{ env.LYCHEE_ARGS }}
103151
# Fail the job if broken links are found
104152
fail: true
153+
154+
# A back-to-back retry is not obviously enough. Every observed recovery
155+
# was tens of seconds to tens of minutes later, so retrying within the
156+
# same second would be retrying inside the same blip. 15s is a judgement,
157+
# not a measurement -- the retry is the part the evidence supports. Costs
158+
# nothing on a green run: this step is `skipped` when attempt 1 passes.
159+
- name: Wait before retrying the lychee setup
160+
if: steps.lychee.outcome == 'failure'
161+
run: sleep 15
162+
163+
- name: Check links with lychee (retry)
164+
id: lychee-retry
165+
if: steps.lychee.outcome == 'failure'
166+
uses: lycheeverse/lychee-action@v2
167+
with:
168+
lycheeVersion: ${{ env.LYCHEE_VERSION }}
169+
args: ${{ env.LYCHEE_ARGS }}
170+
fail: true
171+
172+
# ── #8238, the legibility half: distinguish "links are broken" from ────
173+
# "the link check never ran"
174+
#
175+
# Both outcomes were the same red before this step, and telling them
176+
# apart cost every reader a job-log read. `exit_code` is the
177+
# discriminator, and it is exact rather than heuristic: the action's
178+
# `entrypoint.sh` writes `exit_code=$LYCHEE_EXIT_CODE` to `$GITHUB_OUTPUT`
179+
# BEFORE it exits, so a genuine broken-link failure carries a value (2)
180+
# while a setup failure skips `Run Lychee` entirely and leaves the output
181+
# unset. Empty ⇒ lychee never executed ⇒ nothing about the links was
182+
# examined, whatever the check's name suggests.
183+
#
184+
# `$GITHUB_STEP_SUMMARY` is safe to append to here: the only writer of
185+
# that file in this job is `entrypoint.sh`, which never ran in the one
186+
# case this step fires.
187+
- name: Report a setup failure as "the link check did not run"
188+
if: always() && steps.lychee-retry.outcome == 'failure' && steps.lychee-retry.outputs.exit_code == ''
189+
run: |
190+
{
191+
echo "## ⚠️ The link check did not run"
192+
echo
193+
echo "\`lychee\` was never executed, so **no link in this repository was"
194+
echo "examined** — this red says nothing about the documentation links,"
195+
echo "and nothing about the files this PR touches."
196+
echo
197+
echo "Both attempts failed while \`lycheeverse/lychee-action@v2\` was"
198+
echo "downloading the \`${LYCHEE_VERSION}\` binary from the GitHub releases"
199+
echo "CDN (\`curl\` exit 22). This is a known transient failure (#8238);"
200+
echo "re-running the job is the expected remedy."
201+
echo
202+
echo "The gate remains fail-closed on purpose: a link check that could"
203+
echo "not run must not report success."
204+
} >> "$GITHUB_STEP_SUMMARY"
205+
echo "::error title=Link check did not run::lychee setup failed twice (binary download, curl exit 22). No links were checked — see #8238. Re-run the job."
206+
# Fail-closed, asserted here rather than inherited. The retry step has
207+
# already failed the job, so this `exit 1` is redundant today -- and
208+
# deliberately so: if anyone ever adds `continue-on-error` to the
209+
# retry, the "did not run" case must still be red, not a vacuous
210+
# green (#4690).
211+
exit 1

0 commit comments

Comments
 (0)