chore(ci): job 级 if: 读 needs.*.outputs.* 必须显式点名状态函数 (#5343) - #5477
Merged
Conversation
…a status function (#5343) GitHub wraps any `if:` that names no status function in an IMPLICIT success(). So a job-level condition written to read an upstream job's OUTPUT VALUE silently also carries a status decision nobody wrote, and the two completely different facts "the upstream job DIED" and "the upstream job said do not run" arrive as the same skip -- which every checks list renders as green. This repo has paid for that twice by hand, months apart: #4900's release integrity guard stopped guarding exactly when the job before it failed, and #4928 found seven ci.yml gates that turned themselves off whenever something upstream broke. The rule is statically decidable, so it is a gate now rather than a third manual reading. scripts/check-workflow-status-functions.mjs (`pnpm check:workflow-status-functions`, wired into lint.yml's ESLint job) holds every job-level `if:` that reads `needs.*.outputs.*` to naming one of always() / !cancelled() / success() / failure(). Step-level conditions and `needs.*.result` are deliberately out of scope: the implicit success() in a step is about that job's own earlier steps and is usually what the author wants, and a `.result` read is already a status expression. That boundary is what keeps the rule free of intent-guessing and of an exemption list -- there is no skip-list. The parse is a real YAML parse rather than a grep over `if:` lines, and that is load-bearing: a grep cannot tell a malformed workflow from a clean one (both are zero matches) and cannot see a folded expression at all, which two workflows here already use. Missing input is a failure, never a pass (#4690): absent directory, zero workflow files, parse error, no `jobs:` map and a non-scalar `if:` all exit non-zero. `--self-test` runs 34 assertions through the real scan() path over temp fixture roots; `--list` prints the audit table #5343 had to build by hand. publish-smoke.yml carried the one existing violation. pack-smoke's behaviour is unchanged -- smoke only a resolve that SUCCEEDED and said run -- but success() is now written out, and a new resolve-guard job goes red when resolve fails, then reports that failure as a commit status on the release PR head. #4928's "when in doubt, run everything" shape is deliberately NOT copied here: ci.yml's filter defaults its outputs to run (`|| 'true'`), resolve does not and also computes the ref, so running on doubt would check out an empty ref and spend 45 minutes reporting a verdict about the wrong thing. Measured while fixing it: `publish-smoke / packed-tarballs` is NOT a required check on main (branch protection requires TypeScript Type Check, Build Core, Test Core, Dogfood Regression Gate), so the status resolve failure never writes blocks nothing and is simply invisible on the release PR. That is the hole resolve-guard fills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
5 tasks
os-zhuang
marked this pull request as ready for review
August 5, 2026 13:42
os-zhuang
enabled auto-merge
August 5, 2026 13:42
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5343
这是什么
GitHub 会给任何不含状态函数的
if:隐式包一层success()。于是一个本意是「读上游 job 的输出值做数据驱动决策」的 job 级条件,悄悄同时携带了一个作者从未写下的状态决策 —— 「上游挂了」和「上游说不用跑」这两件完全不同的事,到达时是同一个 skipped,而 skipped 在任何 checks 列表里都渲染成绿。本仓已经手工踩过两次,相隔数月:#4900 的发布完整性守卫恰好在它前面那个 job 失败时停止守卫;#4928 在
ci.yml里找到七处needs.filter.outputs.*闸门,一旦上游出事就自己把自己关掉。这条规则可静态判定,所以这次把它变成门禁,而不是等第三次有人读出来。新门禁
scripts/check-workflow-status-functions.mjs→pnpm check:workflow-status-functions,接进lint.yml的 ESLint job(#5461 刚改写过的 Raw control-byte guard 段之后、按主题挨着 Node-version drift guard 放,两者都是 workflow 文件的守卫)。规则:job 级
if:只要读needs.SOMEJOB.outputs.SOMEKEY,就必须点名always()/!cancelled()/success()/failure()之一。范围收窄是刻意的,也是这条规则能不猜意图、不带豁免名单的前提:
steps.*.outputs.*不管 —— 那里的隐式success()说的是「本 job 前面的步骤挂了就别继续」,通常正是作者要的语义;needs.*.outputs.*同样不管,理由相同(它所在的 job 已经在 job 级过了这条规则)。publish-smoke.yml那两处needs.resolve.outputs.report-sha步骤就是活样本,两处都对;needs.*.result/.conclusion不管 —— 那本来就是状态读,作者已经在推理状态,不是被塞了一层没写的包装。脚本没有 skip-list。若将来真有 job 级
if:需要豁免,那是要摆到台面上做的决定。两个实现细节值得单独说:
if:行。 grep 分不清「文件坏了」和「没有违规」—— 两者都是零匹配 —— 也看不见折叠标量。本仓merge-queue-triage.yml与pr-automation.yml已经在用if: >-跨行写条件,静态扫描:job 级if:读needs.*.outputs.*必须显式带状态函数(#4928 沉淀的规则 + publish-smoke.yml 唯一存量违规) #5343 那张手工 grep 得出的审计表在那里是盲的。为此把yaml加进根 devDependencies(工作区里packages/cli已依赖同一版本,lockfile 只多了 3 行 importer 记录,无新解析条目)。.github/workflows/不存在、零个 workflow 文件、YAML 解析失败、没有jobs:映射、if:不是标量 —— 全部退出非零。读不到输入的门禁什么都没验证,这时报 OK 正是 check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690 记录的那个缺陷。--self-test34 条断言跑真实的scan()路径(临时 fixture 根目录,check-nul-bytes.mjs 的惯例);--list直接把 #5343 手工做的那张审计表打出来。存量违规:按「显式红,不跑、不猜」修
pack-smoke的行为不变 —— 仍是 resolve 成功且run == 'true'才跑 —— 但success() &&现在写了出来。真正新增的是resolve-guardjob:always() && needs.resolve.result == 'failure'时判红,并尽力把一条 failure commit status 写回 release PR head。这里没有照搬 #4928 的
!cancelled() && ... != 'false',因为「存疑就全跑」在本 workflow 不成立:ci.yml的 filter job 输出带|| 'true'兜底,而resolve没有,且ref也是它算出来的。存疑就跑 = checkout 一个空 ref、花 45 分钟 smoke 掉不知道什么东西、再把结论当作「release candidate 通过」报出去 —— 发布完整性上的假绿比没有答案更糟。附带核实项(issue 留的那条)实测结论
publish-smoke / packed-tarballs不是main的必需检查。 分支保护实测要求的是四项:TypeScript Type Check、Build Core、Test Core、Dogfood Regression Gate(仓库 ruleset 里没有required_status_checks规则;必需检查来自 classic branch protection)。所以 issue 里那个条件分支不成立:resolve 挂掉时那条 status 根本不写,后果不是「release PR 被卡住」,而是它在 release PR 上完全不可见 —— 比 skipped 更安静。这正是
resolve-guard回写 status 所填的洞,也是为什么这一条不需要另立单:它没有独立于本单的后果。(顺带说明它为何本就不该是必需检查:这个 workflow 由workflow_run触发,不保证在每个 PR 上跑,设成必需会把不触发它的 PR 全部锁死。)验证
反向验证方向(先声明再跑):这是一道新门禁,所以正确方向是「修 publish-smoke 之前判红、之后判绿」,并且判红的位置必须正好是 issue 点名的那一处。实跑结果:
改前(
origin/main的 workflow 文本):机器审计独立复现了 #5343 手工 grep 的结论:全仓仅此一处,行号一致。
改后:
其余门禁:
pnpm check:workflow-status-functionsnpx eslint scripts/check-workflow-status-functions.mjs --no-inline-configpnpm check:node-versionnode scripts/check-nul-bytes.mjsnode scripts/check-changeset-fixed.mjsnode scripts/check-override-consistency.mjs本 PR 不含任何包源码改动(新增脚本 + 两个 workflow + 根
package.json脚本项/devDep + changeset),故未跑全仓pnpm test/pnpm typecheck—— 那对本 diff 没有信号,只有 OOM 风险。已加 changeset(空 frontmatter,工具链改动不发版,沿用 #5461 的先例)。Generated by Claude Code