Skip to content

Commit 562a9b6

Browse files
committed
fix(ci): Check Changeset 的 skip-changeset 判定加一次「结算读」,首跑不再结构性必红 (#6378)
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
1 parent 5faa23c commit 562a9b6

2 files changed

Lines changed: 241 additions & 16 deletions

File tree

.github/workflows/pr-automation.yml

Lines changed: 170 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,22 @@ jobs:
140140
# re-run green. It is permanently red by construction: three PRs in one day
141141
# (#5467 -- this gate's own fix PR -- plus #5501 and #5577) each left a stale
142142
# red that a human or agent had to stop and explain away.
143-
# The authority is the live re-read in the first step below. This expression
144-
# only short-circuits the case where the payload ALREADY shows the label, so
145-
# the common path still costs no runner at all. The branch/author half needs
146-
# no such treatment: head_ref and the PR author cannot change under a rerun.
143+
# The authority is the live label read below. This expression only
144+
# short-circuits the case where the payload ALREADY shows the label, so the
145+
# common path still costs no runner at all. The branch/author half needs no
146+
# such treatment: head_ref and the PR author cannot change under a rerun.
147+
#
148+
# #6378: ONE live re-read was not enough, and the reason is measured rather
149+
# than reasoned. On PR #6426's `opened` run (31204438874) the re-read step
150+
# ran at 17:54:07Z for a PR created at 17:53:57Z -- it reads the label list
151+
# TEN SECONDS after the PR exists. The `skip-changeset` label cannot be
152+
# applied before the PR exists (GitHub offers no create-with-labels path to
153+
# this flow), and measured label latency is 10-45s after creation (#6310
154+
# ~+15s, #6358 ~+35-45s). So the read is inside the race window by
155+
# construction, and the route-2 first run was red 22 times in one day.
156+
# The second, SETTLING read further down is what closes it; see the note on
157+
# that step for why the wait it costs is charged to nobody who was going to
158+
# pass anyway.
147159
#
148160
# Keeping the fast path leaves ONE stale cell, in the opposite direction: a
149161
# label REMOVED after the event fired still short-circuits this run, which is
@@ -166,6 +178,13 @@ jobs:
166178
# whole job costs one API call, so converging on the live state is cheaper
167179
# than the stale red it replaces.
168180
#
181+
# This is the FAST PATH read, and #6378 measured that it is only a fast
182+
# path: at +10s from PR creation it is too early to be the last word for a
183+
# label that lands at +10..45s. It stays exactly as it is -- every run of an
184+
# ALREADY-labelled PR (`synchronize`, `labeled`, every rerun, every later
185+
# push) still exits here for one API call and no runner. What it must not
186+
# do is pronounce a PR guilty; that verdict moved to the settling read.
187+
#
169188
# The direction of the tolerance is deliberate: an unreadable label list
170189
# (API error, no PR number) resolves to `skip=false`, i.e. ENFORCE. A gate
171190
# that could not read its input has verified nothing, and handing out an
@@ -301,13 +320,20 @@ jobs:
301320
if: steps.labels.outputs.skip != 'true'
302321
run: pnpm install --frozen-lockfile
303322

304-
- name: Check for a changeset added by this PR
323+
# COUNTING ONLY -- the verdict is two steps down (#6378). The split is not
324+
# cosmetic: it is what lets the label window be settled for exactly the PRs
325+
# that would otherwise be failed, and for nobody else. A PR that added a
326+
# changeset needs no label and waits for nothing; this step establishes
327+
# that fact before any waiting is considered.
328+
- name: Count the changesets this PR adds
329+
id: changeset_count
305330
if: steps.labels.outputs.skip != 'true'
306331
env:
307332
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
308333
run: |
309334
if [ ! -d ".changeset" ]; then
310335
echo "::warning::.changeset directory not found. Skipping changeset check."
336+
echo 'added=n/a' >> "$GITHUB_OUTPUT"
311337
exit 0
312338
fi
313339
# Count changesets THIS PR adds (diff against the base commit), NOT
@@ -341,6 +367,117 @@ jobs:
341367
# ("the changeset you added declares nothing").
342368
ADDED=$(git diff --name-only --diff-filter=A "$MERGE_BASE" HEAD -- '.changeset/*.md' \
343369
| grep -v '/README\.md$' | wc -l | tr -d '[:space:]')
370+
echo "This PR adds $ADDED changeset(s) (diffed from $MERGE_BASE)."
371+
echo "added=$ADDED" >> "$GITHUB_OUTPUT"
372+
373+
# ── The settling read (#6378) ────────────────────────────────────────────
374+
#
375+
# The one step in this job that is allowed to WAIT, and its `if:` is the
376+
# whole design. It runs only when both of these hold:
377+
#
378+
# * the fast-path read saw no label, AND
379+
# * this PR added no changeset -- i.e. it is headed for RED.
380+
#
381+
# So the wait is charged exclusively to PRs that were about to be failed.
382+
# A PR that wrote a changeset skips this step entirely and costs neither a
383+
# second of wall time nor an API call; that is the answer to the standing
384+
# objection against "just poll for a while" (issue #6378, direction 1),
385+
# which would have taxed every run instead. It has to be answered rather
386+
# than waved away, because the numbers are small: measured on run
387+
# 31204438874, the whole job is 43s and only ~45s has elapsed since PR
388+
# creation by the time the counting step above finishes. There is no
389+
# convenient slow step to hide a wait behind in this job -- so the wait is
390+
# not hidden, it is CONDITIONED.
391+
#
392+
# Polarity, stated because it is the entire hard constraint of #6378: this
393+
# step can only ever turn a red into a green by OBSERVING A LABEL THAT IS
394+
# REALLY THERE. Every other exit -- no PR number, an unreadable label list,
395+
# a closed window, the attempt cap -- writes `skip=false`, i.e. ENFORCE, the
396+
# same #4690 direction the fast-path read already takes. A PR that genuinely
397+
# forgot its changeset and carries no label is therefore still failed,
398+
# having merely been given the same grace period the labelling agent needs.
399+
# Delaying a red by at most a window costs nothing anyone values; letting a
400+
# red through would cost the gate its meaning.
401+
#
402+
# The window is measured from PR CREATION, not from this step, and that is
403+
# what keeps it self-cancelling: on a `synchronize`, a `labeled` run, or any
404+
# `rerun_failed_jobs` replay, `created_at` is long past, the deadline is
405+
# already behind us, and the loop does exactly one read and no sleeping.
406+
# Only a genuinely fresh PR can wait at all. 120s is ~3x the worst label
407+
# latency measured on this repo (~40s, #6358).
408+
- name: Settle the skip-changeset window (only when this PR would otherwise fail)
409+
id: labels_settled
410+
if: >-
411+
steps.labels.outputs.skip != 'true'
412+
&& steps.changeset_count.outputs.added == '0'
413+
env:
414+
GH_TOKEN: ${{ github.token }}
415+
PR_NUMBER: ${{ github.event.pull_request.number }}
416+
PR_CREATED_AT: ${{ github.event.pull_request.created_at }}
417+
WINDOW_SECONDS: '120'
418+
POLL_SECONDS: '10'
419+
run: |
420+
if [ -z "$PR_NUMBER" ]; then
421+
echo "::warning::No PR number on this event, so the label window cannot be settled. Enforcing the changeset check."
422+
echo 'skip=false' >> "$GITHUB_OUTPUT"
423+
exit 0
424+
fi
425+
# An absent or unparseable created_at collapses the deadline to "now":
426+
# one read, no wait. That is the enforcing direction and it matches
427+
# every other unreadable input in this job (#4690).
428+
DEADLINE=0
429+
if [ -n "$PR_CREATED_AT" ] && CREATED=$(date -u -d "$PR_CREATED_AT" +%s 2>/dev/null); then
430+
DEADLINE=$((CREATED + WINDOW_SECONDS))
431+
else
432+
echo "::warning::This event carries no readable pull_request.created_at, so the label window is treated as already closed: one read, no wait."
433+
fi
434+
ATTEMPT=0
435+
while :; do
436+
ATTEMPT=$((ATTEMPT + 1))
437+
if ! LABELS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.labels[].name'); then
438+
echo "::warning::Could not read the labels of PR #$PR_NUMBER (attempt $ATTEMPT), so this run cannot see a 'skip-changeset' applied after the event fired. Enforcing the changeset check."
439+
echo 'skip=false' >> "$GITHUB_OUTPUT"
440+
exit 0
441+
fi
442+
# Whole-line fixed match, fed by a here-string -- deliberately the
443+
# SAME matcher as the fast-path read above, for the same two reasons
444+
# (an array-element match, not a substring, so `skip-changeset-audit`
445+
# is not newly exempted; and `grep -q` kept out of a pipeline so no
446+
# writer can take SIGPIPE under `set -o pipefail`). A divergence
447+
# between the two reads would be a gate that exempts on one path and
448+
# enforces on the other, so check-empty-changeset.mjs pins them equal.
449+
if grep -qxF 'skip-changeset' <<<"$LABELS"; then
450+
echo "::notice::'skip-changeset' is on PR #$PR_NUMBER (read live on attempt $ATTEMPT), so this PR declares no release of its own and the changeset check is exempt."
451+
echo 'skip=true' >> "$GITHUB_OUTPUT"
452+
exit 0
453+
fi
454+
NOW=$(date -u +%s)
455+
# The attempt cap is belt-and-braces next to the deadline: a clock
456+
# that disagrees with GitHub's must not be able to make this loop
457+
# unbounded.
458+
if [ "$NOW" -ge "$DEADLINE" ] || [ "$ATTEMPT" -ge 20 ]; then
459+
break
460+
fi
461+
echo "No 'skip-changeset' on PR #$PR_NUMBER yet (attempt $ATTEMPT); it is still $((DEADLINE - NOW))s inside the label window. Re-reading in ${POLL_SECONDS}s."
462+
sleep "$POLL_SECONDS"
463+
done
464+
echo "Label window closed after $ATTEMPT read(s). Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}"
465+
echo 'skip=false' >> "$GITHUB_OUTPUT"
466+
467+
# The VERDICT. Everything it needs was decided above; this step only
468+
# announces it, which is what makes the failure message a single block of
469+
# prose rather than something interleaved with counting and polling.
470+
- name: Require a changeset (or the skip-changeset label)
471+
if: >-
472+
steps.labels.outputs.skip != 'true'
473+
&& steps.labels_settled.outputs.skip != 'true'
474+
env:
475+
ADDED: ${{ steps.changeset_count.outputs.added }}
476+
run: |
477+
if [ "$ADDED" = 'n/a' ]; then
478+
echo "There is no .changeset directory to count; the counting step above said so and this gate stands aside."
479+
exit 0
480+
fi
344481
if [ "$ADDED" -eq 0 ]; then
345482
# The full comparison goes to the job log — that is what an author
346483
# reading `gh run view --log-failed`, or expanding this step in the
@@ -362,6 +499,13 @@ jobs:
362499
The label is a gate-level exemption. It produces NO input for
363500
changesets/action, so it cannot affect a release.
364501
502+
You do not have to race this run to apply it: the step above waits
503+
out a window measured from PR creation before this verdict is
504+
reached (#6378), so a label applied promptly after 'gh pr create'
505+
is seen by THIS run. If you were slower than that, applying it now
506+
still fires a 'labeled' event and that run goes green -- but the
507+
red left here does not clear itself.
508+
365509
'skills/**' is on that list, and it is spelled out because the git
366510
log says otherwise (#5947). Changes to PUBLISHED skills have
367511
repeatedly shipped with an empty changeset instead -- #4607, #5130,
@@ -373,8 +517,9 @@ jobs:
373517
EITHER, and pays #4898 for the privilege. Take the label.
374518
375519
3. (CLOSED) An empty-frontmatter changeset. Still present in the
376-
repository's history and still counted by this step, but the step
377-
below now REJECTS any that a PR newly adds (#5471). It was never worth
520+
repository's history and still counted by the counting step above,
521+
but the step below now REJECTS any that a PR newly adds (#5471). It
522+
was never worth
378523
taking: it names no package, so its body reaches no CHANGELOG, and it
379524
buys nothing the label does not. What it uniquely buys is risk --
380525
unlike the label it is a REAL INPUT to changesets/action, and when
@@ -392,7 +537,7 @@ jobs:
392537
echo "::error::This PR adds no changeset. If it releases nothing (including any 'skills/**' change -- see #5947), apply the 'skip-changeset' label; otherwise run 'pnpm changeset' and name the packages. An empty-frontmatter changeset is NOT a third option any more: the step below rejects newly added ones (#5471), because it is a real input to changesets/action and an all-empty set stalls the release silently and greenly (#4898). Full comparison in this step's log."
393538
exit 1
394539
fi
395-
echo "This PR adds $ADDED changeset(s)."
540+
echo "This PR adds $ADDED changeset(s), so it declares a release of its own."
396541
397542
# #5471: an empty-frontmatter changeset is rejected when this PR is the one
398543
# introducing it. Ruled 2026-08-06 after the #5292 / PR #5467 prose route
@@ -422,9 +567,10 @@ jobs:
422567
# place the red and green directions are pinned. It builds real temp git
423568
# repositories and costs well under a second.
424569
#
425-
# Label handling: this step carries the same `steps.labels.outputs.skip`
426-
# guard as every step above it, so it honours the LIVE label re-read
427-
# (#5580 / #5625) and a rerun after labelling converges. That leaves one
570+
# Label handling: this step carries the same live-label guard as the verdict
571+
# step above it -- BOTH reads, the fast path and the settling one (#5580 /
572+
# #5625 / #6378) -- so a label that lands inside the window exempts this
573+
# step on the same run, and a rerun after labelling converges. That leaves one
428574
# cell open and it is recorded rather than implied: a PR carrying BOTH the
429575
# `skip-changeset` label AND a new empty changeset is not caught, because
430576
# the whole job is exempt. Closing it would mean running this step outside
@@ -440,7 +586,9 @@ jobs:
440586
# and reports it against an author who never touched the file. Same defect,
441587
# opposite direction (a false RED here, a false GREEN up there), one base.
442588
- name: Reject an empty-frontmatter changeset added by this PR
443-
if: steps.labels.outputs.skip != 'true'
589+
if: >-
590+
steps.labels.outputs.skip != 'true'
591+
&& steps.labels_settled.outputs.skip != 'true'
444592
env:
445593
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
446594
run: |
@@ -478,7 +626,9 @@ jobs:
478626
# pinned, and each of them was verified to flip green when the corresponding
479627
# check is ablated. Real temp git repositories, well under a second.
480628
- name: Require an ADR-0087 disposition on a declared-breaking changeset
481-
if: steps.labels.outputs.skip != 'true'
629+
if: >-
630+
steps.labels.outputs.skip != 'true'
631+
&& steps.labels_settled.outputs.skip != 'true'
482632
env:
483633
MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}
484634
run: |
@@ -491,9 +641,12 @@ jobs:
491641
# version. During the launch window we ship breaking changes as `minor`.
492642
# Add the `allow-major` PR label when a whole-stack major is intended.
493643
#
494-
# The first clause keeps this step exempt exactly when the changeset check
495-
# above is: before #5580 the `skip-changeset` label skipped the whole job,
496-
# this step included, and a live-read label must not quietly re-arm it.
644+
# The first two clauses keep this step exempt exactly when the changeset
645+
# check above is: before #5580 the `skip-changeset` label skipped the whole
646+
# job, this step included, and a live-read label must not quietly re-arm
647+
# it. Both reads are named for the same reason -- after #6378 the exemption
648+
# can be established by either one, and a step that honoured only the fast
649+
# path would re-arm itself on precisely the PRs the settling read rescued.
497650
#
498651
# The second clause still reads the frozen payload, and so still carries
499652
# the #5580 race in its own right: an `allow-major` applied after the event
@@ -506,5 +659,6 @@ jobs:
506659
# cover of another issue is how exemptions grow unnoticed.
507660
if: >-
508661
steps.labels.outputs.skip != 'true'
662+
&& steps.labels_settled.outputs.skip != 'true'
509663
&& !contains(github.event.pull_request.labels.*.name, 'allow-major')
510664
run: node scripts/check-changeset-no-major.mjs

0 commit comments

Comments
 (0)