From 51dffc54a3e2a6a46971da69407a5e3b9abdc69d Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 2 Aug 2026 14:12:25 +0200
Subject: [PATCH 1/6] ci: check links in skill content
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A dead link in context/ ships into agent context windows and degrades every
wizard and MCP user until someone notices by hand. Two recent fixes (0741482,
fda834d) were exactly that: link rot found by accident, after shipping.
Split into two workflows so a PR is only blocked by what it changes:
- link-check-offline: relative paths and #anchors. No network, so it cannot
flake; baseline is green today, so any red is a real regression. Blocking.
- link-check-external: the 59 external URLs, nightly and non-blocking. Reports
via the same PostHog webhook e2e.yml/integration.yml use, on exit code 2
only (1 and 3 are lychee's own errors, not link rot).
Only reports links that are GONE. 403/429/timeouts are accepted, each with a
reason in-file: they say the server failed to answer, not that the resource is
missing. 5xx stays reportable — retries absorb blips, and a durable 5xx means
the link is effectively dead for an agent.
example-apps/ is excluded on purpose: human-read, not agent-read.
Verified against lychee 0.24.2 (the version the action bundles): offline exits
0, external exits 2 with three genuine findings, fixed separately.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01P2iPGUrMb11u5o6RfxnTkq
---
.github/workflows/link-check-external.yml | 119 ++++++++++++++++++++++
.github/workflows/link-check-offline.yml | 58 +++++++++++
.lycheeignore | 15 +++
3 files changed, 192 insertions(+)
create mode 100644 .github/workflows/link-check-external.yml
create mode 100644 .github/workflows/link-check-offline.yml
create mode 100644 .lycheeignore
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
new file mode 100644
index 00000000..c1d7ace0
--- /dev/null
+++ b/.github/workflows/link-check-external.yml
@@ -0,0 +1,119 @@
+name: PostHog Context Mill - Link check (external)
+permissions:
+ contents: read
+
+# Nightly check of external URLs in the skill content. Deliberately NOT a PR
+# check: an external site going down has nothing to do with the PR in front of
+# it, and blocking merges on someone else's 502 trains people to ignore CI.
+#
+# Why this exists: a dead link in context/ ships into agent context windows and
+# degrades every wizard and MCP user until a human notices by hand. Two past
+# fixes (0741482, fda834d) were exactly that — found by accident, after
+# shipping.
+
+on:
+ schedule:
+ # 08:00 UTC daily (00:00 PST), matching the other nightly workflows here.
+ - cron: '0 8 * * *'
+ workflow_dispatch:
+
+jobs:
+ link-check-external:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ # We only want to hear about links that are GONE — a server successfully
+ # telling us the resource does not exist. Anything else is the server
+ # failing to answer, which says nothing about the link and resolves on
+ # its own. So each non-2xx below is accepted, with the reason:
+ #
+ # 403 Ambiguous: usually bot/WAF blocking of datacenter IPs (the link
+ # is fine in a browser), occasionally a genuine access change. Not
+ # distinguishable from the status code alone, and the noise case
+ # dominates. TRADE-OFF: if a docs page ever goes auth-only, this
+ # check stays silent about it.
+ # 429 Rate limiting — a statement about our request volume, not about
+ # whether the resource exists.
+ # timeouts Transient network trouble; --accept-timeouts covers the
+ # case retries do not.
+ #
+ # 5xx is deliberately NOT accepted. lychee retries 3x by default, which
+ # absorbs a momentary blip; a 5xx that survives retries every night means
+ # the host is durably broken and the link is effectively dead for an
+ # agent, which is worth hearing about.
+ #
+ # -X get: fragment (#anchor) checks need a response body, so they only
+ # happen on GET. Without this, a HEAD success silently skips the anchor
+ # check and dead anchors come and go between runs.
+ #
+ # Exclusions live in .lycheeignore at the repo root — see that file first,
+ # it explains why backticking an illustrative URL is usually the better
+ # fix than adding an entry.
+ #
+ # Paths duplicated in link-check-offline.yml; static, see note there.
+ # example-apps/ excluded on purpose — human-read, not agent-read.
+ - name: Check external links
+ id: lychee
+ uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
+ with:
+ args: >-
+ -X get
+ --accept 200..=204,403,429
+ --accept-timeouts
+ --include-fragments
+ --no-progress
+ 'context/**/*.md'
+ README.md
+ CONTRIBUTING.md
+ AGENTS.md
+ CLAUDE.md
+ # Never fail the job; this workflow reports, it does not gate. The
+ # webhook step below branches on the exit code instead.
+ fail: false
+
+ - name: Explain how to fix a failure
+ if: steps.lychee.outputs.exit_code == 2
+ run: |
+ cat <<'EOF'
+ ::warning::Dead external links found — see the job summary for file, line and URL.
+ A finding here is a 404/410 (resource gone) or a missing #anchor (page moved on, heading renamed).
+ Transient failures (403/429/timeouts) are already accepted and will not appear.
+
+ To fix:
+ - repoint the URL to its current location, or remove the link.
+ - missing anchor: the page still exists but the heading was renamed — update the #fragment.
+
+ FALSE POSITIVE? Prefer the structural fix: lychee ignores URLs inside
+ `inline code spans` and fenced blocks, so backticking an illustrative
+ URL (a config value, an API host, a placeholder) removes it from the
+ check with no config change. Only if it is a real link we genuinely
+ cannot reach from CI, add a regex to .lycheeignore at the repo root and
+ say why in a comment.
+ EOF
+
+ # Mirrors the failure-reporting pattern in e2e.yml / integration.yml.
+ # Only exit code 2 means "link check failures"; 1 and 3 are lychee's own
+ # config/runtime errors and must not be reported as link rot.
+ - name: Send failure event to PostHog
+ if: steps.lychee.outputs.exit_code == 2
+ env:
+ COMMIT_SHA: ${{ github.sha }}
+ GH_REF: ${{ github.ref }}
+ GH_WORKFLOW: ${{ github.workflow }}
+ RUN_ID: ${{ github.run_id }}
+ RUN_NUMBER: ${{ github.run_number }}
+ JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ curl -X POST https://webhooks.us.posthog.com/public/webhooks/019a7a81-7961-0000-d3e3-b5f34cc2a32b \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n \
+ --arg event "posthog-context-mill-repo-link-rot" \
+ --arg commitSha "$COMMIT_SHA" \
+ --arg ref "$GH_REF" \
+ --arg workflow "$GH_WORKFLOW" \
+ --arg runId "$RUN_ID" \
+ --arg runNumber "$RUN_NUMBER" \
+ --arg jobUrl "$JOB_URL" \
+ '{event: $event, commitSha: $commitSha, ref: $ref, workflow: $workflow, runId: $runId, runNumber: $runNumber, jobUrl: $jobUrl}')"
diff --git a/.github/workflows/link-check-offline.yml b/.github/workflows/link-check-offline.yml
new file mode 100644
index 00000000..ffb831d6
--- /dev/null
+++ b/.github/workflows/link-check-offline.yml
@@ -0,0 +1,58 @@
+name: PostHog Context Mill - Link check (offline)
+permissions:
+ contents: read
+
+# Blocking PR check for links that CANNOT flake: relative file paths and
+# #anchors within the repo. No network requests are made (--offline is
+# equivalent to --scheme file), so a failure here is always caused by the PR
+# that triggered it — a moved file, or a renamed heading an existing link
+# points at. External URLs are checked by link-check-external.yml instead,
+# nightly and non-blocking, so an unrelated site being down never blocks a PR.
+#
+# The baseline was green when this landed, so any red is a real regression.
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ link-check-offline:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ # Paths are duplicated in link-check-external.yml. They are static — a
+ # skill tree is added maybe a few times a year — so they are kept inline
+ # rather than hoisted into a shared config.
+ #
+ # example-apps/ is EXCLUDED ON PURPOSE. Its markdown is read by humans,
+ # who can route around a dead link; context/ ships into agent context
+ # windows, where a dead link silently degrades every wizard and MCP user.
+ # Please don't widen this glob without that trade-off in mind.
+ - name: Check relative links and anchors
+ uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
+ with:
+ args: >-
+ --offline
+ --include-fragments
+ --no-progress
+ 'context/**/*.md'
+ README.md
+ CONTRIBUTING.md
+ AGENTS.md
+ CLAUDE.md
+ fail: true
+
+ - name: Explain how to fix a failure
+ if: failure()
+ run: |
+ cat <<'EOF'
+ ::error::Broken relative link or anchor. These do not flake — a link in this repo points at a file or heading that no longer exists.
+ Fix by either:
+ - restoring/repointing the link to the new path or heading, or
+ - if a heading was intentionally renamed, updating every link that targets its #anchor.
+ See the job summary above for the exact file, line, and target.
+ EOF
diff --git a/.lycheeignore b/.lycheeignore
new file mode 100644
index 00000000..cac8acb7
--- /dev/null
+++ b/.lycheeignore
@@ -0,0 +1,15 @@
+# Link-check exclusions, read automatically by lychee in both link-check
+# workflows. One regex per line (Rust regex syntax, unanchored — use ^ when you
+# mean "starts with"). Blank lines and # comments are ignored.
+#
+# BEFORE ADDING A LINE HERE, prefer the structural fix:
+# lychee never extracts URLs inside `inline code spans` or ``` fenced blocks ```.
+# If the URL is illustrative — a config value, an API host, a placeholder like
+# https://example.com/ — wrap it in backticks in the markdown and it stops
+# being checked, with no entry here. That is why this file is empty: every
+# non-link URL in context/ is already code-formatted.
+#
+# Add a regex here only for URLs that are real links we cannot check from CI,
+# e.g. a host that hard-blocks datacenter IPs. Always say why:
+#
+# ^https://intranet\.example\.com # SSO-only, unreachable from CI
From ddbc334c8dd70748f33db004b1e584faa9aacc0e Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 2 Aug 2026 14:29:12 +0200
Subject: [PATCH 2/6] ci: address review feedback on link checks
- Trim comments that restate the diff or belong in the PR description.
- Drop the fix-instruction steps duplicating lychee's own output; keep a
one-line pointer, since logs are read from the failing step upwards.
- Keep the false-positive guidance only in .lycheeignore.
- Fail the nightly job after the webhook fires, so GitHub shows it red too.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01P2iPGUrMb11u5o6RfxnTkq
---
.github/workflows/link-check-external.yml | 41 ++++++-----------------
.github/workflows/link-check-offline.yml | 22 ++----------
2 files changed, 14 insertions(+), 49 deletions(-)
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
index c1d7ace0..9f0367fc 100644
--- a/.github/workflows/link-check-external.yml
+++ b/.github/workflows/link-check-external.yml
@@ -7,9 +7,7 @@ permissions:
# it, and blocking merges on someone else's 502 trains people to ignore CI.
#
# Why this exists: a dead link in context/ ships into agent context windows and
-# degrades every wizard and MCP user until a human notices by hand. Two past
-# fixes (0741482, fda834d) were exactly that — found by accident, after
-# shipping.
+# degrades every wizard and MCP user until a human notices by hand.
on:
schedule:
@@ -48,11 +46,6 @@ jobs:
# happen on GET. Without this, a HEAD success silently skips the anchor
# check and dead anchors come and go between runs.
#
- # Exclusions live in .lycheeignore at the repo root — see that file first,
- # it explains why backticking an illustrative URL is usually the better
- # fix than adding an entry.
- #
- # Paths duplicated in link-check-offline.yml; static, see note there.
# example-apps/ excluded on purpose — human-read, not agent-read.
- name: Check external links
id: lychee
@@ -69,30 +62,10 @@ jobs:
CONTRIBUTING.md
AGENTS.md
CLAUDE.md
- # Never fail the job; this workflow reports, it does not gate. The
- # webhook step below branches on the exit code instead.
+ # Don't fail here — the webhook step below still needs to run. The job
+ # fails at the end instead, so GitHub shows it red too.
fail: false
- - name: Explain how to fix a failure
- if: steps.lychee.outputs.exit_code == 2
- run: |
- cat <<'EOF'
- ::warning::Dead external links found — see the job summary for file, line and URL.
- A finding here is a 404/410 (resource gone) or a missing #anchor (page moved on, heading renamed).
- Transient failures (403/429/timeouts) are already accepted and will not appear.
-
- To fix:
- - repoint the URL to its current location, or remove the link.
- - missing anchor: the page still exists but the heading was renamed — update the #fragment.
-
- FALSE POSITIVE? Prefer the structural fix: lychee ignores URLs inside
- `inline code spans` and fenced blocks, so backticking an illustrative
- URL (a config value, an API host, a placeholder) removes it from the
- check with no config change. Only if it is a real link we genuinely
- cannot reach from CI, add a regex to .lycheeignore at the repo root and
- say why in a comment.
- EOF
-
# Mirrors the failure-reporting pattern in e2e.yml / integration.yml.
# Only exit code 2 means "link check failures"; 1 and 3 are lychee's own
# config/runtime errors and must not be reported as link rot.
@@ -117,3 +90,11 @@ jobs:
--arg runNumber "$RUN_NUMBER" \
--arg jobUrl "$JOB_URL" \
'{event: $event, commitSha: $commitSha, ref: $ref, workflow: $workflow, runId: $runId, runNumber: $runNumber, jobUrl: $jobUrl}')"
+
+ # Last, so the webhook always fires first.
+ - name: Fail on dead links
+ if: steps.lychee.outputs.exit_code == 2
+ run: |
+ echo "::error::Dead links found — see the 'Check external links' step above for each file, line and URL."
+ echo "False positive? .lycheeignore at the repo root explains how to handle it."
+ exit 1
diff --git a/.github/workflows/link-check-offline.yml b/.github/workflows/link-check-offline.yml
index ffb831d6..7cd3f7b6 100644
--- a/.github/workflows/link-check-offline.yml
+++ b/.github/workflows/link-check-offline.yml
@@ -3,13 +3,7 @@ permissions:
contents: read
# Blocking PR check for links that CANNOT flake: relative file paths and
-# #anchors within the repo. No network requests are made (--offline is
-# equivalent to --scheme file), so a failure here is always caused by the PR
-# that triggered it — a moved file, or a renamed heading an existing link
-# points at. External URLs are checked by link-check-external.yml instead,
-# nightly and non-blocking, so an unrelated site being down never blocks a PR.
-#
-# The baseline was green when this landed, so any red is a real regression.
+# #anchors within the repo.
on:
push:
@@ -24,10 +18,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- # Paths are duplicated in link-check-external.yml. They are static — a
- # skill tree is added maybe a few times a year — so they are kept inline
- # rather than hoisted into a shared config.
- #
# example-apps/ is EXCLUDED ON PURPOSE. Its markdown is read by humans,
# who can route around a dead link; context/ ships into agent context
# windows, where a dead link silently degrades every wizard and MCP user.
@@ -46,13 +36,7 @@ jobs:
CLAUDE.md
fail: true
- - name: Explain how to fix a failure
+ - name: Explain the failure
if: failure()
run: |
- cat <<'EOF'
- ::error::Broken relative link or anchor. These do not flake — a link in this repo points at a file or heading that no longer exists.
- Fix by either:
- - restoring/repointing the link to the new path or heading, or
- - if a heading was intentionally renamed, updating every link that targets its #anchor.
- See the job summary above for the exact file, line, and target.
- EOF
+ echo "::error::A link points at a file or heading that no longer exists — see the step above for each file, line and target. These don't flake, so this is a real break."
From 896b3e8141ce968d738a796e6e1b073675d872a3 Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 2 Aug 2026 14:58:22 +0200
Subject: [PATCH 3/6] ci: scan the whole repo, drop remote fragment checks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Red-teaming found the checks didn't cover what they claimed:
- The offline check verified 0 links in context/ — skill content has no
relative links, so the blocking job only ever checked the 4 root docs.
- example-apps/ was excluded as "human-read", but example-processor.js
inlines README markdown raw (unfenced) into references/EXAMPLE.md inside
the shipped skill ZIPs. Its links are agent-read.
Both go away by globbing every .md in the repo instead of naming paths, which
also means a new directory is covered without editing these files. Scoped to
*.md rather than '.' because bare '.' parses HTML too, where root-relative
asset paths can't resolve. This found one more real 404, in an example README.
Dropped --include-fragments from the nightly: posthog.com renders some
headings client-side, so a #fragment that works in a browser is absent from
the HTML lychee sees. Anchors are still checked offline against local files.
Also: pin lycheeVersion (the action SHA doesn't pin the binary it fetches),
fail the job on any nonzero exit rather than only 2 (it would otherwise go
green having checked nothing), curl -f so a dead webhook is visible, add
timeout-minutes, and exclude localhost dev-server URLs.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01P2iPGUrMb11u5o6RfxnTkq
---
.github/workflows/link-check-external.yml | 43 ++++++++++++-----------
.github/workflows/link-check-offline.yml | 19 +++++-----
.lycheeignore | 19 ++++++----
3 files changed, 45 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
index 9f0367fc..57985559 100644
--- a/.github/workflows/link-check-external.yml
+++ b/.github/workflows/link-check-external.yml
@@ -2,22 +2,23 @@ name: PostHog Context Mill - Link check (external)
permissions:
contents: read
-# Nightly check of external URLs in the skill content. Deliberately NOT a PR
-# check: an external site going down has nothing to do with the PR in front of
-# it, and blocking merges on someone else's 502 trains people to ignore CI.
+# Nightly check of external URLs. Deliberately NOT a PR check: an external site
+# going down has nothing to do with the PR in front of it, and blocking merges
+# on someone else's 502 trains people to ignore CI.
#
# Why this exists: a dead link in context/ ships into agent context windows and
# degrades every wizard and MCP user until a human notices by hand.
on:
schedule:
- # 08:00 UTC daily (00:00 PST), matching the other nightly workflows here.
+ # 08:00 UTC daily (00:00 PST).
- cron: '0 8 * * *'
workflow_dispatch:
jobs:
link-check-external:
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -42,33 +43,33 @@ jobs:
# the host is durably broken and the link is effectively dead for an
# agent, which is worth hearing about.
#
- # -X get: fragment (#anchor) checks need a response body, so they only
- # happen on GET. Without this, a HEAD success silently skips the anchor
- # check and dead anchors come and go between runs.
+ # No --include-fragments here, unlike the offline check. posthog.com
+ # renders some headings client-side, so a #fragment that works in a
+ # browser is absent from the HTML lychee sees — indistinguishable from
+ # real rot, on the host that dominates our links. Anchors are still
+ # checked offline, where the target is a local file and can't lie.
#
- # example-apps/ excluded on purpose — human-read, not agent-read.
+ # Every .md in the repo; see the note in link-check-offline.yml.
- name: Check external links
id: lychee
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
+ # Pinned: the action SHA alone doesn't pin the lychee binary it fetches.
+ lycheeVersion: v0.24.2
args: >-
-X get
--accept 200..=204,403,429
--accept-timeouts
- --include-fragments
--no-progress
- 'context/**/*.md'
- README.md
- CONTRIBUTING.md
- AGENTS.md
- CLAUDE.md
+ --exclude-path node_modules
+ --exclude-path dist
+ './**/*.md'
# Don't fail here — the webhook step below still needs to run. The job
# fails at the end instead, so GitHub shows it red too.
fail: false
# Mirrors the failure-reporting pattern in e2e.yml / integration.yml.
- # Only exit code 2 means "link check failures"; 1 and 3 are lychee's own
- # config/runtime errors and must not be reported as link rot.
+ # -f so the workflow fails if the webhook is down and we can fix it.
- name: Send failure event to PostHog
if: steps.lychee.outputs.exit_code == 2
env:
@@ -79,7 +80,7 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
- curl -X POST https://webhooks.us.posthog.com/public/webhooks/019a7a81-7961-0000-d3e3-b5f34cc2a32b \
+ curl -f -X POST https://webhooks.us.posthog.com/public/webhooks/019a7a81-7961-0000-d3e3-b5f34cc2a32b \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg event "posthog-context-mill-repo-link-rot" \
@@ -91,10 +92,12 @@ jobs:
--arg jobUrl "$JOB_URL" \
'{event: $event, commitSha: $commitSha, ref: $ref, workflow: $workflow, runId: $runId, runNumber: $runNumber, jobUrl: $jobUrl}')"
- # Last, so the webhook always fires first.
+ # Last, so the webhook always fires first. Also catches the case where
+ # lychee exits nonzero for a reason other than 2 — without this the job
+ # would go green having checked nothing.
- name: Fail on dead links
- if: steps.lychee.outputs.exit_code == 2
+ if: steps.lychee.outputs.exit_code != 0
run: |
- echo "::error::Dead links found — see the 'Check external links' step above for each file, line and URL."
+ echo "::error::Link check failed (lychee exit ${{ steps.lychee.outputs.exit_code }}) — see the 'Check external links' step above for each file, line and URL."
echo "False positive? .lycheeignore at the repo root explains how to handle it."
exit 1
diff --git a/.github/workflows/link-check-offline.yml b/.github/workflows/link-check-offline.yml
index 7cd3f7b6..d3b0c9d4 100644
--- a/.github/workflows/link-check-offline.yml
+++ b/.github/workflows/link-check-offline.yml
@@ -14,26 +14,27 @@ on:
jobs:
link-check-offline:
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- # example-apps/ is EXCLUDED ON PURPOSE. Its markdown is read by humans,
- # who can route around a dead link; context/ ships into agent context
- # windows, where a dead link silently degrades every wizard and MCP user.
- # Please don't widen this glob without that trade-off in mind.
+ # Every .md in the repo, so a new directory is covered without touching
+ # this file. Scoped to *.md rather than '.' because bare '.' also parses
+ # HTML, where root-relative asset paths (/favicon.ico) can't resolve
+ # without --root-dir and report as errors.
- name: Check relative links and anchors
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
+ # Pinned: the action SHA alone doesn't pin the lychee binary it fetches.
+ lycheeVersion: v0.24.2
args: >-
--offline
--include-fragments
--no-progress
- 'context/**/*.md'
- README.md
- CONTRIBUTING.md
- AGENTS.md
- CLAUDE.md
+ --exclude-path node_modules
+ --exclude-path dist
+ './**/*.md'
fail: true
- name: Explain the failure
diff --git a/.lycheeignore b/.lycheeignore
index cac8acb7..17d1b7bc 100644
--- a/.lycheeignore
+++ b/.lycheeignore
@@ -1,15 +1,20 @@
# Link-check exclusions, read automatically by lychee in both link-check
# workflows. One regex per line (Rust regex syntax, unanchored — use ^ when you
-# mean "starts with"). Blank lines and # comments are ignored.
+# mean "starts with").
+#
+# Comments must be on their OWN line. A trailing comment is read as part of the
+# regex, so the pattern silently stops matching.
#
# BEFORE ADDING A LINE HERE, prefer the structural fix:
# lychee never extracts URLs inside `inline code spans` or ``` fenced blocks ```.
# If the URL is illustrative — a config value, an API host, a placeholder like
# https://example.com/ — wrap it in backticks in the markdown and it stops
-# being checked, with no entry here. That is why this file is empty: every
-# non-link URL in context/ is already code-formatted.
-#
-# Add a regex here only for URLs that are real links we cannot check from CI,
-# e.g. a host that hard-blocks datacenter IPs. Always say why:
+# being checked, with no entry here.
#
-# ^https://intranet\.example\.com # SSO-only, unreachable from CI
+# Add a regex here only for URLs that are real links we cannot check from CI.
+# Always say why.
+
+# Dev-server URLs in example-app READMEs ("open http://localhost:3000").
+# Nothing is listening during a CI run, and nothing should be.
+^https?://localhost(:[0-9]+)?
+^https?://127\.0\.0\.1(:[0-9]+)?
From f2d2fe8ee7e34a823e83f73674bd0c09fa31a43e Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 2 Aug 2026 15:10:07 +0200
Subject: [PATCH 4/6] ci: drop the cross-file comment reference and the flake
disclaimer
State the glob rationale in both workflows rather than pointing at the other
file, which would drift silently if either is renamed. A failing check is
assumed real until proven flaky, so saying so adds nothing.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01P2iPGUrMb11u5o6RfxnTkq
---
.github/workflows/link-check-external.yml | 5 ++++-
.github/workflows/link-check-offline.yml | 2 +-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
index 57985559..837495da 100644
--- a/.github/workflows/link-check-external.yml
+++ b/.github/workflows/link-check-external.yml
@@ -49,7 +49,10 @@ jobs:
# real rot, on the host that dominates our links. Anchors are still
# checked offline, where the target is a local file and can't lie.
#
- # Every .md in the repo; see the note in link-check-offline.yml.
+ # Every .md in the repo, so a new directory is covered without touching
+ # this file. Scoped to *.md rather than '.' because bare '.' also parses
+ # HTML, where root-relative asset paths (/favicon.ico) can't resolve
+ # without --root-dir and report as errors.
- name: Check external links
id: lychee
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
diff --git a/.github/workflows/link-check-offline.yml b/.github/workflows/link-check-offline.yml
index d3b0c9d4..cf4a7885 100644
--- a/.github/workflows/link-check-offline.yml
+++ b/.github/workflows/link-check-offline.yml
@@ -40,4 +40,4 @@ jobs:
- name: Explain the failure
if: failure()
run: |
- echo "::error::A link points at a file or heading that no longer exists — see the step above for each file, line and target. These don't flake, so this is a real break."
+ echo "::error::A link points at a file or heading that no longer exists — see the step above for each file, line and target."
From b4cfdca71d6a123e9ac8ef614c7e05b6b9f849e4 Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 2 Aug 2026 15:17:18 +0200
Subject: [PATCH 5/6] ci: record why redirects are followed, not flagged
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backtesting against the two commits that motivated this check found 0741482's
markdown fix was a 308 to live content, not a 404 — so this check would not
have caught it. That is the intended behaviour: posthog.com redirects moved
docs rather than 404ing, and flagging ~40 redirects a night, most of them auth
gates, costs more than it catches. A retired redirect becomes a 404 and the
next nightly catches it.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01P2iPGUrMb11u5o6RfxnTkq
---
.github/workflows/link-check-external.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
index 837495da..9a82df21 100644
--- a/.github/workflows/link-check-external.yml
+++ b/.github/workflows/link-check-external.yml
@@ -37,6 +37,12 @@ jobs:
# whether the resource exists.
# timeouts Transient network trouble; --accept-timeouts covers the
# case retries do not.
+ # 3xx Followed, not flagged. posthog.com redirects moved docs rather
+ # than 404ing, so a moved page still resolves and an agent still
+ # lands on the content. Flagging redirects would mean triaging
+ # ~40 a night, most of them auth gates that are correct as
+ # written. When a redirect is eventually retired it becomes a
+ # 404 and this check catches it the same night.
#
# 5xx is deliberately NOT accepted. lychee retries 3x by default, which
# absorbs a momentary blip; a 5xx that survives retries every night means
From 9a47fde05aacf3b4bed258e0279345fcd164b6ff Mon Sep 17 00:00:00 2001
From: Peter Trost
Date: Sun, 16 Aug 2026 22:32:16 +0200
Subject: [PATCH 6/6] ci: raise link-check retries to absorb transient network
failures
Retries fire on connection-level failures but not on resolved status
codes, so a 404 still fails fast and the healthy-path runtime is
unchanged. Buys patience for the transient errors that would otherwise
page on a nightly.
Co-Authored-By: Claude Opus 5
---
.github/workflows/link-check-external.yml | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml
index 9a82df21..0ac4734e 100644
--- a/.github/workflows/link-check-external.yml
+++ b/.github/workflows/link-check-external.yml
@@ -44,10 +44,18 @@ jobs:
# written. When a redirect is eventually retired it becomes a
# 404 and this check catches it the same night.
#
- # 5xx is deliberately NOT accepted. lychee retries 3x by default, which
- # absorbs a momentary blip; a 5xx that survives retries every night means
- # the host is durably broken and the link is effectively dead for an
- # agent, which is worth hearing about.
+ # 5xx is deliberately NOT accepted. lychee retries (see --max-retries
+ # below), which absorbs a momentary blip; a 5xx that survives retries
+ # every night means the host is durably broken and the link is
+ # effectively dead for an agent, which is worth hearing about.
+ #
+ # --max-retries 5 / --retry-wait-time 2, up from the 3-at-1s default.
+ # Retries fire on connection-level failures (DNS, refused, reset) but NOT
+ # on a resolved status code: a 404 is returned immediately without
+ # retrying, so raising this costs nothing on the path that matters and
+ # only buys patience for transient network trouble. The wait backs off
+ # exponentially rather than staying flat, so these numbers are a ceiling
+ # of roughly a minute for a genuinely unreachable host, not 5x2s.
#
# No --include-fragments here, unlike the offline check. posthog.com
# renders some headings client-side, so a #fragment that works in a
@@ -69,6 +77,8 @@ jobs:
-X get
--accept 200..=204,403,429
--accept-timeouts
+ --max-retries 5
+ --retry-wait-time 2
--no-progress
--exclude-path node_modules
--exclude-path dist