From bcc280c61054c32780f530ba795efa8de30d94cc Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:33:49 +1000 Subject: [PATCH] ci: harden the link check so it fails only on real breakage Follow-up to the --base . repair. Verified against lychee 0.24.2 locally before pushing this time; all four repos report 0 errors with these exact arguments. - --accept REPLACES lychee's default accept set rather than extending it, so the inherited '--accept 200,206,429' silently made 201/202/204/207/226 count as broken links. Proven with httpbin: a 204 passes by default and is rejected under the old flag. Now restates the default set plus 429. - --accept-timeouts is the purpose-built flag for "fail on broken, tolerate slow". lychee counts a TIMEOUT as an error and exits 2, which is how a run reporting "Errors 0" failed a PR on a slow tauri.app. Confirmed: a lone timeout now exits 0, a 404 still exits 2. - --cache-exclude-status stops a transient 429/5xx being cached as a failure and replayed for 24h, which re-running the job does not clear. - --max-concurrency 32 instead of the default 128 from a single runner IP. - The path filters now include the workflow itself. Omitting it is why --base . shipped and survived three weekly crons: a PR editing only the link check did not run the link check. - The issue step is gated on lychee's own exit code, not on the job failing, so a checkout or 'npm run check' failure can no longer file an issue titled "Broken links found". It also reuses one issue instead of filing a fresh one every failing cron. - package.json still shipped the broken 'lychee --base .' in the linkcheck script; the workflow was fixed in the last pass and the script was not. Co-Authored-By: Claude Fable 5 --- .github/workflows/linkcheck.yml | 62 ++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 2aab5f4..d711502 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -8,7 +8,7 @@ on: - "docs.json" - "package.json" - "scripts/**" - - "api-reference/openapi.yaml" + - ".github/workflows/linkcheck.yml" push: branches: [main] paths: @@ -17,7 +17,7 @@ on: - "docs.json" - "package.json" - "scripts/**" - - "api-reference/openapi.yaml" + - ".github/workflows/linkcheck.yml" schedule: # Catch external-link rot weekly even when no docs change. - cron: "0 6 * * 1" @@ -27,11 +27,14 @@ jobs: lychee: name: lychee runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read issues: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Run docs smoke check run: npm run check @@ -45,24 +48,39 @@ jobs: # External links only (--scheme): internal links and anchors are covered by # `npm run check` above, and extensionless Mintlify paths are not files. - # `--base .` is rejected by current lychee (base must be a URL or absolute + # `--base .` is rejected by current lychee (base must be a URL or an absolute # path); --root-dir resolves root-relative links instead. - # dash.cloudflare.com bot-blocks CI with 403 — excluded rather than accepting - # 403 globally, which would mask genuinely forbidden links. - # localhost/loopback URLs are documentation examples, not live links. - # Generous timeout/retries: lychee exits non-zero on a timeout as well as a - # broken link, so a slow third-party host would otherwise block every PR. - # docs.[a-z]+.privacykey.org: no docs domain has DNS yet; drop this exclusion once they resolve + # + # --accept must restate lychee's DEFAULT accept set, because the flag REPLACES + # it rather than extending it. Passing a bare `--accept 200,206,429` silently + # made 201/202/204/207/226 count as broken links. + # + # --accept-timeouts is the purpose-built flag for "fail on broken, tolerate + # slow": lychee otherwise counts a TIMEOUT as an error and exits non-zero, so + # one slow third-party host fails the job while reporting "Errors 0". + # + # --cache-exclude-status stops a transient 429/5xx being cached as a failure + # and replayed for the next 24h, which re-running the job does not clear. + # + # Excludes, each for a reason that is not link rot: + # loopback/localhost documentation examples, not live links + # dash.cloudflare.com bot-blocks CI with 403; excluded rather than accepting + # 403 globally, which would mask real Forbidden results + # docs.*.privacykey.org no docs domain has DNS yet — DROP THIS once they resolve - name: Run lychee + id: lychee uses: lycheeverse/lychee-action@v2 with: args: >- --cache --max-cache-age 1d + --cache-exclude-status '429,500..=599' --no-progress --timeout 30 --max-retries 5 --retry-wait-time 3 + --accept-timeouts + --max-concurrency 32 --scheme https --scheme http --root-dir ${{ github.workspace }} @@ -71,17 +89,37 @@ jobs: --exclude-loopback --exclude 'localhost' --exclude 'dash\.cloudflare\.com' - --exclude 'docs\.[a-z]+\.privacykey\.org' - --accept 200,206,429 + --exclude 'docs\.([a-z0-9-]+\.)?privacykey\.org' + --accept '100..=103,200..=299,429' './**/*.mdx' './**/*.md' fail: true jobSummary: true - - name: Open issue on schedule failure + # Reuse one issue instead of filing a fresh one every failing cron. + - name: Find the existing link-check issue if: failure() && github.event_name == 'schedule' + id: existing + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + number=$(gh issue list --state open --limit 50 \ + --search 'Broken links found by scheduled link check in:title' \ + --json number --jq '.[0].number // empty') + echo "number=$number" >> "$GITHUB_OUTPUT" + + # Gated on lychee's own exit code, not on the job failing: a checkout or + # `npm run check` failure must not file an issue titled "Broken links found". + # An empty exit_code means lychee never ran, so this is skipped. + - name: Report broken links found by the scheduled run + if: >- + failure() && github.event_name == 'schedule' + && steps.lychee.outputs.exit_code != '' + && steps.lychee.outputs.exit_code != '0' uses: peter-evans/create-issue-from-file@v5 with: + issue-number: ${{ steps.existing.outputs.number }} title: "Broken links found by scheduled link check" content-filepath: ./lychee/out.md labels: docs, broken-links diff --git a/package.json b/package.json index ccc1b02..90ef7db 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "npx --yes mint dev", "build": "npx --yes mint@latest export && rm -rf dist && mkdir -p dist && (unzip -q -o export.zip -d dist || python3 -m zipfile -e export.zip dist) && cp .assetsignore dist/.assetsignore", "check": "node scripts/check-docs.mjs", - "linkcheck": "lychee --base . './**/*.mdx' './**/*.md'" + "linkcheck": "lychee --no-progress --accept-timeouts --accept '100..=103,200..=299,429' --root-dir \"$PWD\" --scheme https --scheme http --exclude-loopback --exclude 'localhost' --exclude 'dash\\.cloudflare\\.com' --exclude 'docs\\.([a-z0-9-]+\\.)?privacykey\\.org' --exclude-path images --exclude-path .github './**/*.mdx' './**/*.md'" }, "engines": { "node": ">=18"