Skip to content

fix(ci): Check Changeset 的 skip-changeset 判定加一次「结算读」,首跑不再结构性必红 (#6378) - #6429

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6378-changeset-label-race
Aug 7, 2026
Merged

fix(ci): Check Changeset 的 skip-changeset 判定加一次「结算读」,首跑不再结构性必红 (#6378)#6429
hotlong merged 1 commit into
mainfrom
claude/issue-6378-changeset-label-race

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6378

1. 根因复核:时序证据

立单说的是「job 起跑那一刻就读标签」。复核后需要修正一处措辞——Check Changeset 早在 #5580 就已经不再读事件负载,而是有一次活标签读取。问题不在于它读的是快照,而在于它读得太早,且只读一次。

实测取自 PR #6426opened 跑(run 31204438874,job 92951930923):

时刻 时间 距 PR 创建
PR 创建 17:53:57Z 0s
run 起跑 17:54:01Z +4s
job 起跑 17:54:04Z +7s
活标签读取步骤 17:54:07Z +10s
checkout 17:54:08 → 17:54:20 12s
install deps 17:54:21 → 17:54:42 21s
判定步骤 17:54:42Z +45s
job 结束 17:54:47Z +50s

skip-changeset 只能在 PR 建立之后才打得上,立单实测其落地在 +10..45s#6310 约 +15s,#6358 约 +35..45s)。一次 +10s 的读取恒定落在竞态窗口内——这就是「结构性必红」的机制,也是为什么 #5542 打了标签仍红、#5650 标签早到 41s 就绿:那是运行器启动快慢在决定判决。

这里还顺带证伪了一个我自己的工作假设:我原本打算「把等待藏在 install 后面,成本为零」。实测否掉了——整个 job 只有 43s,走到判定步骤时距 PR 创建也才 +45s。这个仓库里没有一个足够慢的步骤可以藏住等待。所以等待不能靠藏,只能靠条件化

2. 并发修复检查(Operational note 8,两次)

时机 命令 结论
动手前 git log origin/main -- .github/workflows/pr-automation.yml 最近四笔:81a4a67(#6342)、613c515(#6192)、b030c9a(#6059)、d97f2a2。均已在 main,无在飞并发修复
推送前 同上 + git log 5faa23c..origin/main -- (本单文件面) main 已从 5faa23c 推进到 7618ee8,但该区间内没有任何提交触及本单文件面,零冲突

另核对在飞 PR 列表(#6426 / #6424 / #6423 / #6389 / #6279 / #6208),无一触及 pr-automation.yml

3. 方向选择

选定:方向 1 的条件化变体——保留原有的快路径读取,在计数之后、判定之前插入第二次活标签读取(「结算读」),并且这次读取只在该 PR 本来就要变红时才发生

拆成三步:

  1. Count the changesets this PR adds(原判定步骤,改为只产出 added,不再自己下结论)
  2. Settle the skip-changeset window——if: 为「快路径无标签」并且「added 等于 0」
  3. Require a changeset (or the skip-changeset label)——纯判定

关键在第 2 步的 if:。写了 changeset 的 PR 完全跳过该步:不等一秒,不花一次 API 配额。等待只向「本来就要被判红」的 PR 收取,而把一个红推迟至多一个窗口,代价是零。这正面回答了立单对方向 1 的成本质疑(「把等待成本转嫁给每次运行」),而不是回避它。

窗口自 PR 创建时刻起算 120s(约实测最坏标签延迟 40s 的三倍),轮询间隔 10s。锚在创建时刻使它自动失效synchronizelabeled、任何 rerun_failed_jobs 重放时 created_at 早已过去,截止点已在身后,循环只读一次、不睡眠。

被否决的方向

方向 2(改由 labeled 事件唯一判定)——漏洞假设已证伪,即:漏洞是真的,方向不可取。 立单要求先确认「一个从头到尾没有任何标签动作的 PR 是否仍会被判到」。答案是不会:GitHub 的 labeled 事件只在标签实际写入时触发,一个没有任何标签动作的 PR 不产生该事件。而本仓恰恰存在这种形状——.github/labeler.yml 无匹配路径、且 diff 小到不触发 size 标签的 PR,全程零标签动作。若把唯一判定移到 labeled,这类「真的忘了写 changeset」的 PR 将一次都不会被判。这是本单最危险的一条,故弃。

方向 3(推迟到 ready-for-review / 首次 synchronize) 弃:本仓流程是「PR 先 DRAFT、验收后翻 ready」,但一个建立后直接翻 ready 且再无推送的 PR 不产生 synchronize;且 ready_for_review 未在 on: 里,加它等于改触发面,还要牵动 required 集的语义(本单禁项)。

方向 4(标签缺失时输出 neutral 而非 failure) 弃:这是把门禁降级成提示,直接违反本单硬约束。

4. ⛔ 硬约束:门禁没有被放宽

结算读只有在真的看见标签时才写 skip=true。其余每一个出口——无 PR 号、标签读不到、窗口关闭、重试上限——一律写 skip=false,即强制执行,与 #4690 的既定方向一致。无 changeset 且无 skip-changeset 的 PR 仍然 exit 1

5. 反向验证(先申报,后执行)

声明 A(竞态消失)— 极性:肯定式,会红

申报:本 PR 自身走 skip-changeset 路线,pull_request 事件取 merge ref 版本的 workflow,故本 PR 即活冒烟——首跑就该绿,不需要 rerun

实测结果(活体,首跑,已达成):本 PR 建立于 18:22:52Zskip-changeset 于约 18:23:2xZ 打上。opened 那一跑的 Check Changeset(run 31206681669 / job 92959379121)首跑 success,18:23:01Z 起 18:23:44Z 止,未做任何 rerun。job 日志:

env:
  PR_NUMBER: 6429
  PR_CREATED_AT: 2026-08-07T18:22:52Z
  WINDOW_SECONDS: 120
  POLL_SECONDS: 10
shell: /usr/bin/bash -e {0}

18:23:42  ##[notice]'skip-changeset' is on PR #6429 (read live on attempt 1), so this
          PR declares no release of its own and the changeset check is exempt.

关键在于结算读步骤当时确实执行了——而它的 if: 只在「快路径没看见标签」且「added 为 0」时才成立。也就是说:约 18:23:05Z 的快路径读取没读到标签(标签尚未落地),是 18:23:42Z 的结算读接住了它。换成改动前的 workflow,这一跑必红。 竞态在它自己的修复 PR 上被当场复现、当场吃掉。

附带一格实测:Auto Label18:23:38Z 完成其整组 PUT(新增 ci/cd),标签读回为 ["ci/cd", "skip-changeset"]——本次 #5533 式覆写没有发生,结算读在该 PUT 之后仍读到标签。

日志里的 shell: /usr/bin/bash -e {0} 也实测确认了本文件注释的说法:无 pipefail

声明 B(门没被放宽)— 极性:肯定式,会红

申报:构造「无 changeset 且无 skip-changeset」的形状,必须红。

实测(不是推理):把 workflow 里两个新步骤的 run: 原样抽出,用桩 ghbash -e -o pipefail真跑,11 条全过:

=== settle step ===
[PASS] label already present  : EXEMPT, no wait          skip=true    0.0s
[PASS] B: no label, window closed : ENFORCE              skip=false   0.0s
[PASS] B: no label, window OPEN, never arrives : ENFORCE skip=false   4.0s
[PASS] A: label lands on read 3 inside window : EXEMPT   skip=true    2.1s
[PASS] gh unreadable : ENFORCE (#4690)                   skip=false   0.0s
[PASS] no created_at : one read, ENFORCE                 skip=false   0.0s
[PASS] no PR number : ENFORCE                            skip=false   0.0s
[PASS] lookalike `skip-changeset-audit` NOT exempt       skip=false   0.0s
=== verdict step ===
[PASS] B: added=0 : RED   rc=1 (want 1)
[PASS] added=1 : green    rc=0 (want 0)
[PASS] no .changeset dir : stands aside  rc=0 (want 0)
11/11 passed

其中第 4 条正是本单的竞态:标签在第 3 次读取时才出现,结算读仍判为豁免。第 2、3 条与末尾 added=0rc=1 合起来就是硬约束的实测证据。

(注:桩测跑在 -o pipefail 下,比 GitHub 默认更严;实际 workflow 无 shell: 键,默认 bash -e 无 pipefail——这一点在核对计数步骤时反过来咬了我一次,我最初的本地探针误加了 pipefail,导致空 diff 时 grep -v 返回 1 而整条管道失败。已按真实语义重跑。)

声明 C(既有 CONSUMER 断言未变陈旧)— 极性:肯定式,会红

申报check-empty-changeset.mjs 里直接读 workflow 的既有断言,改后必须仍然真实反映 workflow 现状;不能只是「还绿」。

风险点是真实存在的:我把 Check for a changeset added by this PR 改名为 Count the changesets this PR adds 并移动了计数行。若既有断言锚在步骤名上,它现在就已陈旧。

实测:把 #6129 重新植入改名后的计数步骤($MERGE_BASE 换回 $PINNED_BASE_SHA),自测立刻转红:

consumer: the changeset COUNT must diff from $MERGE_BASE (never the frozen base.sha) -- that count going green on main drift is #6129
EXIT=1

⇒ 既有断言锚在 diff 拼写而非步骤名上,穿过了我的重构仍然咬得住,不是「看不见我的改动」。

空绿自查(消融七条,全部转红)

新增断言若不会因构造被拿掉而转红,就是幻检。逐条消融实测:

消融 自测
结算读不再以 added 等于 0 为条件 RED
两处标签匹配器分叉(-qxF-qF RED
某判定步骤丢掉结算读守卫 RED
某判定步骤丢掉快路径守卫 RED
「无 changeset」判定改成 exit 0 RED
判定步骤加 continue-on-error RED
整个结算读步骤被删除 RED

零空绿。 自测条数 36 改为 43。

6. 门禁 EXIT 表

门禁 EXIT 输出
node scripts/check-empty-changeset.mjs --self-test 0 ✓ 43 assertions over real temp git repos (real scan() path)
pnpm check:workflow-status-functions 0 ✓ 34 assertionsOK (22 workflow files, 41 jobs, 24 job-level if:; 9 read needs.*.outputs.*, all naming a status function)
YAML 解析校验(全部 22 个 workflow) 0 parsed 22 workflow files, 0 errors
每个 run:bash -n 0 BAD COUNT 0
pnpm check:nul-bytes 0 ✓ 56 assertionsOK (scanned 6067 tracked text files)
控制字节自扫 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' 1(无命中) 两个改动文件均无控制字节
npx eslint scripts/check-empty-changeset.mjs 0 无输出

另:用真实 git 跑通计数步骤——本分支 added=0,回退 40 个提交为基准时 added=26

CI 侧(本 PR):ESLint successTypeScript Type Check successCheck Changeset 首跑 success

7. 不在本 PR 里

Check Changeset 只有一次活标签读取,实测在 PR 创建后约 +10s 触发(PR #6426
的 opened 跑 31204438874:PR 17:53:57Z 建立,读标签步骤 17:54:07Z 起跑),
而 skip-changeset 标签只能在 PR 建立之后才打得上,实测落地在 +10..45s。
两者恒定重叠,于是走标签路线的 PR 首跑几乎必红,今日一天复现 22 次。

本次把「计数」与「判定」拆开,并在两者之间插入第二次活标签读取(结算读):

  * 计数步骤只产出 added,不再自己下红结论;
  * 结算读只在 `fast-path 无标签 且 added == 0` 时运行 —— 也就是只向
    「本来就要变红」的 PR 收取等待成本。写了 changeset 的 PR 完全跳过该步,
    既不等待也不多花一次 API 配额;
  * 窗口自 PR 创建时刻起算 120s(约为实测最坏标签延迟 40s 的三倍),因此
    synchronize / labeled / rerun 重放时截止点早已过去,只读一次、不睡眠。

门禁没有被放宽:结算读只有在「真的看见标签」时才写 skip=true,其余所有出口
(无 PR 号、标签读不到、窗口关闭、重试上限)一律写 skip=false 即强制执行。
无 changeset 且无 skip-changeset 的 PR 仍然 exit 1。

check-empty-changeset.mjs 的 CONSUMER 断言同步补齐(36 -> 43 条),钉住
「豁免只能由真实标签建立、等待只能向将红的 PR 收取、每个判定步骤必须同时
认两次读取、失败必须仍是失败」;七条消融全部转红,无空绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:22pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@hotlong hotlong added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 7, 2026 — with Claude
@github-actions github-actions Bot added the ci/cd label Aug 7, 2026
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 18:34
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31207566794 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/utils/format.exit-code.test.ts�[2m > �[22memitJson / emitText — process.exitCode (#4873)�[2m > �[22ma duration can no longer reach the exit-code slot (#4873)
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 17 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Queue steward — yielding (队列管家让行), no re-queue action taken

This PR was kicked out of the merge queue by run 31207566794 (generation pr-6429-26b72e0f, created 18:34:23Z, Test Core (3/3) failure at 18:47:16Z). Recording the signature for the audit trail only — the lane had already disposed of it before this seat's reading, so the steward yields.

Signature (from the full log archive, not a tail):

FAIL src/utils/format.exit-code.test.ts > emitJson / emitText — process.exitCode (#4873)
     > a duration can no longer reach the exit-code slot (#4873)
AssertionError: expected 20 to be 19 // Object.is equality
 ❯ packages/cli/src/utils/format.exit-code.test.ts:115:31
     115|     expect(durationMs & 0xff).toBe(19);
 Test Files  1 failed | 90 passed (91)
      Tests  1 failed | 927 passed (928)

Attribution — not this PR's diff. The failing test lives in packages/cli; this PR changes the Check Changeset workflow. The assertion is durationMs = timer.elapsed() + 531 compared against a hard-coded low byte, so it is green only when elapsed() returns exactly 0; one millisecond of scheduling delay under the queue's full build turns it red at exactly 20. Already filed and diagnosed as #6266 (domain:cli, pm:queue, unassigned) — same signature, same line, second confirmed queue kick-out today (the first was PR #6248).

Disposition: the PR re-entered the queue at 18:48:43Z as generation pr-6429-15614799 and merged at 19:03Z with all four gate jobs completed / success. No steward action was needed or taken; ⛔ no re-queue, no merge, no ready/draft flip.

This signature is not yet in the #5810 ledger — a ledger-upgrade petition has been filed on the anchor issue (the ledger is human-upgrade-only; this seat only petitions).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants