从 #4928 拆出来的第二半。#4928 的正文点名这条规则「值得沉淀成一条可检查的规则」,但它落地需要一个不属于 #4928 文件面的判断,所以单独立单。
规则
任何 job 级 if: 只要读了 needs.SOMEJOB.outputs.SOMEKEY,就必须显式点名一个状态函数(always() / !cancelled() / success() / failure())。
理由:读上游 输出值 来决策,说明这个决策是数据驱动的;而 GitHub 会给不含状态函数的 if: 隐式包一层 success(),于是「上游挂了」和「上游说不用跑」这两件完全不同的事被压成同一个 skipped。作者如果真的想要 success() 语义,也应当写出 success() && ... —— 声明即强制,而不是靠读者记得 GitHub 的隐式规则。
这条规则是可静态判定的:限定在 job 级 且读 needs.*.outputs.*,不涉及意图猜测。
为什么把范围收到「job 级 + needs.outputs」
我在 #4928 里跑过全仓审计(grep 全部 .github/workflows/*.yml 的 if: 行):
| 位置 |
层级 |
表达式 |
判定 |
ci.yml ×7 |
job |
needs.filter.outputs.* |
PR #4928 已修 |
release.yml:342 |
job |
needs.release.outputs.published |
已带 !cancelled()(#4900) |
publish-smoke.yml:98 |
job |
needs.resolve.outputs.run == 'true' |
唯一存量违规 |
ci.yml:1091,1126 |
step |
steps.console-dist.outputs.cache-hit |
规则不覆盖 |
release.yml:99,313 |
step |
steps.*.outputs.* |
规则不覆盖 |
publish-smoke.yml:106,159 |
step |
needs.resolve.outputs.report-sha |
见下 |
step 级读 本 job 自己 的 steps.*.outputs.* 被排除是对的:那里的隐式 success()(前一步挂了就别继续)通常正是作者要的语义。把它们一起扫会立刻产生一批需要豁免的假阳性,而带着长豁免名单出生的门禁离 #4690 的反模式只有一步之遥。
存量违规需要一个决策(所以没有顺手在 #4928 里修)
publish-smoke.yml 的 resolve job 与 ci.yml 的 filter 不同:它的 outputs 没有 || 'true' 兜底(run: ${{ steps.target.outputs.run }}),而且 ref 也是它算出来的。所以 resolve 失败时:
即「存疑就全跑」在这里不像在 ci.yml 那样无条件正确,要先决定 resolve 失败时的正确兜底(补 || 默认值?失败时显式报一个红状态而不是跑?)。这属于该 workflow 自己的取舍,不该由 #4928 顺手拍板。
另外未核实的一点,留给接手的人:publish-smoke / packed-tarballs 这个 commit status 在 resolve 挂掉时根本不会被写(连 pending 都没有)。如果它在 release PR 上是必需检查,后果与「skipped 算通过」不同,需要单独确认。
验收
参考
从 #4928 拆出来的第二半。#4928 的正文点名这条规则「值得沉淀成一条可检查的规则」,但它落地需要一个不属于 #4928 文件面的判断,所以单独立单。
规则
理由:读上游 输出值 来决策,说明这个决策是数据驱动的;而 GitHub 会给不含状态函数的
if:隐式包一层success(),于是「上游挂了」和「上游说不用跑」这两件完全不同的事被压成同一个 skipped。作者如果真的想要 success() 语义,也应当写出success() && ...—— 声明即强制,而不是靠读者记得 GitHub 的隐式规则。这条规则是可静态判定的:限定在 job 级 且读
needs.*.outputs.*,不涉及意图猜测。为什么把范围收到「job 级 + needs.outputs」
我在 #4928 里跑过全仓审计(
grep全部.github/workflows/*.yml的if:行):ci.yml×7needs.filter.outputs.*release.yml:342needs.release.outputs.published!cancelled()(#4900)publish-smoke.yml:98needs.resolve.outputs.run == 'true'ci.yml:1091,1126steps.console-dist.outputs.cache-hitrelease.yml:99,313steps.*.outputs.*publish-smoke.yml:106,159needs.resolve.outputs.report-shastep 级读 本 job 自己 的
steps.*.outputs.*被排除是对的:那里的隐式 success()(前一步挂了就别继续)通常正是作者要的语义。把它们一起扫会立刻产生一批需要豁免的假阳性,而带着长豁免名单出生的门禁离 #4690 的反模式只有一步之遥。存量违规需要一个决策(所以没有顺手在 #4928 里修)
publish-smoke.yml的resolvejob 与ci.yml的filter不同:它的 outputs 没有|| 'true'兜底(run: ${{ steps.target.outputs.run }}),而且ref也是它算出来的。所以resolve失败时:pack-smoke被跳过 —— release candidate 静默地没被 smoke 过。filterjob 一旦失败,Test Core / Build Core / Dogfood 会全部 skipped 而分支保护判为通过 —— 隐式 success() 今天已第三次咬人 #4928 的形状(!cancelled() && needs.resolve.outputs.run != 'false'):run为空 → 跑,但ref同样为空 → checkout 落到 workflow_run 的默认 ref,可能 smoke 了错的东西,而且是一趟 45 分钟的 job。即「存疑就全跑」在这里不像在
ci.yml那样无条件正确,要先决定resolve失败时的正确兜底(补||默认值?失败时显式报一个红状态而不是跑?)。这属于该 workflow 自己的取舍,不该由 #4928 顺手拍板。另外未核实的一点,留给接手的人:
publish-smoke / packed-tarballs这个 commit status 在resolve挂掉时根本不会被写(连 pending 都没有)。如果它在 release PR 上是必需检查,后果与「skipped 算通过」不同,需要单独确认。验收
scripts/check-workflow-status-functions.mjs(或等价物):解析.github/workflows/*.yml,对每个 job 的if:检查是否读needs.*.outputs.*且未点名状态函数,违规则退出非零并打::error file=...,line=...。.github/workflows/不存在 —— 一律退出非零,绝不exit 0静默放过。lint.yml最自然),而不是只躺在scripts/里。publish-smoke.yml:98按上面的决策修掉,或写进脚本的显式豁免名单并附理由注释。参考
filterjob 一旦失败,Test Core / Build Core / Dogfood 会全部 skipped 而分支保护判为通过 —— 隐式 success() 今天已第三次咬人 #4928(本单来源,PR 已修ci.yml七处 + 两个聚合闸门)release.yml的发布完整性守卫与dockerjob)exit 0的反模式)