Skip to content

Commit 57279e9

Browse files
committed
fix(ci): Check Changeset 实时读 skip-changeset 标签,首个 run 不再永久红 (#5580)
`github.event.pull_request.labels` 是事件触发那一刻的快照。开 PR 后数秒内补 `skip-changeset` 标签,`opened` 事件的 run 看不见它 → 走计数路径 → 无 changeset → 红;而 `rerun_failed_jobs` 复用同一份载荷(pm-dispatch Operational notes 5), 于是这个红 run 按构造无法被重跑成绿。一日三例:#5467(本门禁自己的修复 PR)、 #5501#5577,每例都要一个人或 agent 停下来「认签名解释掉」。 job 内新增第一个步骤,用 `gh api repos/$REPO/pulls/$PR` 实时读回标签集,产出 `steps.labels.outputs.skip`;其后每个步骤按它决定是否执行。载荷读法按 issue 建议 保留为 fast-path —— 载荷已有标签就整个 job 跳过,常规路径依旧零 runner 成本。 - **容忍方向朝着执行**:标签读不到(API 报错、无 PR 号)判为 `skip=false`,即 照常执行守卫。读不到输入的门什么也没验证,据此发豁免正是 #4690 反模式(静默 跳过、exit 0、看起来像「无违规」);失败以 `::warning::` 明说,由计数步骤定论。 - **实时读放在 checkout 之前**:标签在位时其后全部步骤跳过,整个 job 只花一次 API 调用 —— 收敛到实时状态比它替掉的那个 stale 红更便宜。 - **精确整行匹配**(`grep -qxF`,here-string 而非管道):被替换的 `contains(数组, 'skip-changeset')` 是数组元素精确匹配,子串匹配会让 `skip-changeset-audit` 这类标签新获豁免;here-string 让 `grep -q` 不进管道,避免 `-q` 首个命中即关闭 管道、写入端吃 SIGPIPE 在 `pipefail` 下把判定翻成 false。 - 保留 fast-path 留下唯一一个反向 stale 格:标签在开 PR 后被**移除**时本 run 仍 短路。该格自愈 —— 移除标签必然触发 `unlabeled` 事件,它起的 run 两处都看不到 标签而照常执行;#5580 那个方向没有这种救援(`labeled` run 的绿不会清掉 `opened` run 的红)。文件内注释写明了这笔交换。 ⛔ 未动 `BASE_SHA` diff 计数逻辑与 #5292/PR #5467 的三段有序失败文案(heredoc 终结符仍在块基缩进);未动其他 job。`allow-major` 步骤的同款载荷读法按边界留在 原样 —— RC pre-mode 期间休眠(`check-changeset-no-major.mjs` 整体让位),已记为 #5620。 验证:`check:workflow-status-functions` 与 `check:nul-bytes`(含各自 self-test) 全绿;从 YAML 抽出该步骤真实脚本,以 stub `gh` 在 `bash -e` 与 `bash -eo pipefail` 两种方言下跑 7 场景 × 2 = 14 例全通过(载荷 stale/标签实时在位、无标签、空标签、 API 失败、无 PR 号、近似标签名、401 个标签的 pipefail 压力);另建前后决策真值表, 7 格中仅「载荷无标签 + 实时有标签」的首 run 与其重跑两格改变(enforce → exempt), 与事前预测一致。 Fixes #5580 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc5b048 commit 57279e9

1 file changed

Lines changed: 95 additions & 1 deletion

File tree

.github/workflows/pr-automation.yml

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ jobs:
5959
# that was structurally unsatisfiable.
6060
# Pin the author as well as the branch name, so a hand-pushed branch of
6161
# that name cannot borrow the exemption as an escape hatch.
62+
#
63+
# The LABEL half of this expression is a fast path, NOT the authority (#5580).
64+
# `github.event.pull_request.labels` is a snapshot frozen when the event
65+
# fired, so a label applied seconds after `gh pr create` is invisible to the
66+
# `opened` run -- and `rerun_failed_jobs` replays that SAME payload
67+
# (pm-dispatch Operational notes 5), so the resulting red run can never be
68+
# re-run green. It is permanently red by construction: three PRs in one day
69+
# (#5467 -- this gate's own fix PR -- plus #5501 and #5577) each left a stale
70+
# red that a human or agent had to stop and explain away.
71+
# The authority is the live re-read in the first step below. This expression
72+
# only short-circuits the case where the payload ALREADY shows the label, so
73+
# the common path still costs no runner at all. The branch/author half needs
74+
# no such treatment: head_ref and the PR author cannot change under a rerun.
75+
#
76+
# Keeping the fast path leaves ONE stale cell, in the opposite direction: a
77+
# label REMOVED after the event fired still short-circuits this run, which is
78+
# then permissive on the strength of a snapshot. That one is self-correcting
79+
# and was left deliberately -- removing a label always fires an `unlabeled`
80+
# event of its own, and the run it starts sees no label in either place and
81+
# enforces. The direction #5580 is about has no such rescue: the `labeled`
82+
# run's green verdict does not clear the `opened` run's red one.
6283
if: >-
6384
!contains(github.event.pull_request.labels.*.name, 'skip-changeset')
6485
&& !(github.head_ref == 'changeset-release/main'
@@ -68,23 +89,80 @@ jobs:
6889
pull-requests: write
6990

7091
steps:
92+
# The label read the frozen payload could not do. It runs BEFORE checkout
93+
# on purpose: when the label is there, every step below is skipped and the
94+
# whole job costs one API call, so converging on the live state is cheaper
95+
# than the stale red it replaces.
96+
#
97+
# The direction of the tolerance is deliberate: an unreadable label list
98+
# (API error, no PR number) resolves to `skip=false`, i.e. ENFORCE. A gate
99+
# that could not read its input has verified nothing, and handing out an
100+
# exemption on that basis is the #4690 anti-pattern -- a check that skips
101+
# silently, exits 0 and reads as "no violations". The failure is announced
102+
# as a warning and the changeset count below decides.
103+
- name: Re-read this PR's labels live (the event payload can predate them)
104+
id: labels
105+
env:
106+
GH_TOKEN: ${{ github.token }}
107+
PR_NUMBER: ${{ github.event.pull_request.number }}
108+
run: |
109+
if [ -z "$PR_NUMBER" ]; then
110+
echo "::warning::No PR number on this event, so the labels could not be re-read. Enforcing the changeset check."
111+
echo 'skip=false' >> "$GITHUB_OUTPUT"
112+
exit 0
113+
fi
114+
# The pulls endpoint carries the PR's full label set inline, and a GET
115+
# on it is covered by this job's own pull-requests permission -- no
116+
# pagination, no wider scope than the job already declares.
117+
if ! LABELS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.labels[].name'); then
118+
echo "::warning::Could not read the labels of PR #$PR_NUMBER, so this run cannot see a 'skip-changeset' applied after the event fired. Enforcing the changeset check."
119+
echo 'skip=false' >> "$GITHUB_OUTPUT"
120+
exit 0
121+
fi
122+
echo "Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}"
123+
# Whole-line fixed match, fed by a here-string rather than a pipe.
124+
# `-x -F` because the payload expression this replaces, `contains(<array>,
125+
# 'skip-changeset')`, matches an array ELEMENT exactly -- a substring
126+
# match would newly exempt a PR labelled e.g. `skip-changeset-audit`.
127+
# The here-string (as in release.yml) keeps `grep -q` out of a pipeline:
128+
# -q closes the pipe on the first hit, so a piped writer can take
129+
# SIGPIPE and, under `set -o pipefail`, flip this test to false for a
130+
# long enough label list.
131+
if grep -qxF 'skip-changeset' <<<"$LABELS"; then
132+
echo "::notice::'skip-changeset' is on PR #$PR_NUMBER (read live, not from the event payload), so this PR declares no release of its own and the changeset check is exempt."
133+
echo 'skip=true' >> "$GITHUB_OUTPUT"
134+
else
135+
echo 'skip=false' >> "$GITHUB_OUTPUT"
136+
fi
137+
138+
# Every step from here down carries the same guard rather than the job
139+
# carrying one `if:`, because a job-level `if:` cannot read a step of its
140+
# own job. Repeating it beats the alternatives: a separate gate job would
141+
# add a check row and a brand-new way to go red to a repo already fighting
142+
# check-list noise, and testing the label inside the counting step would
143+
# pay for checkout + install before discovering the PR is exempt.
71144
- name: Checkout repository
145+
if: steps.labels.outputs.skip != 'true'
72146
uses: actions/checkout@v7
73147
with:
74148
fetch-depth: 0
75149

76150
- name: Setup Node.js
151+
if: steps.labels.outputs.skip != 'true'
77152
uses: actions/setup-node@v7
78153
with:
79154
node-version: '22'
80155

81156
- name: Enable Corepack
157+
if: steps.labels.outputs.skip != 'true'
82158
run: corepack enable
83159

84160
- name: Install dependencies
161+
if: steps.labels.outputs.skip != 'true'
85162
run: pnpm install --frozen-lockfile
86163

87164
- name: Check for a changeset added by this PR
165+
if: steps.labels.outputs.skip != 'true'
88166
env:
89167
BASE_SHA: ${{ github.event.pull_request.base.sha }}
90168
run: |
@@ -157,5 +235,21 @@ jobs:
157235
# so a single `major` bump promotes the ENTIRE monorepo to a new major
158236
# version. During the launch window we ship breaking changes as `minor`.
159237
# Add the `allow-major` PR label when a whole-stack major is intended.
160-
if: "!contains(github.event.pull_request.labels.*.name, 'allow-major')"
238+
#
239+
# The first clause keeps this step exempt exactly when the changeset check
240+
# above is: before #5580 the `skip-changeset` label skipped the whole job,
241+
# this step included, and a live-read label must not quietly re-arm it.
242+
#
243+
# The second clause still reads the frozen payload, and so still carries
244+
# the #5580 race in its own right: an `allow-major` applied after the event
245+
# fired is invisible to this run and a rerun replays the same payload.
246+
# It is DORMANT while Changesets is in pre-release mode, because
247+
# check-changeset-no-major.mjs stands aside for the whole RC window (see
248+
# its RC EXEMPTION note), so the label is currently never needed. Tracked
249+
# as #5620 rather than fixed here: #5580 scoped this change to the
250+
# `skip-changeset` read, and widening a green gate's exemption path under
251+
# cover of another issue is how exemptions grow unnoticed.
252+
if: >-
253+
steps.labels.outputs.skip != 'true'
254+
&& !contains(github.event.pull_request.labels.*.name, 'allow-major')
161255
run: node scripts/check-changeset-no-major.mjs

0 commit comments

Comments
 (0)