ci: hourly full run on main, push narrows to the affected set, and a filer for a red one - #16991
Merged
Merged
Conversation
- push on main computes the Test Core package set with --affected against github.event.before; a zero or unresolvable sha falls back to the full list, loudly. - schedule '0 * * * *' + workflow_dispatch on ci.yml and lint.yml run the full battery, with their own concurrency group so a merge cannot cancel them. - select-shard-timings-run reads the scheduled run, not the push run. - a new workflow_run filer opens or refreshes one deduplicated card per watched workflow when a scheduled run is red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
fileOrRefreshCard holds the whole create-or-refresh judgement, so the acceptance property (one red run files one card; the next refreshes it and files no second) is driven by the self-test against a mutable board rather than asserted in a workflow comment. The filer keeps only the API callbacks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
The refresh lane read every non-zero from the run selector as a refusal, so an EMPTY candidate list printed "NO ELIGIBLE RUN among the 0 ... every one was censored, failed, or has lost its artifacts" and failed the job. Nothing had been examined, and none of those three causes had occurred. Split into two legs with two exits and two messages: an empty candidate list exits EXIT_PREREQUISITE_NOT_MET (3, the repo-wide code), candidates that existed and were all rejected keep exit 1 and keep the sentence naming the causes that now apply. The workflow reads 3 as NOT MEASURED, leaves the dataset alone, stays green, and says loudly in its annotation and its step summary that a persistent NOT MEASURED is a defect rather than a steady state. The step's own run block is lifted out of the YAML and driven under bash against a stub node on all three exit codes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
baozhoutao
marked this pull request as ready for review
September 9, 2026 00:50
baozhoutao
enabled auto-merge
September 9, 2026 00:51
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #16467
pushonmainre-ran the full Test Core battery on a tree the merge queue had just verified — ~90 machine-hours a day, and 36 of the last 60 runs were cancelled by the next merge before finishing. This narrowspushto the affected set and puts the full battery on an hourly clock, on both required-context workflows, with a filer that reports a red one.The three parts land together on purpose. Narrowing
pushwhile nothing runs the full battery and nothing files on red would leavemainless tested than today and unwatched — strictly worse than not doing the card.What changed
ci.ymlpushselection narrows to--affectedagainstgithub.event.before;schedule: '0 * * * *'+workflow_dispatchadded;github.event_nameadded to the concurrency key; the paths-filterif:becomes an allow-listlint.ymlschedule+workflow_dispatch, and the same concurrency key changescripts/ci/select-shard-packages.shpushbranch keyed onOS_SHARD_PUSH_BEFORE_SHA; both prose sites that called the push run the ground truth are correctedscripts/ci/select-shard-timings-run.mjsevent=schedule, notevent=push.github/workflows/shard-timings-refresh.yml--limit 15→24(the artifact retention window now holds ~24 hourly runs)scripts/ci/scheduled-full-run.mjs(new).github/workflows/scheduled-full-run-card.yml(new)workflow_runonCIandLint & Type CheckThe two hazards the card did not name
HAZARD 1 — the hourly run would have been cancelled by the next merge. On a
scheduleeventgithub.event.pull_request.numberis empty andgithub.refisrefs/heads/main, so the old two-segment key produced a group byte-identical to a push tomain, undercancel-in-progress: true. Fixed by puttinggithub.event_namein the key, and asserted rather than read:scripts/ci/scheduled-full-run.mjs --check-concurrencyevaluates each file'sconcurrency.groupexpression against a push-shaped and a schedule-shaped context and reds when they are equal.Ablation (mutation confirmed on disk, restored and hash-verified byte-identical): reverting
ci.yml's key to the pre-card two-segment form makes both the gate and its self-test red, naming the pair —The gate also refuses the two degenerate ways to make the groups differ — a run-unique key (
github.run_id) and a constant key — because both switchcancel-in-progressoff or on for every event in the file. The expression evaluator throws on any token outside its grammar rather than guessing a value.HAZARD 2 — the timings dataset would have been regenerated from a partial measurement.
select-shard-timings-run.mjshard-coded?event=push&branch=main, and its eligibility test is "six shard jobs succeeded + artifacts present". Under an affected-onlypushthat test still reads green over a measurement of whatever the last merge touched.coverageReportis not a backstop either: a package the affected set skipped is a turbo cache HIT, carried at its old weight, which is a pass. The selector now namesDEFAULT_RUN_EVENT = 'schedule'.A new self-test battery drives
listCandidatesagainst an injectedfetchImplthat serves both a schedule-shaped and a push-shaped run, both fully eligible:event=push.{event: 'push'}explicitly returns the push run, and that run is judgedeligible: true. So the zero above is a reading about the query, not a mute fixture, and eligibility demonstrably cannot tell the two apart.Ablation, behavioural. Setting
DEFAULT_RUN_EVENTback to'push'and neutralising both identity checks, so only the behavioural cases can fire:100is the push-shaped run's id. Restored and hash-verified byte-identical; the self-test is green again.There is also a ⛔ in
shard-timings-refresh.ymlthat is now load-bearing:ci.yml'sSave Turbo cache (main only)step must staygithub.event_name == 'push'. If the hourly run seeded the cache it restores, the next hourly run would replay almost the whole workspace, the generator would refuse every replayed task, and this lane would measure nothing — by construction, every hour. Affected-only pushes seeding a narrower cache is the direction that helps: it leaves more real misses for the hourly run to time.Negative control —
pull_requestandmerge_groupselection is byte-identicalMeasured, not asserted. The new self-test harness was run twice over one fixture, changing only the script under test: the pre-card
select-shard-packages.sh(extracted from the merge base) and the one in this PR. Transcripts were compared per case, with 40-hex shas normalised.The firing control is what makes the 19 a reading: the same comparison separates 8 of the 12 push/schedule cases. The 4 that match are
schedule,workflow_dispatchand an unlisted event, which correctly fell to the full list under both scripts.The permanent half of that control is in the selftest itself: one
pull_requestcase and onemerge_groupcase are driven withOS_SHARD_PUSH_BEFORE_SHAset to a real, resolvable commit — the value that would move the answer if it leaked — and pinned to the same diff base, the same union input and the same package set.select-shard-packages.shonpushEvery fallback is the FULL list plus a
::warning::; no branch selects a narrower set quietly.beforeshabeforefieldmerge_group; the #10057 guard stayspull_request-only, where an empty diff is impossible by constructionBattery floor raised 20 cases / 80 checks → 32 / 158, at the measured values.
The filer — a new workflow, and why not an extension
merge-queue-triage.ymlwas the candidate. It is 911 lines about the merge queue; its entireif:isevent == 'merge_group', its subject is a PR comment, and itsworkflows: [CI]list would have had to grow a second entry whose events it must then exclude everywhere. Two products with disjoint triggers in one file buys nothing and makes both harder to read. The new file followstest-nightly-tiers.yml's shape instead.Two cards, not one.
CIandLint & Type Checkgo red for unrelated reasons and the body is rewritten on a refresh, so a shared identity would make whichever filer ran second overwrite the other's diagnosis. Identity is the workflow name; "one red scheduled run files exactly one card" holds per run, which is the unit that is red.De-dup is tested, not described. Identity, matching, paging and the whole create-or-refresh decision live in
scheduled-full-run.mjsand are driven offline — aworkflow_runworkflow only ever runs from the default branch, so nothing on a PR can exercise the live path. The acceptance property is driven end to end against a mutable board:filed, board holds 1 card, labels applied on create.refreshed, same number, board still holds 1 card, body rewritten.filed(its own card), board holds 2, and CI's body untouched.refreshed, still 2 cards.Plus the things that are not this card: a pull request carrying the title, the sibling workflow's card, an unrelated issue. And a scan that hits its page bound throws instead of filing — a truncated scan has not established absence, and filing on an unestablished absence is how a filer mints a duplicate an hour.
The body carries the run link and the commits between the previous green
schedulerun of that workflow and this one. An empty range is its own reading — same tree, green then red, so it is a flake or infrastructure and nobody should go hunting a commit — and a failed lookup is deliberately not allowed to fall through to that sentence.Two things the card did not mention that had to change
dorny/paths-filter'sif:had to become an allow-list. It wasgithub.event_name != 'merge_group'. The action resolves a diff only onpull_requestandpush; onscheduleit would have run with no base, every filter output would have come backfalse, and|| 'true'supplies nothing when the output exists and saysfalse— so every downstream!= 'false'guard would have skipped. The hourly full run would have been an entirely green, entirely empty run of nothing at all. It is nowevent_name == 'pull_request' || event_name == 'push', so each new event fails toward THE FILTER CONTRACT's half 1 instead.--limit 15→24in the refresh workflow. The window that matters is the 1-day artifact retention, which now holds ~24 hourly runs; coverage is accumulated across runs, so examining fewer than the window holds is coverage left on the table.lint.ymlneeds no step changes:select-gate-families.shalready runs every family for any event that is neithermerge_groupnorpull_request.Verification
Gate families derived with
node scripts/pm/dispatch-gates.mjs --commands(no hand-written path list) and reconciled with--ran, at final headf6a2737a62:58 exit 0. 5 exit 3 = PREREQUISITE NOT MET, not red, all from an unbuilt workspace:
check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:sourcemap-no-sources-content,check:type-check-debt(--re-measureneeds 32 built dependency entry points). This diff contains zeropackages/**source, so none of them can move; CI builds and runs them. Reported as NOT MEASURED rather than as a pass.Repo-scoped lint ran in full — no narrowing was needed:
6390 is the receiving population as eslint resolved it from
eslint.config.mjs, read out of its own JSON reporter, at final headf6a2737a62.Directly affected families, re-run after the last content change, all exit 0:
scheduled-full-run --self-test/--check-concurrency,select-shard-timings-run --self-test,check:select-shard-packages,check:select-gate-families,check-self-test-wired,check-self-test-workflow-commands,check-ci-filter-parity,check:required-contexts,check:nul-bytes,check:workflow-status-functions,check:pm-dispatch-gates,check-step-collectors,check-scripts-symbol-anchors,check-whole-set-label-write,check:watch-hint-literal,check:declared-population-live,check-comment-mask-corpus.Control-character sweep over every changed file (
grep -naPfor the C0 set plus DEL): no matches.Patch round — zero candidate runs is NOT MEASURED, not a finding
Regenerate the shard-timings datasetwent red on the first push of this branch, and the number in its log is the whole story:0. Nothing was examined —
schedule:does not exist onmainuntil this merges — and the sentence named three causes, none of which had occurred. It also would not have cleared at merge: there is a bootstrap window of at least an hour before the first hourly run finishes and is downloadable.⛔ Not fixed by falling back to
event=push; that is the partial-measurement hazard this card exists to close. Fixed with the idiom #16558 / PR #16970 landed:EXIT_PREREQUISITE_NOT_MET(3) fromscripts/import-prerequisite.mjs, imported rather than re-picked.shard-timings-refresh.ymlreads 3 as NOT MEASURED: it does not regenerate, does not commit, does not fail —generateandcomparecarryif: steps.select.outputs.not_measured != 'true', and everything aftercompareis already gated onsteps.compare.outputs.changed, which is the empty string whencompareis skipped.⛔ And it is never quiet. A
::warning::annotation, a step-summary section and the selector's own refusal text, all carrying: a PERSISTENT NOT MEASURED is a defect, not a steady state — if it is still saying zero once the hourly run has been live a few hours, the trigger is gone or every run is being cancelled, and the dataset is ageing out while the job reports green.The consumer is driven, not read
The step's
run:block is lifted out of the YAML byextractStepScriptand executed underbash -eagainst a stubnodethat answers with the exit code each case is about — three legs, because a branch tested only on the path it was written for is a branch nobody has seen fail:not_measured=true, an annotation is minted, and the summary carries the persistent-is-a-defect sentence and the selector's verbatim refusal.::error::, andnot_measuredis not set. A finding is still a finding.Plus a nonsense control: extracting a step name that is not in the file throws rather than returning an empty script that would make every assertion vacuous. And the YAML half is pinned — both
generateandcomparemust carry the guard.Ablations (each mutation confirmed on disk by counting both spellings; each restored and
git hash-object-verified byte-identical):Battery floors: new
'select-shard-timings-run empty vs ineligible': 17and'shard-timings-refresh NOT MEASURED path': 18,SELF_TEST_BATTERY_FLOOR2 → 4, all at measured values.Confirmed on the runner, not only offline
Regenerate the shard-timings dataseton head428c04dbcb: success, job102289511470. Its log takes the new path —Every check on that head has settled: 31 success, 2 skipped, 0 failing (the two skips are
Check Changeset, skipped byskip-changeset, and the opt-in packed-tarball smoke).dispatch-gates --ranat that head: 63 derived, 63 run, 0 UNRUN — 58 exit 0, the same 5 exit-3 PREREQUISITE NOT MET from the unbuilt workspace.pnpm exec eslint . --no-inline-configEXIT=0 over 6390 files, 0 errors, 0 warnings.Landing
Authored in Claude Code session
session_012GKcPZbMoGq7WPzKLfRBTU.Draft.
.github/workflows/**andscripts/ci/**only — no governed surface, no published package moves, soskip-changesetapplies. Landing is the PM's.Generated by Claude Code