Skip to content

ci(docs): 把 check-doc-links 接进 CI,并修好它一直在报的那条坏链 (#3213, #3292) - #3450

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3213-docs-link-gate
Aug 6, 2026
Merged

ci(docs): 把 check-doc-links 接进 CI,并修好它一直在报的那条坏链 (#3213, #3292)#3450
yinlianghui merged 2 commits into
mainfrom
claude/issue-3213-docs-link-gate

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3213
Fixes#3292

前提复核(在未改动的 origin/main 上)

三条前提全部复现,均成立:

$ node scripts/check-doc-links.mjs
Found 1 broken docs link:
- content/docs/core/enhanced-actions.mdx -> /docs/components/form
EXIT=1
$ grep -rn "check-doc-links" .github/ package.json scripts/
package.json:42: "docs:check-links": "node scripts/check-doc-links.mjs", # 仅此一处,.github/ 下零调用

check-links.ymlon: 也确认只剩 workflow_dispatch(push / pull_request 被注释掉)。
即:一个能跑的检查器没人跑,另一个只能手动 —— 能力声明了但没被执行。

改动一:坏链改指 /docs/components/form/form(选 retarget,不补 index 页)

维护者裁决给了两个选项(补 components/form 索引页 / 改指 /docs/components/form/form,实施侧择优)。选 retarget,四条证据都指向它:

  1. 本仓约定就是分类目录不设 index。content/docs/components/ 下 9 个分类目录(basic、complex、data-display、disclosure、feedback、form、layout、navigation、overlay)全部 0 个 index 文件。补一个会是 components 树里唯一的例外。
  2. 站点自己就是这么链分类的。content/docs/components/index.md 里写的是 ### [Form Components](/docs/components/form/button)### [Basic Components](/docs/components/basic/text) —— 一律用代表页,从不链裸分类路由。
  3. form/meta.json 显式列出 pages 且不含 index,补页还得同步改 meta.json,进一步偏离约定。
  4. 读者意图对得上。 原句是 - [Form](/docs/components/form) - Form submission actions,讲的是表单提交;content/docs/components/form/form.mdx 的 frontmatter 正是 title: "Form" / description: "Form container with validation and submission handling"。是它,不是 button

路由存在性也已核对:apps/site/source.config.tsdir: '../../content/docs' + apps/site/lib/source.tsbaseUrl: '/docs',故 content/docs/components/form/form.mdx/docs/components/form/form

-- [Form](/docs/components/form) - Form submission actions++ [Form](/docs/components/form/form) - Form submission actions

改动二:ci.yml 的 docs job 加一步 Check docs links

按裁决「A 进 PR 门禁」。位置放在 Setup Node.js 之后、Turbo Cache / Install dependencies / Build Site 之前:

- name: Check docs linksif: steps.docs-changes.outputs.should_run == 'true'run: node scripts/check-doc-links.mjs
  • 脚本零依赖、零网络(只对 content/docs/existsSync 解析),不需要 install,几秒跑完 —— 契合 ci.yml 既有的「便宜的结构性检查放 install 之前」写法(参考 Verify type-check coverage 那一步的注释)。
  • 沿用该 job 的 should_run 条件,与其余步骤风格一致,也避免因别人留下的坏链把无关 PR 判红。

改动三:同步 content/docs/guide/ci-cd-pipeline.md

commandment #2(docs-driven)。该页的 ci.yml job 表格原文只写了 turbo run build,加了门禁就不再准确,已同步;并在 Link Checking 一节补一张对照表,写清两个检查器的分工(站内路由 / 外链、走不走网络、在哪跑)与两个已知缺口 —— 免得读者从这一页得出「文档链接已被完全守住」的结论。

说明:该页还有一处既存漂移(写「Seven jobs」并列了一个 ci.yml 里并不存在的 dev-server job,实际 6 个)。本 PR 没有顺手改,已另立 #3451

⚠️ 已知缺口(请勿据此认为文档链接已被完全守住)—— 已记为 #3448

ci.ymlon:pushpull_request 两处都带 paths-ignore,其中含 content/**'**/*.md';GitHub 的 paths-ignore 是「改动文件全部命中则整个 workflow 不启动」,而且没有 per-job path filter

于是:

PR / push 类型本步骤是否运行
纯 docs PR(只改 content/**)❌ workflow 根本不启动
docs + 代码混合 PR
push 到 main(含非忽略路径)

最可能改坏链接的那类 PR(纯 docs)恰恰不被覆盖。

仓库里已有针对同一堵墙的正确答案 —— control-bytes.yml 的文件头:

Why this is its own workflow instead of a job in ci.yml or lint.yml: both of those list '**/*.md', content/**, docs/** and .changeset/** under paths-ignore, and GitHub has no per-job path filter. (…) a gate that cannot see a markdown-only PR rebuilds the hole it exists to close.

check-doc-links.mjs 与 control-bytes 属于完全相同的类别(守 markdown/content、零 install、零网络)。推荐后续按 #3448 的方案 A 把它挪成独立 workflow。 本 PR 没有自行这么做:裁决明确写的是「加进 ci.yml 的 docs 任务」,且改 workflow 触发策略属于 CI 成本决策,应由维护者拍板。缺口已写进该步骤上方的注释,不留给下一个人踩。

check-links.yml(Lychee):维持 workflow_dispatch,有意未加 cron —— 已记为 #3449

裁决说 cron 可选、有疑虑就跳过。这里有实证疑虑:Lychee 扫的是 docs/**/*.md(x) + README.md,而站点文档在 content/docs/**

$ find docs -name '*.md' -o -name '*.mdx' | wc -l
15 # ARCHITECTURE.md、adr/、audits/ 等内部资料
$ find content/docs -name '*.md' -o -name '*.mdx' | wc -l
183 # 实际发布的站点文档

范围修正之前加 cron,只会按时产出一份「检查了内部 ADR、没检查站点文档」的绿报告 —— 加噪声不加覆盖,还会让人误以为已发布文档的外链有人守。建议先修范围再谈 cron。

验证

反向验证(方向预判:恢复坏链 → 检查器转红)。 预判与实测一致:

# 改前(pristine origin/main)
$ node scripts/check-doc-links.mjs
Found 1 broken docs link:
- content/docs/core/enhanced-actions.mdx -> /docs/components/form
EXIT=1
# 改后
$ node scripts/check-doc-links.mjs
Docs links are valid.
EXIT=0

新步骤在本 PR 上真实跑过,且是 executed 而非 skipped —— 取 Build Docs job 的 steps(commit 3c83a59):

6 Setup Node.js success
7 Check docs links success 05:24:45 -> 05:24:45
8 Turbo Cache success
...
10 Build Site success
Job "Build Docs": conclusion = success

(skipped 的步骤 conclusion 会是 skipped,这里是 success,所以确实执行了。本 PR 能触发 ci.yml 是因为它改了 .github/workflows/ci.yml,该路径不在 paths-ignore 里;docs-changes 又因 content/ 有改动而置 should_run=true。)

其余:

  • ci.yml YAML 解析通过(容器内无 actionlint,改用 python3 -c "yaml.safe_load(...)");docs job 步骤顺序为 Checkout code → Check for docs changes → Enable Corepack → Verify pnpm version → Setup Node.js → Check docs links → Turbo Cache → Install dependencies → Build Site
  • 钉住被改文档的测试:pnpm exec vitest run scripts/__tests__/ --maxWorkers=28 passed (8) / 120 passed (120),其中 ci-cd-pipeline-doc.test.ts 9 条全绿(它正是钉 ci-cd-pipeline.md 的那个文件)。
  • 控制字节自查:node scripts/check-control-bytes.mjsOK (scanned 3640 tracked text file(s); skipped 85 binary);另对三个改动文件单独 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' → 无命中。
  • Build Docs 已在本 PR 上完整构建站点(556 静态页)成功,顺带验证改后的链接在真实路由下成立。

说明

scripts/check-doc-links.mjs 早就存在且能用,但 .github/ 下零调用 —— 它在未
改动的 origin/main 上就 exit 1,报 content/docs/core/enhanced-actions.mdx ->
/docs/components/form 断链,却从来没有工作流跑过它。
两处改动:
1. 坏链改指 /docs/components/form/form(retarget,不补 index 页)。
content/docs/components/ 下 9 个分类目录全部没有 index 页,补一个会是
components 树里唯一的例外;components/index.md 自己也是用代表页链到分类
(如 /docs/components/form/button),从不链裸分类路由;form/meta.json 显式
列出 pages 且不含 index。而该句 "- [Form](...) - Form submission actions"
指向的正是 form/form.mdx(title: Form,description: Form container with
validation and submission handling),不是 button。
2. ci.yml 的 docs job 加一步 Check docs links,放在 Setup Node.js 之后、
install 与站点构建之前 —— 脚本零依赖零网络,坏链几秒内失败,不必等一次
完整的 Next.js 构建。
已知缺口(不在本次裁决范围,已记为 #3448):ci.yml 的 paths-ignore 含
content/**,GitHub 又没有 per-job path filter,所以纯 docs PR 根本不会启动这个
workflow。control-bytes.yml 撞过同一堵墙并靠独立 workflow 解决,文件头写明了
理由。本步骤覆盖 docs+代码混合 PR 与 push to main。
check-links.yml(Lychee)按裁决维持 workflow_dispatch;有意未加 cron —— 它扫的
是 docs/**(15 个内部文件)而非 content/docs/**(183 个站点文档),范围修正前
加 cron 只会按时产出一份不覆盖站点文档的绿报告(已记为 #3449)。
验证:改前 node scripts/check-doc-links.mjs -> exit 1(1 broken link);
改后 -> exit 0 "Docs links are valid."
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 6, 2026 5:29am

Request Review

commandment #2(docs-driven):ci.yml 的 docs job 多了一步 Check docs links,
该页的 job 表格原文只写了 turbo build,已同步。
另在 Link Checking 一节补一张对照表,写清两个检查器各管什么:
check-doc-links.mjs 管站内 /docs/... 路由、不走网络、在 ci.yml 里跑;
Lychee 管外链、走网络、只能手动触发。并写明两个已知缺口(#3448 纯 docs PR
不触发 ci.yml;#3449 Lychee 扫的是 docs/ 而非 content/docs/),以免读者从这一页
得出「文档链接已被完全守住」的结论。
未改该页既有的其它内容(如 job 数量与 dev-server 行的既存漂移),另行记录。
验证:pnpm exec vitest run scripts/__tests__/ -> 8 files / 120 tests passed
(含 ci-cd-pipeline-doc.test.ts 的 9 条,它正是钉这一页的)。
@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 05:43
@yinlianghui
yinlianghui added this pull request to the merge queueAug 6, 2026
Merged via the queue into main with commit 223a92fAug 6, 2026
16 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3213-docs-link-gate branch August 6, 2026 05:43
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 6, 2026
…ctstack-ai#3448) (objectstack-ai#3458)
`check-doc-links.mjs` 由 PR objectstack-ai#3450 接进 CI 时,落点是 `ci.yml` 的 `docs` job ——
而 `ci.yml` 的 `paths-ignore` 列着 `'**/*.md'`、`content/**`、`docs/**`、
`apps/site/**`。GitHub 的 `paths-ignore` 语义是「改动文件全部命中即整个 workflow
不启动」,且没有 per-job path filter;站点文档全在 `content/docs/**`。所以**只改
文档的 PR 根本不会启动 ci.yml**,这道门禁看不见的恰恰是最可能改坏内链的那一类
PR。它此前只覆盖「文档+代码」的混合 PR 和 push 到 main —— 坏链能经纯 docs PR 合
进 main,直到下一个无关作者推代码时才把 main 弄红,归因还错人。
`control-bytes.yml` 撞过同一堵墙,头注写着结论:看不见 markdown-only PR 的门禁
"rebuilds the hole it exists to close";`changeset-guard.yml` 是同一形状的第二例。
本次是第三例。
改动:
• 新增 `.github/workflows/docs-links.yml`,镜像 control-bytes.yml 的形状:
push/PR to main+develop + workflow_dispatch,**无 paths / 无 paths-ignore**,
checkout + setup-node + 一行 `node scripts/check-doc-links.mjs`,无 install
无网络。头注写清为什么不能加路径过滤。
• **从 ci.yml 的 docs job 删掉重复步骤**(最小重复原则):新 workflow 的触发集
是该 job 的严格超集,留着只会为同一条坏链多出一个红勾和一处会忘记同步的副本。
原地留注释说明它去哪了、为什么别加回来。
• 新增 `scripts/__tests__/docs-links-workflow.test.ts` 钉住形状:workflow 必须
存在、必须门禁 PR、必须既无 `paths` 也无 `paths-ignore`、必须是**唯一**跑该
脚本的 workflow;并以不变式表述「跑这个脚本的 workflow 必须是纯 docs PR 能
启动的」,即使将来改名搬家也成立。扫描前先剥掉整行注释 —— ci.yml 的说明注释
里仍然提到脚本名,不剥会误判成重复门禁。
• `content/docs/guide/ci-cd-pipeline.md`:这一条不是顺手改文档,是
`scripts/__tests__/ci-cd-pipeline-doc.test.ts` 的机械要求 —— 只加 workflow
不加章节,该测试立刻红,报「docs-links.yml 无对应标题」。补:清单表一行、新章节
(含为什么无路径过滤)、ci.yml 任务表里 docs 行的更正(它不再跑链接检查)、
以及 Link Checking 章节里 objectstack-ai#3448 那个「已知缺口」的收口。
反向验证(方向先判后跑):把删掉的步骤加回 ci.yml —— 新测试如期两处红:
"is the only workflow that runs the link checker"(ci.yml + docs-links.yml)与
"every workflow that runs it is one a docs-only PR can start"(ci.yml 有
paths-ignore)。给 docs-links.yml 加 `paths: content/**` —— "carries NO path
filter" 如期红。
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…t path filtering into the jobs (objectstack-ai#3523 steps 1-2) (objectstack-ai#3722)
* ci(step 1): subscribe ci/lint/control-bytes/docs-links to merge_group (objectstack-ai#3523)
Step 1 of objectui#3523, on its own commit as the issue's ruling requires:
this is a pure addition and changes nothing about which pull requests or
pushes run CI.
The merge queue is enforced on this repository by a ruleset (objectstack-ai#3243 measured a
direct push to `main` returning 405 `Changes must be made through the merge
queue`), but not one of the 22 workflows subscribed `merge_group` — repo-wide
`event=merge_group` runs stood at total_count = 0. A queue nothing subscribes
to can only carry an empty required-check set, so it rebuilt each PR on the
current `main` and let it through without validating anything. On 2026-08-07
that was cashed in: objectstack-ai#3503 / objectstack-ai#3510 / objectstack-ai#3516 merged between 02:11Z and 02:15Z
with `Type Check` at conclusion=failure, on a `main` poisoned by objectstack-ai#3498, and
objectstack-ai#3505 had to hot-fix it.
The four workflows named by the issue now carry the trigger. It is spelled
`merge_group: types: [checks_requested]`; `checks_requested` is the only
activity type GitHub defines for this event today, so this is equivalent to
objectstack's bare `merge_group:` and merely refuses to inherit a second type
added later.
Two `ci.yml` jobs additionally needed to be told the third event exists —
both changes are no-ops for `pull_request` and `push`:
- `test` moves from `if: github.event_name == 'pull_request'` to
`!= 'push'`, so a queue build actually runs the suite instead of skipping
every shard.
- `docs`'s `should_run` treated anything that is not `push` as a pull request
and diffed `github.event.pull_request.*`, which is null on a queue build —
an empty revision range, i.e. the site build silently skipped on the last
check before `main`.
Verified rather than assumed: `concurrency` needs no merge-queue case. On
`merge_group` the group expression falls back to `github.ref`, which is the
queue's own generation — measured on objectstack, whose 3552 queue runs report
head_branch `gh-readonly-queue/main/pr-6594-251e888a…`. That can collide with
neither a PR group (a bare number) nor a push group (`refs/heads/main`).
Refs objectstack-ai#3523
* ci(step 2): move ci/lint path filtering out of the PR trigger into the jobs (objectstack-ai#3523)
Step 2 of objectui#3523, deliberately a separate commit from step 1: step 1
adds runs that did not exist, this one changes which pull requests start CI,
and mixing them would make that impossible to review apart.
`on.pull_request.paths-ignore` skips the WHOLE workflow when every changed
file matches, and GitHub has no per-job path filter. A docs-only or
changeset-only PR therefore started neither `ci.yml` nor `lint.yml` — objectstack-ai#3509
measured zero check runs from them. A check that is never *created* does not
fail a required-status-check rule, it leaves the pull request pending; inside
the merge queue it fails on the ruleset's 60-minute status-check timeout. So
none of `Lint`, `Type Check`, `Test (shard N/4)`, `Build & E2E` or
`Changeset Fixed Group Check` could be made required while the filter lived
on the trigger — which is why the queue's required set was empty to begin
with.
The filter is not deleted, it moves. `type-check`, `test`, `e2e` (ci.yml) and
`lint` (lint.yml) each open with a `Decide whether this change needs a full
run` step, and every step after it carries
`if: steps.relevant.outputs.should_run == 'true'`. The job always runs and
always reports; the paths decide only whether it does any work. That is the
shape `ci.yml`'s `docs` job has used since objectstack-ai#3450 — not a new mechanism — and
the exclusion lists are byte-for-byte the `paths-ignore` they replace, so
which PRs pay for a full run is exactly as before.
Two deliberate narrowings, both stated so they can be argued with:
- `paths-ignore` stays on the `push` trigger. Branch protection and the merge
queue judge pull requests and queue builds, never pushes to `main`, so the
push lane gains nothing from losing it and would cost a full CI run on
every docs merge. It also gives the ignore list one authored home, which
`merge-queue-reporting.test.ts` then pins the in-job copies against.
- `changeset-check` is not gated. It is a checkout and one `node` call, so
short-circuiting it would cost more in complexity than it saves.
The gate fails OPEN: an unresolvable diff runs everything rather than
reporting green having built nothing (objectstack#4928's filter contract).
Measured both ways against a fixture repository — as shipped an unreachable
base sha yields should_run=true; rewritten with the `|| echo ""` spelling
`ci.yml`'s older `docs` gate still uses, the same input yields
should_run=false, i.e. a silent full skip. That pre-existing `docs` gate is
reported separately rather than changed here.
`scripts/__tests__/merge-queue-reporting.test.ts` pins both steps; against
the pre-change workflows 9 of its 10 assertions go red, each naming its own
regression. `lint-workflow.test.ts`'s TypeScript-exclusion tripwire now reads
the in-job list as well as the trigger, which is where that list now lives.
Refs objectstack-ai#3523
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude