From 57279e9bd9740f0f162c160a55a2ca67390f40b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:46:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(ci):=20Check=20Changeset=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E8=AF=BB=20skip-changeset=20=E6=A0=87=E7=AD=BE,?= =?UTF-8?q?=E9=A6=96=E4=B8=AA=20run=20=E4=B8=8D=E5=86=8D=E6=B0=B8=E4=B9=85?= =?UTF-8?q?=E7=BA=A2=20(#5580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .github/workflows/pr-automation.yml | 96 ++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index e3f3ff78fd..b45b38c62a 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -59,6 +59,27 @@ jobs: # that was structurally unsatisfiable. # Pin the author as well as the branch name, so a hand-pushed branch of # that name cannot borrow the exemption as an escape hatch. + # + # The LABEL half of this expression is a fast path, NOT the authority (#5580). + # `github.event.pull_request.labels` is a snapshot frozen when the event + # fired, so a label applied seconds after `gh pr create` is invisible to the + # `opened` run -- and `rerun_failed_jobs` replays that SAME payload + # (pm-dispatch Operational notes 5), so the resulting red run can never be + # re-run green. It is permanently red by construction: three PRs in one day + # (#5467 -- this gate's own fix PR -- plus #5501 and #5577) each left a stale + # red that a human or agent had to stop and explain away. + # The authority is the live re-read in the first step below. This expression + # only short-circuits the case where the payload ALREADY shows the label, so + # the common path still costs no runner at all. The branch/author half needs + # no such treatment: head_ref and the PR author cannot change under a rerun. + # + # Keeping the fast path leaves ONE stale cell, in the opposite direction: a + # label REMOVED after the event fired still short-circuits this run, which is + # then permissive on the strength of a snapshot. That one is self-correcting + # and was left deliberately -- removing a label always fires an `unlabeled` + # event of its own, and the run it starts sees no label in either place and + # enforces. The direction #5580 is about has no such rescue: the `labeled` + # run's green verdict does not clear the `opened` run's red one. if: >- !contains(github.event.pull_request.labels.*.name, 'skip-changeset') && !(github.head_ref == 'changeset-release/main' @@ -68,23 +89,80 @@ jobs: pull-requests: write steps: + # The label read the frozen payload could not do. It runs BEFORE checkout + # on purpose: when the label is there, every step below is skipped and the + # whole job costs one API call, so converging on the live state is cheaper + # than the stale red it replaces. + # + # The direction of the tolerance is deliberate: an unreadable label list + # (API error, no PR number) resolves to `skip=false`, i.e. ENFORCE. A gate + # that could not read its input has verified nothing, and handing out an + # exemption on that basis is the #4690 anti-pattern -- a check that skips + # silently, exits 0 and reads as "no violations". The failure is announced + # as a warning and the changeset count below decides. + - name: Re-read this PR's labels live (the event payload can predate them) + id: labels + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ -z "$PR_NUMBER" ]; then + echo "::warning::No PR number on this event, so the labels could not be re-read. Enforcing the changeset check." + echo 'skip=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + # The pulls endpoint carries the PR's full label set inline, and a GET + # on it is covered by this job's own pull-requests permission -- no + # pagination, no wider scope than the job already declares. + if ! LABELS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.labels[].name'); then + 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." + echo 'skip=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}" + # Whole-line fixed match, fed by a here-string rather than a pipe. + # `-x -F` because the payload expression this replaces, `contains(, + # 'skip-changeset')`, matches an array ELEMENT exactly -- a substring + # match would newly exempt a PR labelled e.g. `skip-changeset-audit`. + # The here-string (as in release.yml) keeps `grep -q` out of a pipeline: + # -q closes the pipe on the first hit, so a piped writer can take + # SIGPIPE and, under `set -o pipefail`, flip this test to false for a + # long enough label list. + if grep -qxF 'skip-changeset' <<<"$LABELS"; then + 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." + echo 'skip=true' >> "$GITHUB_OUTPUT" + else + echo 'skip=false' >> "$GITHUB_OUTPUT" + fi + + # Every step from here down carries the same guard rather than the job + # carrying one `if:`, because a job-level `if:` cannot read a step of its + # own job. Repeating it beats the alternatives: a separate gate job would + # add a check row and a brand-new way to go red to a repo already fighting + # check-list noise, and testing the label inside the counting step would + # pay for checkout + install before discovering the PR is exempt. - name: Checkout repository + if: steps.labels.outputs.skip != 'true' uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js + if: steps.labels.outputs.skip != 'true' uses: actions/setup-node@v7 with: node-version: '22' - name: Enable Corepack + if: steps.labels.outputs.skip != 'true' run: corepack enable - name: Install dependencies + if: steps.labels.outputs.skip != 'true' run: pnpm install --frozen-lockfile - name: Check for a changeset added by this PR + if: steps.labels.outputs.skip != 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -157,5 +235,21 @@ jobs: # so a single `major` bump promotes the ENTIRE monorepo to a new major # version. During the launch window we ship breaking changes as `minor`. # Add the `allow-major` PR label when a whole-stack major is intended. - if: "!contains(github.event.pull_request.labels.*.name, 'allow-major')" + # + # The first clause keeps this step exempt exactly when the changeset check + # above is: before #5580 the `skip-changeset` label skipped the whole job, + # this step included, and a live-read label must not quietly re-arm it. + # + # The second clause still reads the frozen payload, and so still carries + # the #5580 race in its own right: an `allow-major` applied after the event + # fired is invisible to this run and a rerun replays the same payload. + # It is DORMANT while Changesets is in pre-release mode, because + # check-changeset-no-major.mjs stands aside for the whole RC window (see + # its RC EXEMPTION note), so the label is currently never needed. Tracked + # as #5620 rather than fixed here: #5580 scoped this change to the + # `skip-changeset` read, and widening a green gate's exemption path under + # cover of another issue is how exemptions grow unnoticed. + if: >- + steps.labels.outputs.skip != 'true' + && !contains(github.event.pull_request.labels.*.name, 'allow-major') run: node scripts/check-changeset-no-major.mjs