Skip to content

Commit ffbbc03

Browse files
committed
ci: address serve-filter review — canary docs, cardinality test, gate order (EAI-7746)
Follow-up to the serve paths-filter + PR canary change, addressing review findings on the same PR: - Document @canary and @serves-on-gpu in the e2e-cucumber README tag table, and correct the now-stale "each job runs the whole suite" text and job table to reflect PR canary mode (MI300X runs only @canary) and the Strix lanes skipping on pull_request. - Add a unit test that parses the real .feature files and asserts exactly one @canary scenario, so a later refactor dropping/renaming the tag fails cheaply instead of silently emptying the pre-merge serve smoke. - Move the canary-mode skip after the host-applicability checks in resolve() so a scenario inapplicable for a hard reason (no GPU / wrong OS) reports that reason rather than the canary skip. - Make the merge_group gating comment honest: the Strix lanes are continue-on-error on merge_group too, so they are not a gating backstop — a regression surfaces at nightly, a deliberate trade for serial hardware. Signed-off-by: fredespi <fredrik.espinoza@gmail.com>
1 parent 937c064 commit ffbbc03

3 files changed

Lines changed: 98 additions & 26 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,15 @@ jobs:
967967
# can still target it. The job is required-but-continue-on-error, so skipping
968968
# it on a PR reports the required check as skipped (branch protection
969969
# satisfied) without running the hardware.
970+
#
971+
# NOT a gating backstop: this lane is `continue-on-error` on merge_group too,
972+
# so a Strix (lemonade / Windows) serve regression skips on the PR, fails
973+
# non-blocking in the queue, and still lands on main — first hard signal is
974+
# the nightly run, a day later, decoupled from the causing PR. Deliberate
975+
# trade for scarce serial Strix hardware (a per-PR real Strix serve is the
976+
# cost this change removes); the Strix lanes are proving-out and non-blocking
977+
# by design. Drop `continue-on-error` on the merge_group path if/when they
978+
# graduate to a true pre-merge gate.
970979
if: >-
971980
always()
972981
&& needs.changes.result == 'success'

tests/e2e-cucumber/README.md

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,14 @@ E2E_INCLUDE_LIFECYCLE=1 E2E_ONLY_LIFECYCLE=1 cargo xtask e2e
8888

8989
## Tags and per-scenario expectations
9090

91-
There is no tag-filter tiering. Each CI job runs the **whole** suite
92-
(`cargo xtask e2e`, no `-t` filter); the harness resolves every scenario to
93-
**pass / xfail / skip** at runtime from its capability tags plus the known-bug
94-
matrix, then reconciles the actual result against that expectation.
91+
There is no tag-filter tiering: a job runs `cargo xtask e2e` with no `-t` filter,
92+
and the harness resolves every scenario to **pass / xfail / skip** at runtime from
93+
its capability tags plus the known-bug matrix, then reconciles the actual result
94+
against that expectation. Two env-gated modes narrow *which* scenarios actually
95+
run (still via resolution, not a CLI filter): `E2E_PR_CANARY=1` on the per-PR
96+
MI300X lane runs only the `@canary` serve (see the job table below), and
97+
`E2E_INCLUDE_NIGHTLY` / `E2E_INCLUDE_LIFECYCLE` opt expensive scenarios in. Off
98+
those modes (merge queue, push, nightly, dispatch) a job runs the whole suite.
9599

96100
Scenarios carry stable-id and capability tags:
97101

@@ -104,6 +108,8 @@ Scenarios carry stable-id and capability tags:
104108
| `@serve-timeout:<secs>` | Lengthen the serve-readiness wait for a genuinely slow serve (e.g. a large model). |
105109
| `@nightly` | Expensive scenario skipped by default; included when `E2E_INCLUDE_NIGHTLY=1`. |
106110
| `@lifecycle` | Expensive, OS-mutating release-lifecycle scenario (packaging + real installer + install/uninstall). Skipped by default; included when `E2E_INCLUDE_LIFECYCLE=1`. `E2E_ONLY_LIFECYCLE=1` selects only this set without bypassing expectation resolution. |
111+
| `@canary` | The single minimal real-serve scenario used as the per-PR pre-merge smoke on the MI300X lane. In canary mode (`E2E_PR_CANARY=1`) it is the **only** GPU-serving scenario that runs; every other one resolves to skip. Exactly one scenario must carry this tag (enforced by a unit test). |
112+
| `@serves-on-gpu` | Marks a scenario that does a **real** `rocm serve` on a GPU host even though it is not `@requires-gpu` (its assertion is engine-agnostic, so it also runs mock-backed on the no-GPU lane). Needed so canary mode skips it too; without the tag it would launch an extra real serve on every PR canary run. |
107113

108114
Known bugs are **not** tagged in the `.feature` files — they live in
109115
`expectations.toml`, keyed by `@id`, each with a `when = { ... }` condition (e.g.
@@ -122,18 +128,28 @@ serve does not compete with the first for device memory, and the failure quotes
122128
the service log tail plus the device's free-VRAM state, which is where the
123129
engine's own reason for the stall is recorded.
124130

125-
CI runs one job per platform, each executing the full suite:
126-
127-
| Job | Platform | Blocking |
128-
|---|---|---|
129-
| `e2e` | Mock (no GPU, GitHub-hosted) | yes |
130-
| `e2e-gpu` | MI300X (self-hosted) | no |
131-
| `e2e-gpu-strix-ubuntu` | Strix Halo / Ubuntu (self-hosted) | no |
132-
| `e2e-gpu-strix-windows` | Strix Halo / Windows (self-hosted) | no |
133-
134-
The blocking mock job passes when every applicable scenario is pass-or-xfail with
135-
no XPASS or unexpected failure; the GPU jobs are non-blocking. The `e2e-report`
136-
job consolidates all platforms' results into one cross-platform report.
131+
CI runs one job per platform. Because real GPU serves are the wall-clock long
132+
pole on scarce serial hardware, the heavy GPU lanes are narrowed on a
133+
`pull_request` (see the `changes` job's `serve` paths-filter and the per-job
134+
`if:`/`E2E_PR_CANARY` gating in `ci.yml`):
135+
136+
| Job | Platform | On `pull_request` | On merge_group / push / dispatch | Blocking |
137+
|---|---|---|---|---|
138+
| `e2e` | Mock (no GPU, GitHub-hosted) | full suite | full suite | yes |
139+
| `e2e-gpu` | MI300X (self-hosted) | `@canary` scenario only (canary mode) | full suite | no |
140+
| `e2e-gpu-strix-ubuntu` | Strix Halo / Ubuntu (self-hosted) | skipped | full suite | no |
141+
| `e2e-gpu-strix-windows` | Strix Halo / Windows (self-hosted) | skipped | full suite | no |
142+
143+
Only serve-affecting PRs run the GPU lanes at all: they are gated on the `serve`
144+
paths-filter, so a PR that can't touch serving skips the whole matrix. When a GPU
145+
lane does run on a PR, only the MI300X `@canary` serve executes as a pre-merge
146+
smoke; the two Strix lanes skip on PRs (their required checks report as skipped,
147+
satisfying branch protection) and run the full suite in the merge queue and on
148+
push. The blocking mock job passes when every applicable scenario is pass-or-xfail
149+
with no XPASS or unexpected failure; the GPU jobs are non-blocking
150+
(`continue-on-error`) on every trigger — including merge_group — so they surface
151+
regressions without gating the queue. The `e2e-report` job consolidates all
152+
platforms' results into one cross-platform report.
137153

138154
The nightly workflow runs three non-blocking jobs — the existing MI300X job and
139155
new Strix Halo jobs on Ubuntu and Windows — with `E2E_INCLUDE_NIGHTLY=1`. The

tests/e2e-cucumber/src/expectation.rs

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -369,16 +369,6 @@ pub fn resolve(
369369
reason: "lifecycle-only scenario; set E2E_INCLUDE_LIFECYCLE=1 to run".to_owned(),
370370
};
371371
}
372-
// In PR canary mode, only the @canary scenario serves; skip every other
373-
// scenario that would real-serve on this GPU host — both `@requires-gpu`
374-
// scenarios and `@serves-on-gpu` ones (untagged-for-GPU scenarios that still
375-
// launch a real serve when a GPU is present, e.g. the chat behavioural
376-
// scenarios). Purely non-GPU scenarios are unaffected — they still run.
377-
if canary_mode && (decl.requires_gpu || decl.serves_on_gpu) && !decl.canary {
378-
return Expectation::Skip {
379-
reason: "non-canary GPU-serving scenario; skipped in PR canary mode".to_owned(),
380-
};
381-
}
382372
if decl.requires_gpu && !cap.has_amd_gpu {
383373
return Expectation::Skip {
384374
reason: "requires an AMD GPU; none detected on this host".to_owned(),
@@ -396,6 +386,18 @@ pub fn resolve(
396386
reason: format!("requires os '{os}'; this host is '{}'", cap.os_family),
397387
};
398388
}
389+
// In PR canary mode, only the @canary scenario serves; skip every other
390+
// scenario that would real-serve on this GPU host — both `@requires-gpu`
391+
// scenarios and `@serves-on-gpu` ones (untagged-for-GPU scenarios that still
392+
// launch a real serve when a GPU is present, e.g. the chat behavioural
393+
// scenarios). Purely non-GPU scenarios are unaffected — they still run. This
394+
// sits AFTER the host-applicability checks (no-GPU / OS) so a scenario that
395+
// is inapplicable for a hard reason reports THAT reason, not the canary skip.
396+
if canary_mode && (decl.requires_gpu || decl.serves_on_gpu) && !decl.canary {
397+
return Expectation::Skip {
398+
reason: "non-canary GPU-serving scenario; skipped in PR canary mode".to_owned(),
399+
};
400+
}
399401
let engine = decl.effective_engine(cap);
400402
// A scenario that pins or defaults to a real engine and would actually serve
401403
// needs that engine to be startable here. We treat any GPU scenario with a
@@ -664,6 +666,51 @@ serve_timeout_secs = 90
664666
);
665667
}
666668

669+
// Guards the canary mechanism against silent decay. `E2E_PR_CANARY` skips
670+
// every GPU-serving scenario except the one `@canary`; if a later refactor
671+
// drops or renames that tag, the per-PR MI300X lane would skip everything,
672+
// still write a valid platform.json, reconcile clean, and go green in ~2min —
673+
// silently testing nothing. Parse the real .feature files and assert exactly
674+
// one scenario carries `@canary` so that regression fails a cheap unit test
675+
// instead of quietly gutting the pre-merge serve smoke.
676+
#[test]
677+
fn exactly_one_canary_scenario_across_feature_files() {
678+
let features_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("features");
679+
let mut canary_ids: Vec<String> = Vec::new();
680+
// A scenario's tags are the whitespace-separated `@tag` tokens on the
681+
// line(s) immediately preceding its `Scenario:`. We only inspect lines
682+
// whose first non-space char is `@` (real tag lines) — `#` comment lines
683+
// that merely mention `@canary` in prose are ignored, and the tag line's
684+
// own `@id:<slug>` gives us the scenario id for a useful failure message.
685+
for entry in std::fs::read_dir(&features_dir)
686+
.expect("read features dir")
687+
.flatten()
688+
{
689+
let path = entry.path();
690+
if path.extension().and_then(|e| e.to_str()) != Some("feature") {
691+
continue;
692+
}
693+
let text = std::fs::read_to_string(&path).expect("read feature file");
694+
for line in text.lines() {
695+
let trimmed = line.trim_start();
696+
if !trimmed.starts_with('@') {
697+
continue;
698+
}
699+
let tags: Vec<&str> = trimmed.split_whitespace().collect();
700+
let decl = ScenarioDecl::from_tags(&tags);
701+
if decl.canary {
702+
canary_ids.push(decl.id.unwrap_or_else(|| format!("<no @id> in {path:?}")));
703+
}
704+
}
705+
}
706+
assert_eq!(
707+
canary_ids.len(),
708+
1,
709+
"expected exactly one @canary scenario, found {}: {canary_ids:?}",
710+
canary_ids.len(),
711+
);
712+
}
713+
667714
#[test]
668715
fn effective_engine_prefers_explicit_pin() {
669716
let d = decl(&["id:x", "requires-gpu", "requires-engine:vllm"]);

0 commit comments

Comments
 (0)