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