3254 个测试全绿,CI 红在一个根本没跑的检查上

10 月 4 日下午,我在一个仓库里多加了一行重复的 import。

本地跑全量:3254 通过,6 个基线失败,3 skipped。全绿。

推到 PR,CI 的 ubuntu 3.11 和 3.13 两个作业直接红了:

javascript 复制代码
misakanet/profile.py:20:8: F811 [*] Redefinition of unused `sys` from line 19

F811 就是「重定义」。没错,第 19 行本来就有 import sys,我又加了一行。这是个不需要解释的 bug。

值得解释的不是这行 import,是另一件事:我在本地看到 3254 个测试跑完、看到一行 summary、看到全是绿的那两秒钟里,手上握着一个和真通过逐字相同的信号。

一个「绿」有三种含义

那行 F811 是我自己写的,这个错误毫无价值。真正的问题是:是谁替我决定那 3254 个测试可以不算数的?

顺着这个问题查下去,我在一个仓库里翻出了三种不同的假绿。它们在 CI 界面上的样子完全一样 ------ 绿的 checkbox、绿的 badge、绿的 ✅。

类型 表面现象 实际发生了什么
A 跑绿了,但抓不到 ✅ 通过 门禁物理上不可能变红
B 根本没跑 ✅ 通过 依赖缺失被 skip 吞掉
D 跑的是另一棵树 ✅ 通过 检查在别的 ref 上跑,或绿了没有接收端

三类的共同点是:它们和真通过的输出不可区分。 这才是难的地方 ------ 如果假绿长得不一样,一眼就能看出来。

下面每一条我都打开原文读过,不是转述。

A 类:跑绿了,但抓不到

这类门禁的共性是:它的判据指向一个永远为真的条件。不是判据写错了,是判据被接在了一个恒真的那端。

一次性找出 4 处

2026 年 9 月 21 日,一天之内,仓库里找到 4 处「看起来是门禁、其实永远不会红」:

issue 现象
#2006 / #2008 跨平台矩阵 8 条腿一条测试都没跑,仍然打印 ✅
#1822 工具计数徽章 grep 的正是它自己守护的那个文件
#1999 26 条测试里有 23 条在生产代码回滚后依然通过
#2002 断言 PATH=/usr/bin:/bin(在 CI runner 上恒真)的测试

第 4 条最直白:断言「系统 PATH 就是 /usr/bin:/bin」。在 CI runner 上这个断言永远成立,因为 runner 的 PATH 本来就是这个。所以这条测试绿了 100%,验证的信息量是 0。

第 3 条更有意思:26 条测试,生产代码整个回滚掉之后,其中 23 条照样通过。也就是说这 23 条测的根本不是那段代码。

然后逐个把它们记成 commit

有意思的是,这批缺陷不是靠 review 抓的,是靠后来专门造的一台机器抓的 ------ 我翻了 6 个 commit,每一个标题都是一个「无法失败」的独立案例:

b61d1ed2 #2306(09-26) ------ 这条最干净。测试声称在管「draft 课程会不会漏进搜索结果」,代码这么算:

python 复制代码
draft_count = sum(1 for r in results if r.get("status") == "draft")

但它跑的是 compact(默认)搜索 ,而 status 字段压根不在 compact 的投影里,只在 detail=full 才有。于是 r.get("status") 对每一条结果都是 None,draft_count 恒为 0,断言无条件通过。

讽刺的是,draft 真的会被搜出来 ------ probe lesson 就是个 draft,搜索能命中它。这条 guard 声称在防的事正在发生,而它报绿。

a602ed8e #2270(09-28) ------ 直接把那条空转的 draft-status 检查删了。不是修,是认。

0fff7ee6 #2815(10-04) ------ DSH-home 守卫。它在所有 agent-assisted checkout 里都在 /tmp worktree 下跑,但测试传进去的是仓库根目录,所以那个「拒绝临时目录之外的 home」的断言在真跑的时候从不生效。

3761785(09-21) ------ commit message 里就写着 "a green run that verified nothing"。

c675619b #2201(09-25) ------ D1 备份守卫在 macOS 上根本没有 verdict 。用 stat -c%s 拿文件大小,macOS 的 BSD stat 不认 -c,直接 illegal option。这条腿上的红不是「判错」,是没判。

23a9ee55 #1921(09-20) ------ 最诚实的一条:加一个报警,「PR 门禁从没跑过的时候要报警」。承认了「静默跳过」本身就是一类需要被抓的事故。

这类缺陷不能靠 review 抓,所以造了一台机器

方法叫门禁变异抽查 (docs/maintainer/gate-mutation-audit.md,#2045,09-22 落地):主动把门禁守护的那个东西故意改坏,然后看门禁红不红。

判读方式只有两种:

  • 红 = 门禁还能失败,符合预期(这才是「通过」)
  • 绿 = 门禁无法失败 → 脚本打印 cannot fail 并非零退出

也就是说在这台机器上,「通过」的判定被整个反过来了。2026-09-22 那次实测输出:

scss 复制代码
2 gate(s), 6 mutation(s) --- 5 red (as required), 1 skipped (network),
0 that did not prove the gate can fail

它还有一个刻意的设计:这套变异审计不挂在 PR 上,只在每周一 06:43 UTC 定时跑。理由是它回答的是「这个门禁还有能力失败吗」,尺度是月,不是提交。

外部对照:slim-m #1849

GitHub 上有人在 10-04 报了一个同形状的 case,标题是 "the release asset watchdog can't fail, because piping into tee hides the exit code"。

原文:

the step's exit code is tee's and the checker's exit 1 for an incomplete release gets thrown away. steps.scan.outcome is then always success, ADVISORY_STATUS is always clean, no release-incomplete issue opens and the "fail the run" step never fires.

中文:门禁的退出码被管道吞了,于是那一步永远成功,告警状态永远干净,开 issue 那步永远不触发。

最狠的一句在 notes 里:

The workflow's only test (test_the_script_is_the_one_the_workflow_runs) just checks the script name appears in the yaml, so nothing exercises the exit code path.

中文:这个工作流唯一的测试只检查脚本名出现在 yaml 里,所以没有任何东西走一遍退出码那条路。

这就是 A 类的完整形状:门禁不可能红,而它自己的测试只检查门禁"存在",从不检查门禁"能红"。 存在性测试给了存在性的安心感,没给有效性的任何保证。

B 类:根本没跑

回到我自己的那行 F811。

本地为什么看不见?因为仓库里管 F821/F811 的门禁是这个文件 ------ tests/test_python_undefined_names.py,3 个测试。它的第一件事是探测 ruff 能不能跑:

python 复制代码
def ruff_available() -> None:
    probe = ruff("--version")
    if probe.returncode != 0:
        pytest.skip("ruff is not installed in this interpreter, so the gate cannot run. ...")

我的环境里没装 ruff。于是这 3 个测试全部 skip ,F821/F811 这道门禁在我本地等于不存在。那句「3254 通过」听起来是满的,实际上是 3254 passed + 6 failed + 3 skipped ------ 后两个数字被折进了「全绿」里。

而它在 CI 上是两个作业(ubuntu 3.11 / 3.13)真跑真红的。

handoff 里留了一句操作要点,我到现在还在用:

看到 "3 passed" 才是真的在跑;"3 skipped" 等于没跑

这句话的重点在「3」这个数上 ------ 你得同时知道「有几个测试」和「过了几个」。只报 passed 数的输出会把 3 skipped 显示成一行安静的空。

更隐蔽的一层:一个测试都没跑

同一份 handoff 里还有另一段,比 skip 更难发现。

pytest tests/ 在收集阶段就中断了:

makefile 复制代码
Interrupted: 3 errors during collection

三个测试文件 import 就失败了,一个测试都没开始跑。 那三个是 test_intake_rate_limit_key.py、test_intake_spam_guard.py、test_mcp_http_server.py。

原文对这个的判断:

这比红更危险,因为它长得像"环境坏了"而不是"你什么都没测"。

这是本篇我最想让你记住的一句。skip 的输出里至少有 skipped 这个词,你会看见。本地 pytest 在收集期崩掉,输出长得完全像"我电脑环境坏了,重装一下就好" ------ 而它真正的含义是「这一轮 0 个测试被执行」,比红更糟,因为红至少还告诉你某个断言没成立。

B 类的判别方法很简单,而且只要 5 秒:

你跑的那条命令,报的是 3 passed 还是 3 skipped?采集期有没有 error?

外部对照:exarchos #2107

lvlup-sw/exarchos 10-05 开了个 issue,标题是「Install tests pass when the self-test exclusions, the negation rule, the VCS preamble, the canonical key order or the retired-reference sweep is broken」。

For each item, a probe broke the property that the test names, and the test file passed.

中文:每一项里,一个探针破坏了那个测试名字所声称的性质,而那个测试文件还是过了。

里面最干净的一个,VcsPreamble_PresentInAffectedSkills:

the test reads content/<name>/SKILL.md, and each skill is at content/<domain>/skills/<name>/. None of the six paths exists, so the loop skips each skill.

中文:测试读的路径是 content/<name>/SKILL.md,而实际每个 skill 在 content/<domain>/skills/<name>/。那六个路径一个都不存在,于是循环把每个 skill 都跳过了。

issue 的收尾那句总结得比什么都狠:

The VCS preamble test reads no skill at all.

中文:这个 VCS 前言测试,一个 skill 都没读。

「6 个路径不存在 → 循环体一次没执行 → 14 个测试全绿」。这一类假绿不需要任何巧合,只要路径写错一个字符,就凭空多出 14 个绿色 checkbox。

D 类:跑的是另一棵树

最后一类最难发现,因为它需要你跳出「测试」这个视角,去看这次测试到底是在哪棵树上、给谁看的。

我自己的仓库里有一个 docs/maintainer/ 交接文档目录,专门放给下一轮维护者看的交接。这里有个三层同时坏掉的结构。

第一层:收据本身不在读者所在的地方

10-04 那份 33 KB 的 handoff 当时写在 /home/eric_jia/misakanet-handoff-2026-10-04.md,从没进仓库,抬头还明写「本地交接文档,不在仓库里」。

后果不是「风格不统一」。仓库里有一条门禁专门保证「代码引用的收据必须存在」,而一份没人能读到的 handoff 就是不存在的收据。下一轮维护者(也就是我)花了几分钟才在 WSL 的 home 目录里翻到它。

第二层:检查收据的那条门禁,自己带着一张假收据上线

#2827(4a8e5749,10-04 08:55Z 开、10:06Z 合)加了上面那条门禁,并当场修掉了 5 处悬空引用中的 4 处。第 5 处漏了:

bash 复制代码
E   AssertionError: code cites a handoff that was never committed, so the measurement
    it stands on cannot be checked by anyone:
E       .github/workflows/registry-drift-watch.yml cites docs/maintainer/handoff-2026-09-24.md,
        which does not exist

handoff-2026-09-24.md 从来没存在过 ------ 序列是 09-16 → 09-30,中间没有 09-24。那条 handoff 里的「~1 request in 4」这个数字在仓库里也查不到出处。

于是这条门禁在九个 test 作业上全红,release PR #2825(2.41.2)直接 BLOCKED。

一层讽刺:这条门禁的全部职责就是「不让假收据进仓库」,而它自己带着一张假收据进了仓库。

第三层:main 上根本不跑矩阵

这才是三层里最该先记的,因为它解释了上面两层为什么没被早点发现。

shell 复制代码
$ gh run list --commit 4a8e5749 --limit 25     # 空
$ gh run list --limit 12                        # main 上只有 Update Badge Counts / Site build watch /
                                                # PR Quality Gate(skipped) / deploy-worker

空的。 tests/ 矩阵只在 PR 上跑。推 main 的时候 PR Quality Gate 是 skipped,没有任何 test (...) 作业。

所以 #2827 让 main 变红这件事,在 main 上完全不可见 ------ 它只在下一个 PR 上来的时候才显形。

这次是靠「2.41.2 发布 PR 恰好存在、恰好被挡住」才浮出来的。handoff 原文那句判断我直接抄:

这不是门禁在工作,这是运气。

规则集 23826057(名字叫 main: the deterministic gates)的必需检查只有 4 条:

vbnet 复制代码
DCO / Signed-off-by
test (ubuntu-latest, 3.11)
gate
audit

其余全部是可选的。 判断「能不能合」的时候,别把可选的腿当门禁。

(顺带补一句我这次复核的:那次被挡之后,#2848 修掉了最后一处引用,#2825 随后合入,v2.41.2 于 10-05T16:50:16Z 发布。所以「2.41.2 没发出去」这个说法当时只在几分钟内为真。但要点不在发布结果上 ------ 要点是它当时能被看见,是因为发布 PR 恰好存在,不是因为 main 上的矩阵在跑。)

外部对照:perl-lsp-swarm #17354

这是 D 类里「绿了但没有接收端」的另一种形态。

标题:Nightly benchmark regressions never reach a human (no alert path)。

No nightly benchmark regression --- in any category --- reaches a human. The scheduled-run signal is a step summary nobody watches plus artifacts ("manual investigation").

中文:任何类别的夜间基准回归都到不了人跟前。 定时任务的信号是一个没人看的 step summary,加上 artifacts 上写的一句「manual investigation」。

具体配置是:compare 和 alert 两个步骤都是 continue-on-error: true,工作流权限只有 contents: read,PR 评论被移除(#6028),退出码被吞掉,trend 存储禁用。

所以这一类的形状是:矩阵每天按时跑,绿灯按时亮,但结论的接收端不存在。 跟 A 类的区别是 A 的判据恒真、这一类的判据可能是真的 ------ 判据没问题,是没人接线。

收尾:五个能自己问的问题

这三类假绿的共同点是:输出和真通过不可区分。所以只能靠换问题来区分,不能靠读输出。

我把自己的做法压缩成五问,你拿去问自己那个仓库:

一、A 类:这个门禁的红,是什么让它红的? 把它守护的那个东西改坏一次,看它真的红吗 ------ 别靠推理,靠跑一次。如果改坏了它还是绿,那这个 ✅ 是空的。

二、B 类:我跑的那条命令,报的是「3 passed」还是「3 skipped」? 两个数都要知道。采集期有没有 error?pytest tests/ 会不会在收集阶段就中断 ------ 那不是环境坏了,那是一个都没跑。

三、D 类:这个检查在哪个 ref 上跑? gh run list --commit <sha> 返回空吗?返回空就意味着这次 push 上什么都没跑。规则集里 required_status_checks 只有几条?剩下的都是可选的腿,别当门禁。

四、这份「收据」,读者能打开吗? 它在仓库里吗?它引用的每一个文件都存在吗?写在自己 home 目录里的东西,对下一轮维护者就是不存在的。

五、这个门禁的结论,最后落在哪儿?谁会看? 一个 step summary 没人打开的话,它和一个不存在的门禁没有区别。

这五问都不是新东西,但把它们放在一起有个好处:它们覆盖了三类假绿的三个位置 ------ 判据、触发、接收端。 一个门禁要真的有效,三个位置都得是实的。少任何一个,它给你的绿就只是一个形状。

至于我自己那行多余的 import sys ------ 那个 bug 一分钟就修好了。真正花时间的是搞清楚:为什么我本地的 3254 个绿,和 CI 的两个红,能在同一个 commit 上同时为真。

答案是:因为那次本地运行是 3254 passed + 6 failed + 3 skipped,而那 3 个 skipped 空跑这件事,在输出里长得和真通过一模一样。

附:本文引用的仓库与编号

自家:Ikalus1988/MisakaNet。每个 commit / issue 我都用 git log 或 gh 核过。

外部三条,2026-10-04 ~ 10-05 公开:

  1. Slim-m-org/slim-m#1849 --- 10-04 23:10 UTC
  2. lvlup-sw/exarchos#2107 --- 10-05 07:12 UTC
  3. EffortlessMetrics/perl-lsp-swarm#17354 --- 10-05 16:28 UTC

自家 A 类那批修 commit:

  • b61d1ed2 #2306(09-26)· a602ed8e #2270(09-28)· 0fff7ee6 #2815(10-04)
  • 3761785(09-21)· c675619b #2201(09-25)· 23a9ee55 #1921(09-20)
  • 审计文档 docs/maintainer/gate-mutation-audit.md(#2045,09-22 落地)

B 类证据:docs/maintainer/handoff-2026-10-04.md:100-128、docs/maintainer/handoff-2026-10-05.md:257。 D 类证据:docs/maintainer/handoff-2026-10-05.md §2.1--2.3。

相关推荐
Ikalus19882 小时前
我批准部署的那一刻,抓「部署没发生」的门禁变绿了
自动化运维
Zelman20 小时前
测试过程模型与左移右移
测试·自动化运维·devops
Ikalus19882 天前
假成功有四种形状:给 AI 写自动化工具的自检清单
自动化运维
johnsmithCA2 天前
给搜索的法规条文做了个「版本核验」流程:怎么确认手上的条文还是现行有效版
人工智能·自动化运维
考虑考虑16 天前
nohup启动java程序
后端·自动化运维
东莞市云毅网络有限公司16 天前
PDF 表格抽取的三条技术路线对比:规则、库解析与版面识别
python·sqlite·自动化运维·geo·数据监测
东莞市云毅网络有限公司19 天前
用标准库做网页正文抽取:从 HTML 到结构化字段的轻量实现
python·sqlite·自动化运维·geo·数据监测
考虑考虑20 天前
docker compose V2版本新属性
运维·后端·自动化运维
东莞市云毅网络有限公司22 天前
用 SQLite FTS5 给企业知识库做问答检索原型:中文二元切分与 BM25 排序
python·sqlite·自动化运维·geo·数据监测