Uh oh!
There was an error while loading. Please reload this page.
fix(ci): write PR labels additively, never as a whole set - #10777
Conversation
Both label-writing jobs in pr-automation.yml reached
`PUT /issues/{n}/labels` through a third-party action. A whole-set PUT is a
read-modify-write across a network round trip, so it destroys any label that
lands in between. Measured on PR #10698: a seat's additive `skip-changeset`
POST landed at 09:05:29Z and the size labeler's PUT erased it at 09:05:30Z,
which turns a PR that publishes nothing into a false changeset-check red.
Replaces both actions with scripts/pr-labels.mjs, which issues POST and
targeted DELETE only -- neither verb carries a label the writer does not name,
so neither can destroy a concurrent writer's label at any interleaving. The
plan builders are pure and the self-test asserts no plan can emit the
destructive verb; it also replays the #10698 interleaving directly.
Refs #10703
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wtPM review ( |
check:entry-guard rejected the hand-typed `process.argv[1]` comparison the previous commit shipped. The gate is right: node resolves symlinks for the module graph but leaves `process.argv[1]` as the caller typed it, so the hand-typed guard answers false through a symlink and the script does nothing -- exit 0, no output, which a caller reading the status reads as success. Measured on this tree with the two spellings side by side, each reached directly and through a symlink: old-guard -> exit=0 output=RAN old-link -> exit=0 output=(nothing) new-guard -> exit=0 output=RAN new-link -> exit=0 output=RAN `scripts/**` has exactly one sanctioned predicate. Uses it, and drops the now unused pathToFileURL import. The file stays OFF KNOWN_IMPORT_UNSAFE: it exports bindings and the gate still counts 10 known-unsafe (unchanged), with this file among the 75 inert exporters. Verified directly -- importing it with `--size` in argv runs nothing and still yields every export. Refs #10703 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
A third specimen, 13 minutes after I held this PR — the cost of waiting is now measuredAdding this because it bears directly on the scope decision above, and because it happened while that decision was pending rather than in the past. PR #10786 (a
Same second. The size labeler's whole-set PUT erased it, bundled with its own That makes three measured losses to this verb: #5533, #10698, and now #10786. The third is the one that changes the arithmetic, because it is not archaeology — it landed after the fix was written, reviewed and ready, while the PR sat unarmed awaiting a call on scope. What this does and does not changeIt does not change why I held this PR. Replacing two maintained upstream actions with 775 lines of in-repo code is still a maintenance-ownership decision above this seat, and a maintainer may still legitimately prefer the reduced-scope option I named (replace only It does sharpen the cost side of it. The rate is roughly one loss per batch of PRs from this seat, and every one of them is silent unless someone reads the label set back after the bots settle — which is not the happy path any brief describes. My recommendation to land stands, and now rests on a measured recurrence rather than on two historical incidents. One thing worth noting for whoever rules: this PR's own live evidence is the mirror image of the specimen above. Its author applied Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32550920278 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10703
Both label-writing jobs in
.github/workflows/pr-automation.ymlreachedPUT /issues/{n}/labelsthrough a third-party action. A whole-set PUT is a read-modify-write across a network round trip, so it destroys any label that lands in between. This replaces both withscripts/pr-labels.mjs, which issues POST and targeted DELETE only.Premise check — one half of the dispatch brief was wrong
The brief said one of the two PUTs was "a step of ours doing
curl -X PUT" at:16and could be made additive, while the other was third-party. Re-derived againstorigin/mainat01c50322a1: line 16 is not a step. It is a comment line inside the header block, quotingcodelytv/pr-size-labeler's ownsrc/github.sh:68-91. This workflow contained no step of ours that wrote labels at all — a grep for label writes across.github/workflows/**returns only these twouses:lines. So the "one is ours / one is theirs" split does not exist; both were third-party and both needed the same remedy. Both were replaced.The issue's own premise — that the erasure is real and recurring — is confirmed. I re-fetched the #10698 timeline independently and it matches the reported table event-for-event.
The mechanism, reproduced mechanically
Only three label verbs exist and exactly one is destructive:
POST /issues/{n}/labelsDELETE /issues/{n}/labels/{name}PUT /issues/{n}/labelsThe race cannot be re-run live, so it is built from the code path plus the timeline.
github.sh:68-91reads the label set, greps out its own size family, appends, and PUTs. On #10698 that read happened before the seat'sskip-changesetPOST landed, so it read[]and PUT["size/l"]onto a PR that by then carried["skip-changeset"].That interleaving is now a permanent test, not a paragraph.
--self-testreplays it against both shapes:(that is the ablation output — the retired shape, reproduced. The shipped shape returns
["skip-changeset","size/l"].)The write is additive — exact verbs and paths
Driving the real exported plan builders over PR #10698's real file list:
Neither verb carries a label the writer does not name, so neither can destroy a concurrent writer's label — at any interleaving, with no ordering constraint between writers and no window left to narrow. That is the difference from every configuration change before it: correctness no longer depends on timing.
The same run reproduces the retired actions' output exactly on real data:
total modifications = 724 -> size/l(the bot appliedsize/l) andpath labels = [ci/cd](the bot appliedci/cd).Decisions the brief left to me
1. Additive writes, not reconcile-after. Reconcile-after cannot know what to restore: the label it would repair arrives during the window, so it is in neither the before-snapshot nor the after-snapshot, and distinguishing "erased by us" from "deliberately removed by a human" needs a timeline heuristic that is wrong in both directions. Additive writes need no such inference. The triage note reached the same conclusion; direction 2 is not included even as a rider, because a self-healing step on top of a write that can no longer lose anything is machinery guarding nothing.
2. Both writers replaced, not just the measured one.
actions/labeler@v7.0.0is partly mitigated already —labeler.ts:111-133re-reads the live set and carries forward labels added during the run — but the window[re-read .. PUT]stays open, and #5533 lostskip-changesetto exactly this writer. Closing only the measured half would have left a 12-second-later window that has already caused a loss.3. Header prose rewritten. It asserted this half was open and pointed at a closed card, which would have been wrong in a new way. It now documents the verb table, the measured specimen, why this is a fix rather than a narrowing, and what is still open.
4. The reverse race. The labeler removing a size label a seat is concurrently adding: under
sync-labels: falsethe path labeler never removed anything, and it still never does — the path half has no DELETE at all. The size family is owned by this writer, so a seat's hand-appliedsize/xlis still retired when the computed size disagrees. That behaviour is unchanged fromcodelytv(which grepped the family out of its PUT payload); the difference is that its version also carried, and could drop, every bystander label. Noted, not changed — it is the writer removing a label it owns, not an erasure.Behaviour deliberately not carried over
fail_if_xl: 'false'— selected the do-nothing branch.message_if_xl— dead as this workflow configured it.labeler.shcallsadd_label_to_prand only then asks! github::has_label "$pr_number" "$xl_label", i.e. it tests for the label it has just written, so the guard is false and the comment never posts. Reimplementing it would add a comment this repo has never actually seen — a feature request, not a port.github.sh:23caps atper_page=100and never paginates (its own NOTE says so), so a 400-file PR was sized off its first 100 files. This paginates, so a PR over 100 files may now get a larger, correct label.What CI on this PR does and does not prove
pr-automation.ymltriggers on plainpull_request— there is nopull_request_targetand no merge-triggered job in it — andpull_requestworkflows run the head branch's version of the file. So both replaced steps really do execute on this PR, with a real token, against a real PR. The labels this PR carries are written by the new code. That is unusually strong for a workflow change, and it is worth stating precisely because it is normally not true.Not proven here: behaviour under a fork PR (a read-only token), and the
>100-file pagination path (this PR changes 3 files).Live proof: this PR is its own specimen
I applied
skip-changesetwith an additive POST at 11:18:56Z, seconds after opening the PR — deliberately into the window that erased it on #10698. Every label event on this PR since:skip-changesetclaude[bot](additive POST, HTTP 200)size/xlgithub-actions[bot]ci/cdgithub-actions[bot]No
unlabeledevent. The label was written 6 seconds before the size labeler and 23 seconds before the path labeler — squarely inside the window — and survived both. Read back at 11:20:36Z, after the bots settled:ci/cd, size/xl, skip-changeset. Compare #10698, where the gap was one second and the label was gone.The steps that ran are the new ones, by name, all green (run 32476631817):
and their real logs show the verbs actually issued against a real PR with a real token:
Note the third line of each: the writer read
skip-changesetand did not carry it into any write. That is the whole difference. The retired code path would have taken that same read, appended its own label and PUT the union back — and any label arriving after the read would not have been in it.The follow-up push gave a second, independent specimen — the idempotent path. Both writers ran again against the settled set and wrote nothing at all (run 32477473695):
That is the run the retired header called out as "one more chance to erase a concurrent writer in exchange for no new information":
codelytvwould have re-PUT the whole set here. Two full runs of both label writers on this PR, and still zerounlabeledevents.Check Changesetalso went green with the exemption honoured (itsRequire a changesetstep skipped), which is the downstream consequence the card is really about.Gates
node scripts/pm/dispatch-gates.mjsre-derived from the merge base at final commitce525a05d3— editinglint.ymlpulled in two families the dispatch list did not name (check:type-check-coverage,check:type-check-debt). Each gate's verdict line, exit code captured before any pipe:check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declaredcheck:node-versionOK (32 setup-node step(s) across 26 workflow(s), all on Node 22)check:required-contextscheck:shard-attestation2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)check:workflow-status-functionsOK (scanned 26 workflow file(s), 49 job(s) ...)check:type-check-coverageOK — 64/77 workspace packages type-checkedcheck:nul-bytesOK (scanned 6238 text file(s) ... no raw ASCII control bytes)check-aggregator-roster.mjsroster == needs: in both directionsDeclared narrowing:
check:type-check-debtis--self-test && --re-measure. The self-test half passed;--re-measurerefused to run without a built workspace closure and said so explicitly, leaving the ledger untouched. It re-measures tsc error counts for 13 packages, and this diff contains zero TypeScript — a YAML edit and one.mjsscript cannot move a tsc number. I did not build the full closure to satisfy it; CI runs that half with the closure built.Workflow syntax:
actionlintis not installed in this container, so both edited workflows were validated by a real YAML parse (PyYAMLsafe_load), plus the four repo gates that parse workflows for real (check-workflow-status-functions,check-required-contexts,check-aggregator-roster,check-shard-attestation).Reverse verification
Two ablations, each confirmed on disk by grepping the injected and the removed text before running (an editor's exit code is not evidence). No build step is involved — this script runs from source, no
dist/.method: 'POST'→'PUT'inplanSizeWrites(on-disk: PUT 0→1, POST 3→2): self-test exit 1, 4 failures, including the interleaving replay showing["size/l"]where["skip-changeset","size/l"]was expected — the defect itself.**zero-segment case broken (k = j→k = j + 1): self-test exit 1, 3 failures, exactly the assertions where**must match zero segments.matches through the parsed configcorrectly stayed green — that input has enough segments that the mutation does not change its answer.Both restored and proved restored (grep counts back,
git diffempty, self-test exit 0).Follow-up commit: the entry guard (
e0accbffc7)check:entry-guardwent red on the first commit, and it was right.ce525a05d3guarded its CLI dispatch with a hand-typedprocess.argv[1]comparison, andscripts/**has exactly one sanctioned predicate. That spelling avoided the percent-encoding trap (pathToFileURL(...).hrefencodes, so a#in a parent directory is harmless), but it still fails the symlink direction: node resolves symlinks for the module graph and leavesprocess.argv[1]as the caller typed it. Measured here with both spellings side by side, each reached directly and through a symlink:That is the silent-success direction this repo treats as worse than no check at all — a caller reading
result.statusgets a green from a tool that never ran. Nowimport { isEntrypoint } from './invoked-as.mjs';, with the newly unusedpathToFileURLimport dropped.The file stays off
KNOWN_IMPORT_UNSAFE: the gate still reports10 known-unsafe(unchanged) with this file among the 75 inert exporters. Verified directly rather than inferred — importing it with--sizein argv runs nothing and still yields all eight exports.Gates re-run at
e0accbffc7, exit codes captured before any pipe:check:entry-guard0 (both legs —47 cases pass, thenevery entry guard goes through invoked-as.mjs; 85 export bindings, 75 of them inert on import),check:parse-guard0,check:type-check-coverage0,check:nul-bytes0,node scripts/pr-labels.mjs --self-test0. The families in the table above were re-run unchanged.Why it was absent from my gate list.
node scripts/pm/dispatch-gates.mjsnever named it — and still does not, re-derived ate0accbffc7with the file committed and tracked (grep -c entry-guardover the output:0).--residueplaces it in Silent, because the derivation reads the gate's declared population offKNOWN_IMPORT_UNSAFE(check-entry-guard.mjs:437-448) — an enumeration of the ten files that already violate the rule. A newly added script can never be in that list, so the derivation returns its weakest verdict for exactly the case most likely to fire. Its siblingcheck:parse-guardcovers the same directory and fails the opposite way, reportedUnreachable — dead: 'scripts'. Bothscripts/**gates are invisible to the derivation, by opposite routes. Filed as #10784; I rancheck:parse-guardhere by hand for the same reason.Still open
This file no longer writes a whole set, but nothing mechanically stops a future workflow, action or agent from doing so. There is no repo gate that bans the verb; the header paragraph and this script's self-test are the whole guard. Filed as a follow-up rather than added here, because it needs a
check:*entry in the rootpackage.json, which is fenced by the @changesets/cli v3 migration lane.Fenced surfaces were not touched: the
Check Changesetjob and its steps, and rootpackage.json. The self-test is wired intolint.yml'sLint & Repo Gatesjob by directnode scripts/...invocation for exactly that reason — and it needs a required-context home anyway, sinceCheck PR Sizeis deliberately excluded from the required set.Generated by Claude Code
Generated by Claude Code