Skip to content

Check Links

Check Links #589

Workflow file for this run

name: Check Links
# Lychee is the EXTERNAL link checker. It makes real network requests and is
# flaky by nature. Site-internal routes (`/docs/...`) are a different problem
# with a different tool — `scripts/check-doc-links.mjs`, run by
# `docs-links.yml`, which reads the checkout and nothing else and is therefore
# the one that gates pull requests. The two are complementary, not redundant;
# do not move either one's job onto the other.
#
# SCAN SCOPE — stated as populations, never counted (objectui#7448,
# objectui#7825). What gets swept is decided by the `args` glob list below and
# by nothing else: the published documentation tree `content/docs/**` (the
# fumadocs content source `apps/site/source.config.ts` declares —
# `dir: '../../content/docs'`, baseUrl `/docs`), the repo-root INTERNAL tree
# `docs/**` (ADRs, audits), and `README.md`.
#
# ⛔ Never write the size of either tree back into this comment. Two hand-copied
# sizes stood here, one per tree, and both had drifted by the time anyone
# looked, with nothing red over the whole distance — nothing fails on a stale
# number written in a comment, which is exactly why it rots. Point at the
# reading instead: `find content/docs docs -type f \( -name '*.md' -o -name
# '*.mdx' \) | wc -l` in any checkout, and Lychee's own run summary, which
# reports what it actually scanned. `check-links-workflow.test.ts` enforces
# this — it reds on the next numeral that qualifies a document population
# anywhere in this header.
#
# HISTORY, past tense on purpose — none of the following describes the scope
# today. Until PR #3449 the `args` list named only the repo-root `docs/**`, so
# not one published page had ever been scanned: this workflow was green because
# of what it was not looking at. #3449 pointed it at both trees, and
# `scripts/__tests__/check-links-workflow.test.ts` derives the expected scope
# from the site config rather than restating it, so moving the content tree
# turns that test red instead of blinding the sweep a second time.
on:
workflow_dispatch:
# Weekly sweep, Sundays at 04:17 UTC.
#
# The PR that fixed #3213 deliberately did NOT add a cron, and it was right to
# hold off: with the scope pointed at the wrong tree, a schedule only turns
# one false-green report into a periodically produced false-green report. Now
# that the scope is correct, #3213's ruling-B intent — a periodic sweep — is
# real, so the schedule lands in the same change that makes it meaningful.
#
# Timing: off the top of the hour, because GitHub's scheduled-workflow queue
# is most congested (and most delayed) at :00; Sunday because that is when the
# repo contends least for runners.
schedule:
- cron: '17 4 * * 0'
# ⛔ Do NOT enable the two triggers below — #3213's ruling B still stands.
# An external link check goes over the network: one 502, rate-limit or
# anti-scraping response from a third-party site turns an unrelated pull
# request red, and its author can do nothing about it. `schedule` +
# `workflow_dispatch` do not have that problem: this workflow blocks nobody,
# so flakiness costs a second look and nothing else.
# push:
# branches:
# - main
# pull_request:
jobs:
check-links:
runs-on: ubuntu-latest
# ── The job ceiling, DERIVED FOR THIS JOB (objectui#7956) ────────────
# Before this line the only backstop was GitHub's 360-minute default, on the
# one job in this repository that deliberately goes out over the network.
#
# ⚠️ The stakes here are NOT objectui#7270's. That card's argument leaned on
# two of its three jobs producing a REQUIRED context on a shared serial
# queue. Re-measured on `GET /repos/objectstack-ai/objectui/rules/branches/
# main` at 2026-09-06T17:20Z, the required checks are `Lint`, `Type Check`,
# `Build & E2E`, `Test (shard 1/4)` through `(4/4)`, `Build Docs` and
# `Changeset Declaration` — this job produces none of them, and the header
# above says it blocks nobody on purpose. What is left is the generic
# exposure: a sweep wedged on an unresponsive host holds a runner for six
# hours and then reports `cancelled`.
#
# ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40, nor from the ceilings
# objectui#7270 derived — objectui#7048 fences exactly that. The arithmetic
# below is this job's own.
#
# ⚠️ THE SAMPLE HAS TO BE CHOSEN, and the obvious choice is the wrong one.
# `status=success` returns a `total_count` of 217 for this workflow, and
# every single one of those runs is from 2026-01-24 .. 2026-01-28 on `push`
# and `pull_request` — triggers this workflow no longer declares, under the
# narrower scan scope it had before #3449. Those runs measure a different
# job wearing this job's name, and deriving from them would put the ceiling
# under today's honest slowest run, which is the hazard objectui#7048
# fenced. They are recorded here and NOT used.
#
# - Population: every run under today's configuration —
# `?event=schedule`, whose `total_count` is 5, window 2026-08-09T04:43Z
# .. 2026-09-06T04:28Z. That is the entire population since the cron
# landed, not a sample cut short; paging further buys the January runs
# described above. Per-job wall clock from the Actions jobs endpoint
# (`completed_at` minus `started_at`), never the run's total.
# - min 15s / median 24s / p95 30s / max 31s.
# - max/p95 = 1.03 — no fat tail inside the sample.
# - ⚠️ All five ended `failure`, and that does NOT make them unusable: the
# step conclusions show Lychee running 9s .. 24s and the job reaching its
# post steps every time, so the failure is the sweep's own verdict
# (`fail: true`, broken links found), not an abort partway through the
# work. It is also the CONSERVATIVE side of the measurement — a link
# that cannot be resolved is retried (`max_retries` in `lychee.toml`),
# so a sweep that finds breakage does strictly more work than a clean
# one. The max above therefore over-states a healthy run rather than
# under-stating it.
# - ⚠️ That every scheduled sweep so far has been red is a defect in its
# own right and is filed as objectui#8126's sibling, objectui#8128. It
# is deliberately NOT fixed by the same change that sets this ceiling:
# the ceiling is about what happens when a sweep wedges, and the red is
# about what the sweep found.
#
# Ceiling = the smallest round number that is both >= 3x max (1.6min) and
# >= max + 15min (15.5min) => 20. That is ~39x the slowest sweep observed
# under this configuration; a wedge dies in 20 minutes instead of 6 hours.
#
# ⚠️ The residual this does NOT bound, named rather than hidden:
# `lychee.toml` declares a per-request `timeout` and `max_retries` at a
# `max_concurrency` of 10, so a whole-run worst case scales with how much
# this workflow sweeps — and that size is exactly the number the header
# above forbids writing down, because it rots (objectui#7448,
# objectui#7825). The additive 15 minutes is where that residual is
# absorbed, not a proof against it. If a network-weather run ever does hit
# this ceiling, the cost is a `cancelled` weekly sweep that blocks nobody —
# which the header already accepts as this workflow's failure currency.
#
# ⚠️ Four of the six jobs objectui#7956 bounded land on 20 by this same
# arithmetic. That is NOT one number copied across them, which precondition
# ② of that card's triage forbids: for any job whose slowest run is under
# 7.5 minutes the additive term dominates the multiplicative one, so the
# rule puts every sub-minute job on the same rung. Each of the four carries
# its own sample and its own derivation beside its own key.
#
# ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
# objectui#7048): the hang just runs longer and the gate still reports
# `cancelled`.
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Check links with Lychee
uses: lycheeverse/lychee-action@v2
with:
# Both trees:
# content/docs/** — the published site's documentation (the fumadocs
# content source). Never scanned before #3449; the whole point.
# docs/**, README.md — the repo-root internal material (ADRs,
# audits). Their external links are worth sweeping too and cost
# nothing extra, so they stay.
# Site-absolute routes (`/docs/...`) are NOT judged here — see the
# `root_dir` section of lychee.toml.
args: |
--verbose
--no-progress
--config lychee.toml
'content/docs/**/*.md'
'content/docs/**/*.mdx'
'docs/**/*.md'
'docs/**/*.mdx'
'README.md'
# Fail the action on broken links
fail: true
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}