我批准部署的那一刻,抓「部署没发生」的门禁变绿了

10-05 16:39:37Z,我点了批准。30 小时 42 分之后,一个卡在 release 环境审批队列里的部署终于落地了。

3 秒后,一个专门用来抓「部署没发生」的工作流变绿了。

它叫 Deploy Freshness。它存在的原因写在文件第 3 行:Is production running the commit that main says it should? Once a day, without a deploy.

这个 run 变绿的方式是:跳过唯一一次真正的测量。

那一步的 step 状态是 skipped,不是 success。它不是"测了发现没问题",它是"没测"。 而在它变绿的那一刻,生产跑在 82cee67f,main 在它前面 2 个提交。

这篇文章写四层:同一个冻结,四层全绿,最外那一层是我自己按出来的 ------ 而且按了两次。

一、完整时间线(全部 UTC)

时刻 事件
10-04 09:57:31 部署 run 226 (sha 82cee67f)创建,status=waiting,卡在 release 环境审批队列
10-04 10:07:23 code scanning 开出告警 #325 / #326,规则 GITHUB_ACTIONS_UNTRUSTED_CHECKOUT
10-04 10:46:37 哨兵 run #2 (event=schedule)→ failure
10-04 10:46:46 探针第 1 次失败
10-04 10:46:56 探针第 2 次失败
10-04 10:47:06 探针第 3 次失败,run 判红
10-05 11:52:34 哨兵 run #3 (event=schedule)→ failure
10-05 16:39:37 run 226 被批准并完成,success (等了 30h42m)
10-05 16:39:40 哨兵 run #4 (event=workflow_run)→ success ,第 5 步 skipped
10-05 16:49:49 部署 run 227 (sha babb2a9e)创建,status=waiting,又卡回审批队列
10-05 17:00:36 cf1a8563 chore: update leaderboard snapshot(#2878)进 main
10-05 17:04:00 run 227 被批准并完成,success
10-05 17:04:02 哨兵 run #5 (event=workflow_run)→ success ,第 5 步又是 skipped

探针三次失败说的都是同一句,逐字:

csharp 复制代码
❌ production runs 7897946bc034, but this checkout is 4a8e5749a452
   --- 6 commit(s) behind. The worker is stale; the deploy did not land. (#2779)

看第 9 行和第 12、13 行。

第 9 行:那个抓「部署没发生」的门禁,在 3 秒后报绿。

第 12、13 行:同样的事又发生了一遍。 10 分钟后又一个部署卡回队列,17:04:00 我再点一次批准,2 秒后门禁又变绿了。我把 run #5 的 step 也拉下来了,和 run #4 一模一样:

css 复制代码
[4] success  A deploy succeeded, so there is nothing to re-measure
[5] skipped  Is production running main's commit?

同一个洞,踩了两次。 我没有做任何修复 ------ 我只是又点了一次批准。这不是我为了文章效果补的转折,是我在写这篇查数据的时候它自己发生的。

二、变绿的那一次,测了什么

run #4 的 run_id=37342530363。它一共有 5 个 step:

less 复制代码
[1] success  Set up job
[2] success  Run actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
[3] success  Setup Python
[4] success  A deploy succeeded, so there is nothing to re-measure
[5] skipped  Is production running main's commit?

这个工作流的名字叫「生产跑的是 main 的 commit 吗」。第 5 步的 skipped 就是它的答案。

第 4 步 success 了,但它的内容只是 echo。三行 echo,没有一次 curl,没有一次 python3 scripts/doctor.py,没有一次读 /api/health。

走绿的分支条件写在 deploy-freshness.yml:89:

yaml 复制代码
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'

也就是说:只要"有人刚部署成功",这个门禁就自动放弃测量。

而"部署成功"这个信息,是从 github.event.workflow_run.conclusion 读来的 ------ 一个关于另一个工作流的结论 ,不是一次对生产的观测。

这就是整件事的形状:一个门禁把自己要回答的问题,外包给了另一个门禁的退出码。

那一刻生产在 main 后面几个提交

在 run #4 变绿的那个时刻(main 当时停在 4a8e5749),gh api compare/82cee67f...4a8e5749:

css 复制代码
main is ahead_by=2 commits:
  e84b24ba  ci(registry): pin the advertised tool surface ... (#2823)
  4a8e5749  fix(ci): the freshness probe depends on a cron that has never fired (#2827)

我把这两个 commit 的文件清单拉下来数了一遍,没有一个文件在 workers/ 下:

  • e84b24ba 改了 3 个文件:.github/workflows/registry-drift-watch.yml、docs/CI.md、tests/test_mcp_tool_surface_matches_docs.py
  • 4a8e5749 改了 5 个文件:两个 workflow、三个 test

所以那一刻的真相是:worker 产物其实是最新的,只有 commit SHA 落后 2 个。

方法上补一句。main 是个移动靶子 ------ 我写完这段再查,它已经又往前走了几个提交,82cee67f 落后 5 个而不是 2 个。所以这个数必须钉在时刻上:"run #4 变绿的那一刻,落后 2 个"。一个会随时间自己变号的证据,读者没法复核。

这句必须写清楚,因为它决定了后面 critique 的落点 ------ 如果生产真的少了东西,那这就是一次货真价实的漏报。它不是。 绿色的那一刻,生产跑的就是 main 上最新的 worker 代码。

但门禁报的是 success,判据写的是「production runs main's commit」。

而 production 并不跑 main 的 commit。它跑的是 main 的 worker 产物,那两个提交没碰过 worker。

一个事实为真的状态,被一个为假的断言盖住了。这比真的漏报更难查 ------ 真的漏报你能查出来,你只是没查;断言为假,你查出来之后也不知道该信哪个。

三、这个 skip 是我自己写的,理由当时是对的

我不打算把这个 skip 写成"我犯了个蠢"。因为它不是。deploy-freshness.yml:101-109 是我自己写的注释,理由完整、具体、而且在那条分支的局部范围内是正确的:

Re-measuring here would compare production against whatever main has become by now, and a push landing in the seconds between "deploy succeeded" and "this job checks out" would report production behind by a commit it is not behind by in any meaningful sense --- a red for a reason that is not a defect, which is the fastest way to teach people to ignore this workflow.

它防的是一个真实存在的假红:部署在 T 时刻成功,这个 job 在 T+5 秒 checkout,有人在 T+2 秒 push 了一个跟 worker 无关的 commit ------ 探针于是报「生产落后 1 个 commit」,而那 1 个 commit 根本没碰 worker。

这个红是假的,而且它会教人忽略这个工作流。 我怕的正是这个。

一个正确的局部权衡,产生了一个错误的全局绿灯。

错在哪:我在那条分支上论证的是「不要因为无关的 commit 报红」,然后把它实现成了「不测」。

正确的做法是让判据对那类假红免疫 ------ 比如只统计碰了 workers/ 的提交数。那样 T+2 秒那个 push 报 0,"红是假的"这个结论仍然对,但红本身不会出现。

我实现的是「把这次测量取消掉」。在一条防假红的注释下面,我写了一个产生假绿的分支。

四、四层:同一个冻结,四层全绿

这才是我事后最在意的部分。同一个冻结(线上 3 天、5 个已合并 PR 没上线),有四层本该拦住它的地方,四层全绿,而且最外那一层是我自己按出来的。

L0:症状是「版本号两边一样」

issue #2779 记的是原始现场。线上 3 天没更新,最先暴露它的是首页 7 天活动图不更新 ------ 但那只是症状。真正的证据:

ini 复制代码
GET https://misakanet.org/api/activity/history?days=7   → HTTP 404

handler 本身在 88b360004(#2692)里已经进 main,完整、无条件,缺 store 返 503、内部异常返 502 ------ 它不可能返 404。所以 404 只能由"线上 bundle 里没有这段代码"解释。

而当时 post-deploy 的版本校验是通过的:

ini 复制代码
线上 serverInfo.version = 2.40.0
main  package.json      = 2.40.0

两边版本号一模一样,校验通过,而线上其实少了 5 个提交。

deploy-freshness.yml:13-16 记着原因:release-please 只在 release 时 bump 版本,所以 fix: / feat: 提交会让生产和 main 长期报同一个版本号。版本号这个判据对"版本变了的陈旧部署"有效,对"版本没变的陈旧部署"完全失明 ------ 而后者是日常的大多数。

L1:修它的哨兵自己,几乎没跑过

这是我今天最想写的一层。deploy-freshness.yml:22-27 是我自己写下的复盘:

as of 2026-10-04T06:00Z it had produced exactly one run ever --- a manual workflow_dispatch from a PR branch --- and its first daily slot (04:53Z) had passed with nothing. So the safety net for the very freeze it was written for had never once run on its own, and the guard below could not see that, because asserting a cron string is not asserting a cron firing.

我服务端复核了一遍,五个 run 全在这儿(截至 10-05 17:04:02Z):

run 时刻 (UTC) event 第 5 步 run 结论
#1 10-03 18:08:11 workflow_dispatch failure(真测了) failure
#2 10-04 10:46:37 schedule failure(真测了) failure
#3 10-05 11:52:34 schedule failure(真测了) failure
#4 10-05 16:39:40 workflow_run skipped success
#5 10-05 17:04:02 workflow_run skipped success

这张表是全文最该看的一张。

  • 它跑过 5 次,一次都没自己触发过。 10-03 18:08 到 10-04 18:08 整整 24 小时里它一次都没动,10-04 那个 04:53Z 的日槽是空的 ------ 那是它被创建后的第一个日槽。5 次的触发源是 1 次手动派发 + 2 次 cron 槽 + 2 次"别人部署成功了"。
  • 它测过的 3 次,全红;它绿着的 2 次,一次都没测。

而那个本该守住它的测试,我打开看了正文(tests/test_deploy_freshness.py:118):

python 复制代码
def test_the_freshness_probe_is_scheduled_not_only_post_deploy():
    """The whole point: post-deploy verification cannot catch a deploy that never ran."""
    ...
    assert "schedule:" in text, "a workflow_dispatch-only probe is a probe somebody must remember"
    assert "cron:" in text
    assert "--deploy-freshness" in text

三个 assert 全是对文件文本的字符串匹配。 它断言的是"yaml 里有 schedule: 这个字符串"。

GitHub 那天负载高,会丢 scheduled 事件 ------ 这个仓库自己在 approval-watch.yml 的头注释里记着 2026-09-29 的实测:9 小时里只跑了 1 次,连续 5 个槽位连 run 都没创建。

一个字符串断言,测不出一个被平台丢掉的调度。 这个测试全绿,而被它守护的东西从来没响过。

顺带一个我今天才发现的小事:deploy-freshness.yml:26 引用的那个测试名 test_the_freshness_probe_does_not_depend_on_the_scheduler_alone,在仓库里根本不存在。我全仓 grep 过,只有注释里这一处提到它。

一条注释在给一个不存在的测试挡枪。 而这个注释恰恰是在解释"为什么测试测不出来"------它自己就是那个现象的样本。

L2:30 小时 42 分,红了 2 次,没有一次被消费

run 226 创建于 10-04 09:57:31Z,status=waiting。我 10-05 16:39:37Z 批准。中间 30 小时 42 分。

这 30 小时 42 分里,哨兵红了两次(#2、#3),每次都把同一句话打进日志三次,每一句都精确到 commit SHA 和落后提交数,末尾还附了 issue 链接。

这些红没有产生任何后续动作。 不是"我看到了但决定不处理",是它们在 GitHub 上以 failure 状态静静躺着,等一个不会来的通知。

GitHub Actions 不会因为一个 scheduled workflow 变红就叫人。它需要一个 required check 的上下文、一个通知的订阅、或者一个人恰好在看。而这个工作流三条触发器全都不是 required check ------ 它是旁路的。

一个旁路探针红了,等于没红。

L3:红消失的方式,是我按了一下;绿的方式,是跳过测量

10-05 16:39:37Z,run 226 变成 success。3 秒后(16:39:40Z),哨兵 run #4 被这个 success 触发,然后它读到这个 success,判定"部署已经成功了,不用再测",跳过第 5 步,报 success。

我消除一个告警的方式,是制造一个事件;而这个门禁把那个事件当成了证据。

红 2 次没人看,1 次人工点击就全绿。这个门禁的输入是 github.event.workflow_run.conclusion ------ 一个人工动作的副产品。它不是自己发现的。

然后我又按了一次。

10 分钟后的 16:49:49Z,一个新的部署 run 227 又卡进了同一个队列。17:04:00Z 我再点一次批准。2 秒后 (17:04:02Z),哨兵 run #5 变绿,第 5 步 skipped,和 run #4 一字不差。

两次人工点击,两个绿灯,零次测量。中间隔了 24 分钟,仓库里没有一行代码被改过。

这个洞不会因为我注意到它就自动补上。它只会在下一次有人点批准的时候,再绿一次。

五、不变式选错了:该断言产物,不是 commit

现在回到最要紧的地方。这个门禁的判据是:

production 的 /api/health 报的 commit_sha == 这个 checkout 的 HEAD

SHA 相等是一个既过严、又过松的条件。

过松,是刚刚发生的这件事:跳过测量就等于放行。判据在"部署事件"这条分支上完全不生效,但它报的是 success。

过严 ,是 L0 那一层:那两个提交(e84b24ba、4a8e5749)一个字节都没碰 workers/,但 SHA 不相等。判据分不出来"落后"里哪些碰了 worker、哪些没碰。

真正该断言的是「生产跑的是 main 的 worker 产物」,不是「生产跑的是 main 的 commit」。

部署的是 bundle,main 上是源码,SHA 层面没法直接比。但有一个可以落地的近似判据:

  1. 比产物指纹,不比 commit。 部署时把 workers/ 的内容哈希写进 /api/health,探针拿它对 main 上 workers/ 的哈希。无关 commit 不再影响判据,碰了 worker 的提交一定影响判据。 假红从定义上消失,那条 skip 分支也就没有存在必要了。
  2. 退一步:比"碰过 workers/ 的提交数"。 纯 git 层就能做:git log --oneline <deployed_sha>..<head> -- workers/ 数一下。10-05 那一刻这个数是 0,探针就该报绿 ------ 它本来就不会假红,那条 skip 分支是在解决一个自己造出来的问题。
  3. 版本号不能当判据 (L0 已经证过),但可以当辅助信号:版本号不变且 SHA 不变,恰好是"该红没红"的形状。

还有一个更便宜的止损:让 skipped 不等于 success。

第 5 步 skipped 时整个 run 报 success,在 GitHub 的语义里是"通过了",但实际含义是"没测"。这两件事在 CI 的颜色上无法区分。 任何把 CI 颜色当结论的流程(合并规则、通知、看板),都会把"没测"读成"过了"。

我改不动 GitHub 的颜色语义,但我可以给这个工作流一个 required check 的位置,让 skipped 至少出现在必须有人看的那个面板上。

六、顺带:那天同一天的两条 error 级告警,是误报

10-04 10:07:23Z,code scanning 开了 #325 / #326,规则 GITHUB_ACTIONS_UNTRUSTED_CHECKOUT,说的是"特权工作流检出了不受信任的代码(workflow-run head ref)"。

run #4 恰好是它们的反证:run #4 的 head_sha 是 4a8e5749(main),而触发它的部署 run 226 的 head_sha 是 82cee67f(非 main)。

workflow_run 触发下,checkout 取到的是默认分支,不是触发方的 head。 告警假设"特权工作流会检出触发方的代码",而这里它检出的是 main ------ 一份我完全控制的代码。

deploy-freshness.yml:62-72 里我确实把 ref 显式钉成了默认分支。我按正确的方式写了代码,然后告警还是开了 ------ 因为它看的是事件语义,不是 checkout 参数。

这两条告警后来都被 dismiss 了。它们不构成安全问题,但说明一件事:扫描器也在做"用事件推测量"的同一件事,只是它推错了方向,而且错的方向更安全 ------ 它给我发了两条 error,我核实一下就 dismiss 了。

而那个被我按出来的假绿,没有任何工具会给我发一条消息。

七、结尾

这四层我复盘了两天,最刺的不是最外面那层。

L0(版本号看不见)、L1(测试断言 cron 字符串)、L2(旁路红了没人看),这三层里我都能指着说"这是已知的、记着的、写进注释里的问题"。它们是技术债,有名字,有 issue,有 commit。

L3 那个 skip 不一样。它是我在写"我要防假红"的注释时,同一笔写下去的。 我在注释里论证的是"不要报没有意义的红",我在代码里实现的是"不测"。论证和实现之间隔了一次偷懒,而这次偷懒在 YAML 里是合法的、在 review 里是安静的、在 CI 里是绿色的。

而且它发生在我最不该松手的地方 ------ 这个工作流存在的全部意义就是抓假绿,而它是那个洞本身的形状。

三件事我想留着:

一、skip 不是 pass。 第 5 步 skipped 的时候 run 报 success,在颜色上等于"测过了没问题"。判据跳过和判据通过,在任何消费 CI 状态的下游(合并规则、通知、看板)都是同一个 bit。如果你有一个会 skip 的步骤,那你需要的是一个能把 skip 报出来的方式,不是一个能 skip 的方式。

二、别让一个门禁读另一个门禁的退出码。 github.event.workflow_run.conclusion == 'success' 不是对生产的观测,是对上游结论的转述。转述会丢掉"上游为什么绿"这个信息,而这次丢掉的正好是全部信息 ------ 上游绿是因为部署跑完了,不是因为新鲜度是对的。

三、判据要对那类假红免疫,而不是靠不测来躲它。 我躲的不是假红,是测量本身。一个只对相关提交敏感的判据,假红和假绿会同时消失。

最后回到那张时间线表。

我写完第二节的时候顺手查了一次生产,发现 run 227 又卡进了队列,就回去看了一眼 step ------ 和 run #4 一模一样,[5] skipped。然后我意识到:我为了核实这个洞,又点了一次批准。

所以这个洞现在有两次样本,形状一模一样。而5 次运行历史里,3 次真测的全是红,2 次没测的全是绿,重合是零。

那个抓「部署没发生」的门禁,现在是绿的。

它会一直绿到下一个部署事件为止。而下一个部署事件要等一个人去点批准。

这个工作流从来没有自己发现过任何一次冻结。它只是把别人点批准这件事,报成了一句"生产是最新的"。

相关推荐
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·数据监测
考虑考虑23 天前
cmd局部设置java变量
运维·后端·自动化运维