Skip to content

ci(docs): fail OPEN when the Build Docs path gate cannot compute its diff (#3723) - #3744

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3723-docs-gate-fail-open
Aug 8, 2026
Merged

ci(docs): fail OPEN when the Build Docs path gate cannot compute its diff (#3723)#3744
yinlianghui merged 1 commit into
mainfrom
claude/issue-3723-docs-gate-fail-open

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3723

问题

ci.ymldocs job(Build Docs)用这一句决定要不要构建站点:

CHANGED=$(git diff --name-only BASE...HEAD -- 'apps/site/' 'content/' 2>/dev/null || echo "")

2>/dev/null || echo "" 把两件完全不同的事压成同一个空串:

  • diff 成功、apps/site/content/ 下确实没变 —— 该跳过,正确;
  • diff 根本算不出来 —— checkout 拉得不够深、git 偶发失败、sha 畸形 —— 同样得到空串,于是整个站点构建被跳过,而 job 依然报 success:没有红步骤,没有警告,summary 里也没有任何痕迹。check 说文档构建过了,实际什么都没构建。

objectstack#4928 把这条命名为 filter 契约:filter 拿不准的时候必须 RUN。PR #3722 给本 workflow 新加的四个门禁(type-check / test / e2e / lint)全是 fail-open 拼法,这一处成了唯一的例外。

改法(.github/workflows/ci.yml,docs job 一步)

捕获改成与那四个门禁同一个形状:if ! CHANGED=$(git diff …); then should_run=true; exit 0; fi,revision range 加单引号(畸形 sha 会作为一个参数递给 git 被拒,而不是被词法拆开),并沿用它们的说明性注释形状。

验证方式不是手抄一遍逻辑,而是把这一步真实的 run: 脚本从解析后的 YAML 里取出来、替换 ${{ … }} 后在 fixture 仓库里执行:

场景 旧拼法 新拼法
base sha 不可达(浅 checkout / 畸形 sha) should_run=false(静默全跳) should_run=true(Could not diff against the merge base)
文档有改动,base 可达 true true
只有代码改动,base 可达 false false
merge_group 队列构建 true true
push 到 main true true

也就是说:哪些 PR 需要付一次站点构建的代价没有变,只有"算不出来"这一格从静默跳过翻成了运行。

测量中另外确认了两件事,都已写进步骤注释:

  1. fail-open 并不能覆盖空 revision range。 没有 github.event.pull_request payload 时,range 会插值成一个裸的 ...,git 把它读成 HEAD...HEAD,exit 0 且无输出(已实测)—— 所以覆盖队列与 push 两条 lane 的是 ci: subscribe the four gate workflows to merge_group, and move ci/lint path filtering into the jobs (#3523 steps 1-2) #3722 加的 != 'pull_request' 提前返回,不是 diff 本身。这一条本来容易被当成冗余删掉。
  2. 2>/dev/null|| echo "" 一起删。 它把 git 自己对失败原因的说明从 run log 里抹掉了,而那是"构建为什么被跳过"的读者唯一能拿到的诊断。

顺带修掉 type-check job 里那段注释:它原本写着"下面 docs job 的 || echo "" 是 fail-CLOSED 拼法",本 PR 之后这句话就是错的——同一个文件里留一句与代码相反的断言,比不写更糟。

钉面(scripts/__tests__/merge-queue-reporting.test.ts)

原来的 fail-open 断言是按文件的一条 toMatch,所以它看不见 docs 这一处:ci.yml 里三个 fail-open 捕获替 docs 门禁满足了正则,整份文件带着缺陷仍然全绿(实测:改前的树上 10/10 通过)。现在改成按捕获:

  • ci.yml / lint.yml 里每一处 CHANGED=$(git diff …)(先剥注释——两个 workflow 现在都在正文里讨论这个形状)都必须以 if ! 开头;新加的第六个门禁写成 closed 拼法会立刻红,不需要有人记得来改这个测试;
  • 每个文件配一个下界(ci.yml 4 个:type-check / test / e2e / docs;lint.yml 1 个:lint),否则"删掉某个门禁的捕获"会让上面那条断言拿到空列表——因为什么都没产出而绿,不是因为逻辑对;
  • 另加一条:门禁里不得出现 || echo ""2>/dev/nullif ! CHANGED=$(git diff … || echo "") 形状上是 fail-open,实际不是——|| echo "" 让命令无论 git 如何都成功,失败分支不可达,等于把安全拼法套在 fail-closed 外面。

反向验证(方向先预测,后测量)

预测:把 ci.yml 的拼法回退到 origin/main,恰好 2 条测试变红,9 条仍绿(捕获数量仍是 4,下界那条应当保持绿)。实测一致:

Tests  2 failed | 9 passed (11)

FAIL  fails OPEN: EVERY gate that cannot compute the diff runs everything
AssertionError: ci.yml captures a `git diff` without letting its failure mean RUN:
  - CHANGED=$(git diff --name-only ${{ … }}...${{ … }} -- \

FAIL  fails OPEN in effect, not only in shape: no gate swallows its diff failure
AssertionError: ci.yml still contains `|| echo ""` makes the capture succeed even when git failed.

门禁

pnpm exec vitest run scripts/__tests__/merge-queue-reporting.test.ts  →  11 passed (11)
pnpm exec vitest run scripts/                                          →  20 files, 362 passed (362)
pnpm type-check:scripts                                                →  exit 0
node scripts/check-control-bytes.mjs                                   →  OK (3691 tracked text files)

scripts/ 全跑是刻意的:ci-cd-pipeline-doc.test.ts 也读 .github/workflows/(按 job 与按命令两个方向钉 content/docs/guide/ci-cd-pipeline.md 的表格),本改动没有增删任何 first-party 命令,它保持绿。仓库里没有 workflow linter(package.json.github/workflows/ 均无 actionlint),所以 YAML 的合法性由上面那个"解析 YAML 取出 run: 再执行"的模拟顺带覆盖。

content/docs/guide/ci-cd-pipeline.md 不需要改:它第 116 行本来就写着 "The gate fails open — if the diff cannot be computed the job runs everything",而那一段同时点了 docs job 的名。本 PR 之前这句话对 docs 是过度声明,之后才真正成立。

无 changeset:纯 CI 配置,无已发布包的行为变化——与 #3722 及它之前三个 ci.yml 提交(#3659 / #3547 / #3550)的先例一致。


Generated by Claude Code

…diff (#3723)

`ci.yml`'s `docs` job decided whether to build the site from

    CHANGED=$(git diff --name-only BASE...HEAD -- 'apps/site/' 'content/' 2>/dev/null || echo "")

which collapses two different facts into one empty string: "the diff
succeeded and nothing docs-related changed" (correctly a skip) and "the
diff could not be computed at all" — a checkout that did not fetch deep
enough, a transient git failure, a malformed sha. The second case skipped
the entire site build and the job still reported success: no red step, no
warning, nothing in the summary. objectstack#4928 named this the filter
contract after the same shape produced a fully green, zero-job pull
request: when the filter cannot tell, RUN.

The capture is now the fail-open form the four gates PR #3722 added to
this workflow already use — `if ! CHANGED=$(git diff …); then
should_run=true; exit 0; fi`, with the revision range quoted so a
malformed sha reaches git as one argument and is rejected rather than
word-split. Measured by executing this step's own `run:` script (pulled
out of the parsed YAML, `${{ … }}` substituted) against a fixture
repository: unreachable base sha `should_run=false` -> `true`; docs
changed `true` -> `true`; only code changed `false` -> `false`. Which
pull requests pay for a site build is unchanged.

Two things found while measuring, both now written into the step:

- Failing open does NOT cover an empty revision range. With no
  `github.event.pull_request` payload the range interpolates to a bare
  `...`, which git reads as `HEAD...HEAD` and exits 0 with no output — so
  the `!= 'pull_request'` early return #3722 added is what covers the
  queue and push lanes, not the diff.
- `2>/dev/null` is dropped as well as `|| echo ""`. It hid git's own
  explanation of the failure from the run log, which is the only
  diagnostic a reader of a skipped build gets.

`merge-queue-reporting.test.ts`'s fail-open assertion was a single
whole-file `toMatch` per workflow, so ci.yml's three fail-open captures
satisfied it on the `docs` gate's behalf — the file was green with the
defect in it (measured: 10/10 on the pre-fix tree). It is now per
CAPTURE: every `CHANGED=$(git diff …)` in `ci.yml` and `lint.yml` must
open as `if ! …`, with a per-file floor so deleting a gate's capture
cannot make the check vacuously green, plus a second test rejecting
`|| echo ""` and `2>/dev/null` anywhere in the gates — `if !` wrapped
around a swallow is fail-closed with the safe spelling around it.

No changeset: CI configuration only, no published package changes, in
line with #3722 and the three `ci.yml` commits before it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 8, 2026 11:17am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci.yml's Build Docs path gate fails CLOSED: a failed git diff reads as "nothing changed" and skips the site build, green

2 participants