Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/workflow-status-function-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
---

chore(ci): job 级 `if:` 读 `needs.*.outputs.*` 必须显式点名状态函数,并修掉 publish-smoke 的存量违规 (#5343)

GitHub 会给任何**不含状态函数**的 `if:` 隐式包一层 `success()`。于是一个本意是「读上游输出值做数据驱动决策」的 job 级条件,悄悄同时携带了一个作者从未写下的状态决策,而「上游挂了」和「上游说不用跑」这两件完全不同的事,到达时是同一个 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)。** 范围严格限定在 **job 级** `if:` 且读 `needs.*.outputs.*`;step 级条件与 `needs.*.result` 刻意在外(前者的隐式 `success()` 说的是「本 job 前面的步骤挂了就别继续」,通常正是作者要的;后者本来就是状态读,作者已经在推理状态,不是被塞了一层没写的包装)。这条边界是「无需猜意图、也无需豁免名单」的前提,脚本因此**没有**任何 skip-list。

判据用**真 YAML 解析**而非 grep `if:` 行,这一步是承重的:grep 分不清「文件坏了」和「没有违规」—— 两者都是零匹配 —— 而且看不见折叠标量。本仓已有两个 workflow 写 `if: >-` 跨行(`merge-queue-triage.yml`、`pr-automation.yml`),#5343 那张靠手工 grep 得出的审计表在那里是盲的。输入缺失一律判红(目录不存在、零个 workflow 文件、YAML 解析失败、没有 `jobs:` 映射、`if:` 不是标量),绝不 `exit 0` 静默放过 —— 即 #4690 反模式的正面。`--self-test` 34 条断言跑真实 `scan()` 路径,`--list` 直接输出审计表(现有 9 处 job 级 `needs.*.outputs.*` 读)。

**`publish-smoke.yml` 的唯一存量违规按「显式红,不跑、不猜」修掉。** `pack-smoke` 的行为不变(仍是 resolve 成功且 `run == 'true'` 才跑),但 `success() &&` 现在写了出来;真正新增的是 `resolve-guard` job:`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 通过」报出去。发布完整性上的**假绿**比没有答案更糟。

顺带实测(#5343 留的核实项):`publish-smoke / packed-tarballs` 在 `main` 的分支保护里**不是**必需检查(必需的是 `TypeScript Type Check`、`Build Core`、`Test Core`、`Dogfood Regression Gate` 四项)。所以 resolve 挂掉时那条 status 根本不写的后果不是「PR 被卡住」,而是**它在 release PR 上完全不可见** —— 这正是新增 guard job 回写 status 所填的洞。

纯工具链改动,不发版。
21 changes: 21 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,27 @@ jobs:
- name: Node-version drift guard
run: pnpm check:node-version

# Workflow status-function guard (#5343, from #4900 and #4928). 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 facts
# "the upstream job DIED" and "the upstream job said don't run" arrive as
# the same skip — which every checks list renders as green. #4900 found a
# release-integrity guard that stopped guarding exactly when the job before
# it failed; #4928 found seven ci.yml gates that turned themselves off
# whenever something upstream broke. Both were caught by hand, months
# apart, and the rule is statically decidable — so it is a gate now.
# Job-level `if:` reading `needs.*.outputs.*` only: step-level conditions
# and `needs.*.result` are deliberately out of scope (see the script
# header). Real YAML parse, not a grep over `if:` lines — a grep cannot
# distinguish a malformed workflow from a clean one and cannot see the
# folded `if: >-` two of these workflows already use. Runs its own
# --self-test first: the detector can break while every workflow is fine,
# and a scan that quietly stops matching would report OK while reading
# nothing (#4690's family).
- name: Workflow status-function guard
run: pnpm check:workflow-status-functions

# #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the
# whole package directory, and 20 of the 49 publishable packages declared
# none — so consumers installed TypeScript sources, unit tests and build
Expand Down
66 changes: 65 additions & 1 deletion .github/workflows/publish-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
# commit status on the branch head so it IS visible on the release PR.
# Not wired into normal PR CI on purpose: full build + pack + clean install
# is far too slow for the inner loop.
#
# That reporting path is also why there is a third job, resolve-guard (#5343):
# every verdict this workflow can produce reaches a human through that commit
# status, so a `resolve` that FAILS writes nothing at all — the release PR then
# looks exactly like "there was nothing to smoke". The status is not a required
# check (branch protection on main requires TypeScript Type Check, Build Core,
# Test Core and Dogfood Regression Gate), so an absent one blocks nothing and is
# simply invisible. resolve-guard turns that silence into an explicit red.

name: Publish Smoke

Expand Down Expand Up @@ -92,10 +100,66 @@ jobs:
echo "No open release PR — nothing to smoke"
fi

# resolve failing is NOT "nothing to smoke" — it is "we do not know". Say so
# out loud (#5343). Without this job the two outcomes are indistinguishable
# everywhere it matters: pack-smoke is skipped in both, and a skipped job reads
# as green; and since this workflow runs on `workflow_run`, its own red run is
# filed against the default branch, nowhere near the release PR a human is
# about to merge.
#
# The alternative shape — #4928's `!cancelled() && ... != 'false'`, i.e. "when
# in doubt, run everything" — is wrong HERE, and that is why the decision was
# taken per workflow rather than copied. ci.yml's filter job defaults its
# outputs to "run it" (`|| 'true'`); resolve does not, and it also computes the
# `ref` to check out. Running on doubt would check out an EMPTY ref (the
# workflow_run default), spend 45 minutes smoking whatever that resolves to,
# and report the verdict as if it were about the release candidate. A false
# green on release integrity is worse than no answer.
resolve-guard:
name: Resolve target failed — the candidate was NOT smoked
needs: resolve
if: always() && needs.resolve.result == 'failure'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
statuses: write
steps:
- name: Report the gap on the release PR, then fail
env:
GH_TOKEN: ${{ github.token }}
run: |
# Best-effort, and deliberately a SECOND lookup rather than a reuse of
# resolve's outputs: resolve failed, so its outputs are empty — that is
# the whole problem. Same query as resolve's (`--head
# changeset-release/main`); keep the two in step if that convention
# ever changes. Whatever this lookup does, the job still fails below,
# so the fallback can never turn into a silent pass.
release_head=$(gh pr list --repo "$GITHUB_REPOSITORY" \
--head changeset-release/main --state open \
--json headRefOid -q '.[0].headRefOid // empty') || release_head=''
if [ -n "$release_head" ]; then
gh api "repos/$GITHUB_REPOSITORY/statuses/$release_head" \
-f state=failure -f context='publish-smoke / packed-tarballs' \
-f description='Target resolution failed — the candidate was never smoked' \
-f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|| echo "::warning::could not write the commit status on $release_head"
else
echo "::warning::no open changeset-release/main PR found — the failure is reported here only"
fi
echo "::error title=Publish smoke did not run::resolve failed, so the release candidate was never smoked. This is NOT the same as 'nothing to smoke' — re-run the workflow, or check the release PR by hand before merging."
exit 1

pack-smoke:
name: Packed-tarball smoke (release candidate)
needs: resolve
if: needs.resolve.outputs.run == 'true'
# success() is spelled out rather than left to GitHub's implicit wrapper
# (#5343): an `if:` naming no status function is silently wrapped in one, so
# "resolve failed" and "resolve said don't run" both arrive here as the same
# skip. The behaviour is unchanged — smoke only a resolve that SUCCEEDED and
# said run — but it is now written down, and resolve-guard above states the
# failure case that this condition deliberately does not cover.
if: success() && needs.resolve.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"check:release-notes": "node scripts/check-release-notes.mjs",
"check:release-body": "node scripts/release-github-releases.mjs --self-test",
"check:node-version": "node scripts/check-node-version.mjs",
"check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs",
"check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs",
"check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs",
"check:driver-conformance": "node scripts/check-driver-conformance.mjs --self-test && node scripts/check-driver-conformance.mjs",
Expand All @@ -78,7 +79,8 @@
"tsup": "^8.5.1",
"tsx": "^4.23.1",
"turbo": "^2.10.7",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"yaml": "^2.9.0"
},
"engines": {
"node": ">=22.0.0"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading