Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion .github/workflows/check-links.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
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.
#
# The scan scope below (`args`) was wrong until #3449: it listed only the
# repo-root `docs/**`, which holds 15 INTERNAL documents (ADRs, audits), while
# the 183 files the published site is built from live in `content/docs/**`
# (`apps/site/source.config.ts`: `dir: '../../content/docs'`, baseUrl `/docs`).
# This workflow had therefore never scanned a single published page — it was
# green because of what it was not looking at.
# `scripts/__tests__/check-links-workflow.test.ts` pins the scope to the
# directory the site config itself declares: if the content tree moves again
# that test turns red, instead of this workflow silently going blind 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
Expand All @@ -17,11 +56,20 @@ jobs:
- name: Check links with Lychee
uses: lycheeverse/lychee-action@v2
with:
# Scan all markdown files in docs directory and README
# 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'
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/docs-links.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ jobs:
# `content/docs/**` against the files actually on disk. Reads the checkout
# and nothing else, so no install is required. External URLs are a
# different problem with a different tool — Lychee, in `check-links.yml`,
# which is manual-dispatch only and scans `docs/` rather than
# `content/docs/` (#3449).
# which sweeps both `content/docs/` and `docs/` on a weekly cron and on
# demand, and deliberately gates nothing (#3449 fixed its scope; #3213
# decided it stays off pull requests).
- name: Check internal docs links
run: node scripts/check-doc-links.mjs
33 changes: 21 additions & 12 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ one has its own section below.
| `changelog.yml` | Auto Changelog | GitHub Release published; manual | n/a |
| `stale.yml` | Stale Issues & PRs | Daily cron `0 0 * * *`; manual | n/a |
| `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a |
| `check-links.yml` | Check Links | Manual dispatch only | n/a |
| `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates |

The path filters explain most "why did nothing run on my PR?" questions:

Expand Down Expand Up @@ -280,29 +280,38 @@ checked as *routes*, so `/docs/guide/foo` is what belongs in the markdown, not

## Link Checking (`check-links.yml`)

**Trigger:** Manual workflow dispatch (`workflow_dispatch`).
**Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run
queue is shortest) plus manual workflow dispatch.

There are **two** link checkers, and they cover different things (objectui#3213):

| | Covers | Network | Runs |
|---|---|---|---|
| `scripts/check-doc-links.mjs` | **Internal** `/docs/...` routes, resolved against `content/docs/` | No | `docs-links.yml` — every push and PR, no path filter (previous section) |
| Lychee (this workflow) | **External** URLs in `docs/` and `README.md` | Yes | Manual dispatch only |
| Lychee (this workflow) | **External** URLs, plus **relative** in-repo file links, in `content/docs/`, `docs/` and `README.md` | Yes | Weekly cron and manual dispatch |

Note the asymmetry in what Lychee scans: `docs/` holds internal material (ADRs, audits,
architecture notes), while the published site is built from `content/docs/`. Lychee therefore does
not currently see the site's own pages.
Lychee sweeps **both** documentation trees: `content/docs/` (the 183 pages the site publishes) and
the repo-root `docs/` (15 files of internal material — ADRs, audits, architecture notes) plus
`README.md`. Until objectui#3449 it scanned only the latter, so no published page had ever been
link-checked; the workflow was green about a tree almost nobody reads.
`scripts/__tests__/check-links-workflow.test.ts` now derives the expected scope from
`apps/site/source.config.ts`, so moving the content tree turns that test red instead of quietly
blinding the sweep again.

One known gap remains tracked rather than silently lived with: Lychee's scan scope predates the
move to `content/docs/` (objectui#3449), so **external** URLs on the published site's own pages are
checked by nothing. The gap that used to sit beside it — docs-only PRs never being link-checked,
because `ci.yml` ignores `content/**` — is closed: that check is now `docs-links.yml` (objectui#3448).
It is deliberately **not** a PR gate (objectui#3213). External link checking goes over the network,
and one 502 or rate-limit from a third-party site would redden a pull request whose author can do
nothing about it. The cron was added only once the scope was correct: a schedule pointed at the
wrong tree just produces a false-green report on a timer.

Uses [Lychee](https://github.com/lycheeverse/lychee) with configuration from `lychee.toml`:
- Scans markdown files in `docs/` and `README.md`
- Scans `content/docs/**/*.{md,mdx}`, `docs/**/*.{md,mdx}` and `README.md`
- Max concurrency: 10, timeout: 20s, retries: 3
- Excludes: localhost, example.com, Twitter/X, GitHub compare/commit URLs
- Remaps internal `/docs/*` paths to `file://./docs/*` for local resolution
- Skips **site-absolute** routes (`/docs/...`, `/api/...`): Lychee cannot resolve extensionless
fumadocs routes, and without handling it fails them while building the URI — before `exclude` is
even consulted. `root_dir` therefore resolves them into a sentinel namespace that is then
excluded wholesale. Judging those routes is `check-doc-links.mjs`'s job, and duplicating its
route-to-file mapping here would only create a second copy free to drift.

## Release Workflows

Expand Down
63 changes: 50 additions & 13 deletions lychee.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Lychee configuration file for ObjectUI documentation link checking
# This configuration enables checking internal fumadocs links by mapping
# Next.js routes (e.g., /docs/guide) to filesystem paths (e.g., docs/guide.mdx)
# (used by `.github/workflows/check-links.yml` — schedule + workflow_dispatch).
#
# Lychee checks two classes of link here: EXTERNAL URLs, and RELATIVE in-repo
# file links (`docs/**` links into `../../packages/**` source files rely on
# that). Site-ABSOLUTE routes — `/docs/...`, `/api/...`, `/protocol/...` — are
# not its job; those belong to `scripts/check-doc-links.mjs`, run by
# `docs-links.yml`. See the `root_dir` section below.

# Maximum number of concurrent requests
max_concurrency = 10
Expand All @@ -19,29 +24,61 @@ exclude = [
# Local development URLs
"http://localhost*",
"https://localhost*",

# Example and placeholder URLs
"https://example.com",
"http://example.com",

# Social media (anti-scraping)
"https://twitter.com*",
"https://x.com*",

# GitHub specific patterns that may cause false positives
"https://github.com/.*/compare/*",
"https://github.com/.*/commit/*",
]

# Path remapping for fumadocs internal links
# Maps Next.js routes to actual file paths
# Format: ["pattern to match", "replacement"]
remap = [
# Map /docs/* links to docs/ filesystem paths
# The file:// prefix tells lychee to check local files
"^/docs/(.*)$ file://./docs/$1"
# Site-absolute routes, resolved into the sentinel namespace by `root_dir`
# below and skipped wholesale here. Rationale: see `root_dir`.
"^file:///__site-routes-not-on-disk__/",
]

# Why site-absolute routes (`/docs/...`) are handled this way
#
# When a local file contains an absolute-path link, Lychee without a `root_dir`
# reports a HARD error:
# [ERROR] Cannot resolve root-relative link '/docs/plugins':
# To resolve root-relative links in local files, provide a root dir
# and that failure happens while the URI is being constructed, BEFORE the
# exclude filter runs — adding `^/docs` or `^/` to `exclude` is measurably a
# no-op: the errors are still reported and the exit code is still 2.
# `content/docs/**` contains 297 such links, so once #3449 widened the scan to
# `content/docs`, leaving them unhandled would make this workflow red from its
# very first run — trading a false-green report on the wrong tree for a
# false-red one on the right tree, which is no better.
#
# So: `root_dir` resolves site-absolute routes into a sentinel namespace that
# does not exist on disk, and the exclude entry above skips that namespace
# entirely. `root_dir` must be an absolute path, and the repository's absolute
# path differs between a local checkout and a runner, so it can only be a
# repo-independent constant — the sentinel is forced by that constraint, not
# chosen for cuteness. Expect one line of output per run:
# [WARN] Root dir '/__site-routes-not-on-disk__' does not exist
# That is intended; the directory is not supposed to exist, and its name is the
# explanation.
#
# Why not have Lychee resolve these routes properly instead: fumadocs routes
# carry no extension (`/docs/guide/data-source` -> `content/docs/guide/
# data-source.md`), so judging them needs a real route-to-file mapping. That
# mapping already exists — `scripts/check-doc-links.mjs` — and a second copy
# here would only be a second copy free to drift from the first.
#
# (This section replaced `remap = ["^/docs/(.*)$ file://./docs/$1"]`, which had
# never once fired: `remap` operates on an already-parsed URL, and `/docs/x`
# fails to parse before it ever reaches remapping. Its replacement target was
# wrong twice over anyway — no extension, and pointing at the internal `docs/`
# rather than the site's `content/docs/`.)
root_dir = "/__site-routes-not-on-disk__"

# Cache configuration
# Don't use cache to ensure fresh results
cache = false
Loading
Loading