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 可达truetrue
只有代码改动,base 可达falsefalse
merge_group 队列构建truetrue
push 到 maintruetrue

也就是说:哪些 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

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 11:17am

Request Review

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 11:24
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit b1a67e0Aug 8, 2026
17 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3723-docs-gate-fail-open branch August 8, 2026 11:25
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…objectstack-ai#3387) (objectstack-ai#3769)
objectstack#4731 / #4843 把「哪些前端改动发版」的判据统一成**读本仓声明的
`.changeset/*.md`**,而这个判据赖以成立的前提——改了发版包源码就必须带一个
changeset——此前由任何门禁保证。实测的后果:`19716b5bf` fix(charts)、
`5e7ef1141` fix(i18n)、`0e50440`(objectstack-ai#3518,26 个文件跨五个包加十个语言包)
都改了已发布包的源码、都是用户可见修复、都没有 changeset,于是搭着下一次发版
出去,在 CHANGELOG、版本号、平台发布记录里一处都查不到。
新增正向触发门禁 `.github/workflows/changeset-presence.yml`
(`scripts/check-changeset-presence.mjs`):改动落在发版包的 `src/` 上时,
本次改动必须**新增**一个 `.changeset/*.md`。
⛔ 没有加宽 `changeset-guard.yml` 的 paths。它的 `paths: ['.changeset/**']`
是刻意的反向触发,并写在自己的文件头里:`ci.yml`/`lint.yml` 都把
`.changeset/**` 列进 `paths-ignore`,只加 changeset 的 PR 不会启动任何别的
workflow,那个 guard 就是为看见这种 PR 而存在的。而**忘了写 changeset 的 PR
按定义不碰 `.changeset/**`**——唯一能发现它的检查恰好是唯一不会跑的检查。
加宽会毁掉它原本要服务的场景,所以两个门禁并存、方向相反:一个管已有声明的
级别,一个管声明是否存在。
几处判断,连同得出它的测量:
- **空 frontmatter 是一等通过写法**,不是变通。要的是「声明一次」,不是强制
发版;纯内部改动/只动测试写 `---` 紧跟 `---` 加一句理由即可,理由就留在仓
库里。因此也**没有**为 `src/` 下的测试文件开豁免口子——教门禁认哪些文件
「不算」正是漏洞的藏身处;顺带一个实测反例:`f1310e40f` 是 `test(...)` 前
缀却同时改了非测试源码,提交信息的前缀并不可信,文件清单才可信。
- **守护面是推导出来的,不是写死的 glob。** issue 提的字面 glob 只覆盖
`packages/` 下一层,而 `@object-ui/console` 在 `apps/console`——本仓最常改
的已发布包,也正是平台 `bump-objectui.sh` 替它写 changeset 的那个包——会被
整整漏掉。改为读 `.changeset/config.json` 的 `fixed` 组:发版覆盖谁,门禁
就守谁,`ignore` 的(`@object-ui/site`、examples)不守。今天推导出 40 个包
目录。既不在 `fixed` 也不在 `ignore` 的包,其源码改动**响亮失败**而不是被
当成「不发版」,分类本身由 `check-changeset-fixed.mjs` 负责。
- **触发器上不加任何 path 过滤。** trigger 上的 `paths` 会跳过整个 workflow
(GitHub 没有 per-job path filter),于是不匹配的 PR 根本不会**创建**这个
check;而一个从不上报的必需 check 不会让 PR 失败,只会让它永远 pending,
在合并队列里则要等 ruleset 的 60 分钟超时——这正是 objectstack-ai#3523 的后半段。所以本
门禁在每个 PR 上都上报、由脚本读 diff 决定,并因此**可以**被设为必需,同时
订阅 `merge_group`(`merge-queue-reporting.test.ts` 的名单加了这一条)。
过滤器还会成为脚本守护面的第二份副本,和它自由漂移。
- **push 到 main 不订阅**:改动已经落地,没有还能写的声明,失败只会把 main
染红在下一位提交者头上。`workflow_dispatch` 也不订阅:手动跑没有可判的
revision range,而本门禁宁可响亮失败也不肯自己编一个。
- **每一项缺失输入都响亮失败**(#4690 / objectstack#4928):base 解析不出、
`git diff` 报错、`.changeset/` 目录不存在、包未分类,全部红。方向和
`ci.yml` 里的过滤门禁**相反**:那些决定要不要跑活,「判断不了」就跑;这里
判断本身就是活,「判断不了」就失败。两者都拒绝在什么都没看的情况下报绿。
自身写测过程中被自己的测试抓出一个真实缺陷并修掉:`resolveBaseRef` 原先把
显式 `--base` 只当作候选链的第一环,于是一个在本地 clone 里不存在的 sha 会
静默跌落到 `merge-base with main`,拿**另一个**提交做比较并打印自信的绿灯
(实测 exit 0;修好后 exit 1)。「你指的 base 不存在」和「你没指 base」是
两件不同的事,只有后者可以靠猜回答。(同族的
`check-i18n-en-drift.mjs` 仍是跌落写法,已另开单,本 PR 不动。)
反向验证(先预判方向再跑):去掉 `--diff-filter=A` → 「编辑他人待发
changeset」用例转红(1 failed / 30 passed);恢复上述 base 跌落 → 显式 base
用例转红且 exit 0→1;把 `git diff` 失败吞成空列表 → diff 失败用例转红。第一
项的预判**错了一次并已改正**:原先声称覆盖该 filter 的 "pending" 用例在去掉
filter 后依然全绿——早提交的 changeset 本就落在 diff range 之外,那条用例钉
的是 range 而非 filter。补了真正触达 filter 的两个 fixture,其中「删除待发
changeset」经测量由两道独立防线各自挡住,注释按实测改写。
三处文档会因本门禁变成假话,一并修正:`ci-cd-pipeline.md` 里
「Nothing in CI requires a pull request to add a changeset」(该页被
`ci-cd-pipeline-doc.test.ts` 双向钉住,新 workflow 本就必须在此建节)、
`CONTRIBUTING.md` 的「DON'T create a changeset for ... apps / 测试改动」、
以及 AGENTS.md 那句「纯 bug 修复不需要」——正是这条旧判据放走了上面三条修复。
这页自己的教训就是:一个把 CI 实际强制内容说错的文档比没有文档更糟。
无 changeset:CI 配置 + 仓库级脚本 + 文档,不改任何已发布包源码,与 objectstack-ai#3722 /
objectstack-ai#3744 同例。本 PR 也是自指的冒烟测试——门禁在自己的改动上判为「不欠 changeset」
并通过(实测 7 个文件,0 个落在守护面内)。
Fixesobjectstack-ai#3387
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
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

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

@yinlianghui@claude