Skip to content

fix(ci): 下游 job 改用显式状态函数,filter 失败不再静默跳过七个核心闸门 (#4928) - #5344

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4928-implicit-success-guard
Aug 5, 2026
Merged

fix(ci): 下游 job 改用显式状态函数,filter 失败不再静默跳过七个核心闸门 (#4928)#5344
os-zhuang merged 1 commit into
mainfrom
claude/issue-4928-implicit-success-guard

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4928

改了什么

ci.yml 的 7 个下游 job 原本都是 if: needs.filter.outputs.core == 'true' 这个形状。if: 里不出现状态函数时,GitHub 会隐式包一层 success(),于是 filter 一旦失败(checkout 抖动、dorny/paths-filter 故障、10 分钟超时),七个 job 会被一起跳过 —— 而 skipped 在分支保护里算通过。结果是零测试运行、全绿可合、没有任何红色信号。

七处统一改成「只有过滤器明确说 false 才跳」,每个 job 保留自己的输出名:

job 输出名 新条件
test core !cancelled() && needs.filter.outputs.core != 'false'
temporal-conformance core 同上
dogfood core 同上
dogfood-verify core 同上
build-core core 同上
build-docs docs ... needs.filter.outputs.docs != 'false'
console-pin console ... needs.filter.outputs.console != 'false'

这与 filter 自身 outputs 上 || 'true' 的取舍完全一致 —— 存疑就全跑。作者意图本来就写在代码里了,只是那半只覆盖「输出值缺失」,没覆盖「job 整体失败」。

真值表:只有一个状态改变了行为

filter 的每种结局,旧条件 vs 新条件(模拟脚本按 GitHub 文档语义实现,两种「失败 job 的 outputs 是什么」的读法都跑了):

状态 outputs.core
S1 filter 成功,核心路径有改动 'true' RUN RUN
S2 filter 成功,核心路径没改动 'false' SKIP SKIP
S3 filter 失败(本 issue) '''true' SKIP RUN
S4 run 被取消(supersession) '' SKIP SKIP
S5 merge_group(过滤步骤被跳过) 'true' RUN RUN

只有 S3 变了,其余四个状态逐字节同行为 —— 这是共享基础设施改动该有的爆炸半径。

S3 那一行同时说明了为什么这个写法是稳的:「失败 job 的 outputs」有两种读法(null/空串,或者 || 'true' 照常渲染),新条件在两种读法下都跑('' != 'false''true' != 'false' 都为真);而旧条件在两种读法下都跳,因为隐式 success() 压过一切。修法不依赖于这个语义细节的判定结果。

!cancelled() 只排挤隐式 success(),不改变取消语义:真正的 run 级取消(cancel-in-progress)仍然跳过,即 #3668 的 run-lifecycle 论证原样保留。超时的 job 结论是 failure 而非 cancelled,所以 issue 点名的第三种失败模式也被 S3 覆盖。

repo 内已有同形状的生产先例:release.yml:342docker job 正是靠 !cancelled() && needs.release.outputs.published == 'true' 在上游 job 失败后仍然执行(#4900)。

另一半:聚合闸门

issue 自己的分析指出,case "$result" in success|skipped|cancelled) 分不清「路径过滤器说没碰核心代码」和「过滤器自己炸了」。只修 if: 会把这半个洞留着。

test-gatedogfood-gate 因此把 filter 纳入 needs,并在某条腿是 skippedfilter 未成功时判红。这条分支在新 if: 下不可达 —— 留作不变量的常驻断言,因为它守的失败模式是静默的,而那七行 if: 距离被人手滑改回去只有一次编辑。cancelled 两侧仍然放行,理由与 dogfood-gate 里已有的那段一致。

build-core / build-docs / console-pin / temporal-conformance 背后没有聚合闸门(它们自己的 name 就是必需检查),所以对这四个来说 if: 就是全部的修复。

验证

CI 配置没法靠跑本地测试套件证明,所以做了三件能真做的:

1. actionlint 1.7.7,改前改后都零发现

### BASELINE (origin/main ci.yml) ###   baseline exit=0
### CHANGED (this branch)      ###   changed  exit=0

绿色不是空转 —— 反向对照:故意把 docs 拼成 doc、把 test-gateneeds 去掉 filter,actionlint 立刻两条都点名:

ci.yml:321:67: property "filter" is not defined in object type {test: {outputs: {}; result: string}} [expression]
ci.yml:998:29: property "doc" is not defined in object type {console: string; core: string; docs: string} [expression]
negative-control exit=1

也就是说它确实在逐条类型检查这些表达式:七处 if: 引用的输出名真的存在于 filter 的 outputs 上,两个闸门的 needs.filter.result 真的有 needs: 撑着。

2. YAML 解析 + job 图

python3 yaml.safe_load 通过,10 个 job 全部还在,needs / if 逐条 dump 核对,无环。

3. 两个闸门的 shell 脚本按真实结果状态跑遍

从 YAML 里抽出 run: 原文,替换表达式后交给真 bash 执行(不是重写一份等价逻辑)。test-gate 跑了 4×4 全交叉:

test.result  filter.result  exit
skipped      success        0      Test Core gate satisfied (skipped).
skipped      failure        1      ::error::Test Core shards were skipped while the filter job did not succeed …
skipped      cancelled      0      Test Core gate satisfied (skipped).
cancelled    failure        0      Test Core gate satisfied (cancelled).
failure      success        1      ::error::Test Core shards did not pass (aggregate result: failure)

dogfood-gate 同样,并确认它不会短路 —— 一腿 pass 一腿 error 时两行都打出来且整体 exit 1。

其余门禁:check-nul-bytes OK(5379 个文件),check-changeset-fixed OK,check-changeset-no-major exit 0,控制字符自查 grep -naP 零命中。

changeset

CI-only,不发布任何包,所以用了仓库既有的空 frontmatter 写法(与 ci-cache-tier1-optimizations.md / agents-releases-freeze-merge-queue.md 同款)—— 这是 Check Changeset 明确认可的「本 PR 不发布任何东西」声明,门禁照常绿。

并发核查

Operational note 8:重跑 git log --oneline origin/main -- .github/workflows/ci.yml,最新仍是认领时那个 b8503183c(与本单无关),期间无人改动 ci.yml,不存在静默 revert 的风险。

派生

#4928 正文提到的「静态扫出来」那条规则拆成了 #5343 单独立单,没有塞进本 PR:落地它需要处理 publish-smoke.yml:98 这个唯一存量违规,而那里的 resolve job 没有 || 'true' 兜底、ref 也由它算出,「存疑就全跑」会导致 checkout 落到错的 ref 跑一趟 45 分钟的 job —— 属于那个 workflow 自己的取舍,不该由本 PR 顺手拍板。#5343 里写了完整审计表和验收条件(含「输入缺失必须大声失败」的 #4690 约束)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01FTszibd6C8sUCCZnM4VcrL


Generated by Claude Code

`ci.yml` 的 7 个下游 job 原本都是 `if: needs.filter.outputs.<x> == 'true'`。
`if:` 里不出现状态函数时,GitHub 会隐式包一层 `success()`,于是 `filter` 一旦
失败(checkout 抖动、dorny/paths-filter 故障、10 分钟超时),test /
temporal-conformance / dogfood / dogfood-verify / build-core / build-docs /
console-pin 会被一起跳过 —— 而 skipped 在分支保护里算通过。结果是零测试运行、
全绿可合、没有任何红色信号。

七处统一改成「只有过滤器明确说 false 才跳」:

    if: ${{ !cancelled() && needs.filter.outputs.<name> != 'false' }}

这与 filter 自身 outputs 上 `|| 'true'` 的取舍完全一致 —— 存疑就全跑。每个
job 保留自己的输出名(core / docs / console)。

聚合闸门补上同一层论证:test-gate 与 dogfood-gate 把 `filter` 纳入 needs,
当某条腿是 skipped 而 `filter` 未成功时判红,不再无条件接受 skipped。这条
分支在新 `if:` 下不可达,留作不变量的常驻断言 —— 它守的失败模式是静默的。
`cancelled` 两侧仍然放行(#3668 的 run-lifecycle 论证)。

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

vercel Bot commented Aug 4, 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)
objectstack Ignored Ignored Aug 4, 2026 11:59pm

Request Review

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

Labels

ci/cd documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

filter job 一旦失败,Test Core / Build Core / Dogfood 会全部 skipped 而分支保护判为通过 —— 隐式 success() 今天已第三次咬人

2 participants