feat(vllm): Tackle out of memory errors (EAI-8058) #626
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
| name: E2E self-hosted | |
| # Self-hosted GPU E2E lanes, split out of ci.yml on purpose. A job queued on an | |
| # OFFLINE self-hosted runner cannot be cancelled by GitHub, so if it shared | |
| # ci.yml's concurrency group a superseded run would hold that group forever and | |
| # the newer run's merge-required (GitHub-hosted) checks would sit pending with | |
| # zero jobs (observed on PR #138). Isolating these lanes in their own workflow — | |
| # with their OWN concurrency group — means an offline runner can only ever stall | |
| # THIS workflow's supersession, never the required checks in ci.yml. | |
| # | |
| # These lanes are non-blocking (continue-on-error). NOTE: their check names | |
| # (`E2E tests (GPU)` etc.) are still in main's required-status-check list, so | |
| # while a runner is offline they report as missing and can still block a merge; | |
| # fully closing that requires removing them from the required list (a separate | |
| # branch-protection change, out of scope for this workflow). | |
| on: | |
| push: | |
| branches: [main] | |
| # Mirror ci.yml: run on PRs against ANY base branch so a stacked PR still gets | |
| # the GPU lanes. Affected-crate/heavy selection diffs against github.base_ref. | |
| pull_request: | |
| merge_group: | |
| branches: [main] | |
| types: [checks_requested] | |
| # On-demand GPU E2E without a push/PR. Dispatch against any ref | |
| # (`gh workflow run e2e-selfhosted.yml --ref <branch> -f platform=... `) — the | |
| # mock lane's dispatch stays on ci.yml; this one covers the self-hosted runners. | |
| workflow_dispatch: | |
| inputs: | |
| platform: | |
| description: Which self-hosted runner(s) to target | |
| type: choice | |
| default: all | |
| options: [all, app-dev-gpu, strix-ubuntu, strix-windows, strix-wsl] | |
| name_filter: | |
| description: "Scenario-name regex (cucumber --name); empty = full suite" | |
| type: string | |
| default: "" | |
| include_nightly: | |
| description: "Include @nightly scenarios (large-model serve, cold install)" | |
| type: boolean | |
| default: false | |
| concurrency: | |
| # Own group, namespaced by this workflow so it is DISTINCT from ci.yml's shared | |
| # group — the whole point of the split. Manual dispatches still get a unique | |
| # (run_id) group so a stuck dispatch never blocks later dispatches. | |
| group: >- | |
| ${{ github.workflow }}-${{ github.ref }}-${{ | |
| github.event_name == 'workflow_dispatch' && github.run_id || 'shared' }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| # Trimmed copy of ci.yml's `changes` job: cross-workflow `needs` is impossible, | |
| # so this workflow computes its own `heavy` gate to avoid running the expensive | |
| # GPU lanes on a doc-only PR. Off pull_request (push/merge_group) heavy is | |
| # forced true, matching ci.yml. | |
| changes: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| heavy: ${{ steps.filter.outputs.heavy || steps.all.outputs.forced }} | |
| # Narrow GPU-serve gate; forced true off-PR so the merge queue always runs | |
| # the full matrix and its required checks are never starved. | |
| serve: ${{ steps.filter.outputs.serve || steps.all.outputs.forced }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Filter changed paths (pull requests only) | |
| id: filter | |
| if: github.event_name == 'pull_request' | |
| uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 | |
| with: | |
| filters: | | |
| # Same `heavy` set as ci.yml: anything that can affect the E2E suite. | |
| heavy: | |
| - '**/*.rs' | |
| - '**/Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'rust-toolchain*' | |
| - 'scripts/**' | |
| - 'xtask/**' | |
| - 'engines/**' | |
| - '**/*.py' | |
| - '**/*.sh' | |
| - '**/*.ps1' | |
| - '**/*.feature' | |
| - 'tests/e2e-cucumber/**' | |
| - 'install*' | |
| - 'docs/keys/**' | |
| - '.github/workflows/**' | |
| # The real-GPU serve matrix (e2e-gpu*). Narrower than `heavy`: only | |
| # paths that can change serve BEHAVIOUR or the GPU E2E harness — NOT a | |
| # blanket `**/*.rs`. Compile coverage for every crate already runs on | |
| # ci.yml's always-on build/test lanes, so a dash-only or unrelated- | |
| # crate Rust PR need not fire the heavy serve matrix. Err toward | |
| # inclusion — Cargo.lock, the toolchain, and the workflow itself are | |
| # broad safety nets so a transitive-dep or CI change still runs the | |
| # matrix. (Excluded on purpose: crates/rocm-dash-* — they build into | |
| # `rocm` but cannot change serve behaviour.) | |
| serve: | |
| - 'engines/**' | |
| - 'crates/rocm-core/**' | |
| - 'crates/rocm-engine-protocol/**' | |
| - 'apps/rocm/**' | |
| - 'apps/rocmd/**' | |
| - 'tests/e2e-cucumber/**' | |
| - 'crates/e2e-report/**' | |
| - 'xtask/**' | |
| - '**/*.feature' | |
| - 'scripts/**' | |
| # Root manifest only (NOT `**/Cargo.toml` — that would re-include | |
| # the excluded dash crates): `[workspace.dependencies]` edits here | |
| # can change a serving crate's deps without touching Cargo.lock. | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'rust-toolchain*' | |
| - '.github/workflows/**' | |
| - name: Force full run off pull requests | |
| id: all | |
| if: github.event_name != 'pull_request' | |
| run: echo "forced=true" >> "$GITHUB_OUTPUT" | |
| e2e-gpu: | |
| name: E2E tests (GPU) | |
| timeout-minutes: 90 | |
| runs-on: [self-hosted, linux, amd-gpu] | |
| needs: [changes] | |
| # No build-and-test gate (cross-workflow needs is unavailable): the job builds | |
| # the rocm binary itself and is continue-on-error; ci.yml's required | |
| # build-and-test / mock e2e remain the authoritative pre-merge build gate. | |
| if: >- | |
| always() | |
| && needs.changes.result == 'success' | |
| && ( | |
| (github.event_name != 'workflow_dispatch' | |
| && needs.changes.outputs.serve == 'true') | |
| || (github.event_name == 'workflow_dispatch' | |
| && (inputs.platform == 'all' || inputs.platform == 'app-dev-gpu')) | |
| ) | |
| continue-on-error: true | |
| env: | |
| # Bound serve readiness below the 35-min job cap so a serve that never comes | |
| # ready fails the scenario with a real error instead of hanging until the | |
| # job is cancelled. 300s is ample for a real MI300X vLLM cold-start; | |
| # per-scenario overrides in expectations.toml / a `@serve-timeout` tag adjust | |
| # it (shorter for known bugs, longer for large models). | |
| E2E_SERVE_TIMEOUT_SECS: "300" | |
| # Opt-in @nightly scenarios on a manual dispatch (default off). The nightly | |
| # workflow sets this unconditionally; here it lets a scoped dispatch confirm | |
| # a single nightly scenario (e.g. the 27B serve) without the full nightly run. | |
| E2E_INCLUDE_NIGHTLY: "${{ inputs.include_nightly && '1' || '' }}" | |
| # Opt-in @merge-queue serves: the heavy real serves (default-engine + | |
| # readiness) run only in the merge queue, where a cheaper per-engine canary | |
| # (scenarios 5 vLLM / 7 lemonade) has already guarded the PR. This keeps the | |
| # per-PR GPU run short while still exercising the full serve matrix before a | |
| # change lands. Only set on the `merge_group` event. | |
| E2E_MERGE_QUEUE: "${{ github.event_name == 'merge_group' && '1' || '' }}" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Reclaim the GPU before running: a serve leaked by a killed/timed-out prior | |
| # run (its Drop teardown never executed) can keep an engine process spinning | |
| # on the GPU, starving this job's serves until it hits the timeout. Kill only | |
| # e2e leftovers — scoped to /tmp/rocm-e2e-* and the e2e-target/e2e-shared | |
| # trees — never the runner or any /workload manual-testing processes. | |
| - name: Reclaim GPU from stray E2E processes | |
| run: | | |
| pkill -f '/tmp/rocm-e2e.*llama-server' 2>/dev/null || true | |
| pkill -f '/tmp/rocm-e2e.*vllm serve' 2>/dev/null || true | |
| pkill -f 'e2e-shared.*llama-server' 2>/dev/null || true | |
| # NOTE: no unanchored `pkill -f 'vulkan/llama-server'` — the lemonade | |
| # Vulkan assistant an e2e scenario spawns lives under /tmp/rocm-e2e-* | |
| # and is already caught by the first line; an unanchored pattern would | |
| # also kill a legitimate /workload manual-testing serve on this shared | |
| # self-hosted runner. | |
| pkill -f '__engine-serve-http.*rocm-e2e' 2>/dev/null || true | |
| pkill -f 'e2e-target/release/rocm daemon' 2>/dev/null || true | |
| rm -rf /tmp/rocm-e2e-* 2>/dev/null || true | |
| echo "reclaimed" | |
| # GPU preflight: fail fast (~90s) instead of hanging to the job cap when the | |
| # GPU is missing, the driver is wedged, or VRAM is still saturated by a | |
| # leftover serve. A BOUNDED POLL, not a one-shot check: transient contention | |
| # (e.g. the reclaim step's kills still draining VRAM) self-heals within | |
| # seconds, so we retry up to a ceiling and succeed the moment the GPU is both | |
| # responsive AND has enough free VRAM. Only a genuinely absent/wedged/held | |
| # GPU reaches the ceiling and fails — with a per-reason message. | |
| - name: GPU preflight (bounded wait for an available GPU) | |
| run: | | |
| # A serve here needs most of the card; require a generous free-VRAM | |
| # floor so a leftover serve (which the reclaim step should have killed) | |
| # is caught, while normal baseline (~300 MB used) passes immediately. | |
| MIN_FREE_GIB="${GPU_PREFLIGHT_MIN_FREE_GIB:-16}" | |
| CEILING_SECS="${GPU_PREFLIGHT_CEILING_SECS:-90}" | |
| min_free=$(( MIN_FREE_GIB * 1024 * 1024 * 1024 )) | |
| deadline=$(( SECONDS + CEILING_SECS )) | |
| reason="rocm-smi never returned within its timeout (driver wedged or GPU absent)" | |
| while [ "$SECONDS" -lt "$deadline" ]; do | |
| # rocm-smi itself can hang on a wedged driver — bound it with timeout. | |
| out=$(timeout 15 rocm-smi --showmeminfo vram 2>/dev/null) || { sleep 5; continue; } | |
| # Parse the byte value AFTER the colon; the line prefix "GPU[0]" would | |
| # otherwise make a naive first-number match pick up the "0". | |
| total=$(printf '%s\n' "$out" | grep -i 'VRAM Total Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| used=$(printf '%s\n' "$out" | grep -i 'VRAM Total Used Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| if [ -z "$total" ] || [ -z "$used" ]; then | |
| reason="rocm-smi returned no VRAM figures (no AMD GPU detected)" | |
| sleep 5; continue | |
| fi | |
| free=$(( total - used )) | |
| if [ "$free" -ge "$min_free" ]; then | |
| echo "GPU ready: $(( free / 1024 / 1024 / 1024 )) GiB free (>= ${MIN_FREE_GIB} GiB)." | |
| exit 0 | |
| fi | |
| reason="VRAM never dropped below the floor: only $(( free / 1024 / 1024 / 1024 )) GiB free (< ${MIN_FREE_GIB} GiB) — a serve is likely still holding the GPU" | |
| echo "waiting: $(( free / 1024 / 1024 / 1024 )) GiB free (< ${MIN_FREE_GIB} GiB)…" | |
| sleep 5 | |
| done | |
| echo "::error::GPU preflight failed after ${CEILING_SECS}s: ${reason}" | |
| exit 1 | |
| # cache: false — on this self-hosted runner we persist the build cache | |
| # ourselves via CARGO_TARGET_DIR (below). The action's built-in | |
| # Swatinem/rust-cache otherwise tries to SAVE the large target dir to | |
| # GitHub's cache service in a post-step (slow/hangs) and its cleanup wipes | |
| # the local target. | |
| - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 | |
| with: | |
| cache: false | |
| # `actions/checkout` runs `git clean -ffdx`, which deletes the gitignored | |
| # `target/` inside the repo every job → a full ~15min rebuild each run. | |
| # Point CARGO_TARGET_DIR at a sibling of the checkout ($RUNNER_WORKSPACE is | |
| # the checkout's parent — untouched by git clean and persistent between jobs | |
| # on a self-hosted runner), so cargo rebuilds incrementally. | |
| - name: Run E2E tests on GPU hardware | |
| run: | | |
| export CARGO_TARGET_DIR="$RUNNER_WORKSPACE/e2e-target" | |
| # Share heavy immutable artifacts (TheRock runtimes ~3.3GB, HF weights, | |
| # vLLM venv) across scenarios so they download once per runner, not per | |
| # scenario. Persistent path; service state stays isolated per scenario. | |
| export E2E_SHARED_CACHE_DIR="$RUNNER_WORKSPACE/e2e-shared" | |
| # Share uv's wheel download/build cache so `rocm install sdk` is a warm | |
| # ~34s per scenario instead of a cold ~160s (measured on MI300X). Kept | |
| # OFF the RUNNER_WORKSPACE Longhorn PVC (near-full) and on the roomy `/` | |
| # overlay; ~23GB, one cold fill per pod-life. Not git-cleaned (outside | |
| # the checkout) and outside the reclaim step's /tmp/rocm-e2e-* glob. | |
| export E2E_SHARED_UV_CACHE_DIR="/var/tmp/rocm-e2e-uv-cache" | |
| # Share ONE installed managed runtime across the serve/chat scenarios so | |
| # `rocm install sdk` runs once per runner, not once per scenario (the | |
| # per-scenario install count — each a multi-GiB TheRock SDK whose probe | |
| # unpacks an ~8.8 GiB devel tarball — is what blew the time cap). The | |
| # "a managed runtime is active" precondition symlinks each scenario's | |
| # data/runtimes here (see use_shared_runtimes); clean-slate scenarios | |
| # stay isolated. Persisted across runs on RUNNER_WORKSPACE, so after the | |
| # first run ever the pre-warm below is a no-op. | |
| # | |
| # CRITICAL: the shared dir IS the pre-warm's own `data/runtimes`, and we | |
| # NEVER move it. `install sdk` bakes ABSOLUTE paths (install_root, | |
| # python_executable) into the runtime manifest; a post-install `mv` would | |
| # leave those pointing at a deleted location and every serve would fail | |
| # instantly (observed on run 29320025393). Installing in place keeps the | |
| # baked paths valid, and each scenario's data/runtimes symlink resolves to | |
| # this same real tree. | |
| prewarm="$RUNNER_WORKSPACE/e2e-prewarm" | |
| export E2E_SHARED_RUNTIMES_DIR="$prewarm/data/runtimes" | |
| # Build the rocm and rocmd binaries ONCE and reuse them for both the | |
| # pre-warm and suite so xtask does not rebuild. Honors | |
| # CARGO_TARGET_DIR set above. | |
| cargo build --release -p rocm -p rocmd | |
| export ROCM_CLI_BINARY="$CARGO_TARGET_DIR/release/rocm" | |
| export ROCM_CLI_ROCMD_BINARY="$CARGO_TARGET_DIR/release/rocmd" | |
| # Pre-warm the shared runtime ONCE, SERIALLY, before the suite — never | |
| # lazily inside a concurrent scenario (two multi-GiB installs racing the | |
| # same dir). Skipped once the tree is populated (it persists across runs). | |
| # Uses the prebuilt binary (no cargo run --release) and the shared uv + HF | |
| # caches. Installs directly into the persistent pre-warm data dir (no mv), | |
| # so the manifest's absolute install_root stays valid for every scenario. | |
| if [ ! -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "pre-warming shared runtime (first run on this runner)…" | |
| mkdir -p "$prewarm"/{data,config,cache} | |
| ROCM_CLI_CONFIG_DIR="$prewarm/config" \ | |
| ROCM_CLI_DATA_DIR="$prewarm/data" \ | |
| ROCM_CLI_CACHE_DIR="$prewarm/cache" \ | |
| HF_HOME="$E2E_SHARED_CACHE_DIR/huggingface" \ | |
| UV_CACHE_DIR="$E2E_SHARED_UV_CACHE_DIR" \ | |
| "$ROCM_CLI_BINARY" install sdk | |
| if [ -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "shared runtime pre-warmed at $E2E_SHARED_RUNTIMES_DIR" | |
| else | |
| echo "pre-warm did not produce a runtimes registry; scenarios will install their own" >&2 | |
| fi | |
| else | |
| echo "shared runtime already present at $E2E_SHARED_RUNTIMES_DIR — skipping pre-warm" | |
| fi | |
| # Optional scenario-name filter for a scoped dispatch — lets a manual | |
| # run select only the large-model scenario instead of the whole suite. | |
| NAME_FILTER="${{ github.event.inputs.name_filter }}" | |
| if [ -n "$NAME_FILTER" ]; then | |
| echo "name filter active: $NAME_FILTER" | |
| cargo xtask e2e -- --name "$NAME_FILTER" | |
| else | |
| cargo xtask e2e | |
| fi | |
| - name: Upload E2E report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-gpu-report | |
| path: tests/e2e-cucumber/results/ | |
| # Second AMD GPU architecture: the Strix Halo (gfx1151) Ubuntu runner, targeted | |
| # by the `strix-halo` label (app-dev-gpu carries `amd-gpu`, not `strix-halo`). | |
| # Non-blocking while this hardware is proven out. | |
| e2e-gpu-strix-ubuntu: | |
| name: E2E tests (Strix Halo, Ubuntu) | |
| # 35min: see e2e-gpu — one collapsed job runs all serves + per-scenario | |
| # install sdk; the cap must exceed the run so the job writes platform.json. | |
| timeout-minutes: 35 | |
| # `native` disambiguates the two Linux Strix runners: the WSL host also | |
| # carries `strix-halo`, but the paths below exist only on the native one. | |
| runs-on: [self-hosted, linux, strix-halo, native] | |
| needs: [changes] | |
| # See `e2e-gpu`: no build-and-test gate (cross-workflow); strix-ubuntu. | |
| if: >- | |
| always() | |
| && needs.changes.result == 'success' | |
| && ( | |
| (github.event_name != 'workflow_dispatch' | |
| && needs.changes.outputs.serve == 'true') | |
| || (github.event_name == 'workflow_dispatch' | |
| && (inputs.platform == 'all' || inputs.platform == 'strix-ubuntu')) | |
| ) | |
| continue-on-error: true | |
| # On this runner `/`, `/home/ubuntu`, and `/tmp` are ALL on a full root | |
| # partition; only /home/ubuntu/actions-runner (a 1.7T nvme) has space. So | |
| # EVERYTHING the job writes must land on the nvme. Point HOME there (catches | |
| # ~/.cache/pip, ~/.config, and any other $HOME writer — pip's cache under the | |
| # real /home/ubuntu is what previously failed `install sdk` with ENOSPC), | |
| # plus the toolchain, temp, and pip cache. The rustup bootstrap still uses | |
| # --no-modify-path so it doesn't touch $HOME/.profile. These paths are | |
| # specific to that host, which is why `runs-on` pins `native` above. | |
| env: | |
| HOME: /home/ubuntu/actions-runner/e2e-home | |
| CARGO_HOME: /home/ubuntu/actions-runner/.cargo | |
| RUSTUP_HOME: /home/ubuntu/actions-runner/.rustup | |
| TMPDIR: /home/ubuntu/actions-runner/tmp | |
| PIP_CACHE_DIR: /home/ubuntu/actions-runner/pip-cache | |
| E2E_SERVE_TIMEOUT_SECS: "300" | |
| # The three Strix lanes share one physical machine, and this workflow now | |
| # runs a third of them, so a TUI frame that renders well inside the 30s | |
| # default on an idle runner can miss it while a sibling lane loads a model. | |
| # Raise the wait budget rather than let that read as a product failure; a | |
| # genuine hang still fails, just later. | |
| E2E_TUI_TIMEOUT_SECS: "90" | |
| # Match the MI300X dispatch path: opt into the platform-adaptive large-model | |
| # scenario only when the manual include_nightly input is enabled. | |
| E2E_INCLUDE_NIGHTLY: "${{ inputs.include_nightly && '1' || '' }}" | |
| # Heavy @merge-queue serves run only in the merge queue; see e2e-gpu. | |
| E2E_MERGE_QUEUE: "${{ github.event_name == 'merge_group' && '1' || '' }}" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Prepare writable dirs on the nvme + reclaim GPU from stray E2E procs | |
| run: | | |
| mkdir -p /home/ubuntu/actions-runner/e2e-home /home/ubuntu/actions-runner/tmp /home/ubuntu/actions-runner/pip-cache | |
| # Reclaim the GPU from any serve leaked by a killed/timed-out prior run | |
| # (see e2e-gpu). Scoped to e2e leftovers only. | |
| pkill -f '/tmp/rocm-e2e.*llama-server' 2>/dev/null || true | |
| pkill -f '/tmp/rocm-e2e.*vllm serve' 2>/dev/null || true | |
| pkill -f 'e2e-shared.*llama-server' 2>/dev/null || true | |
| pkill -f '__engine-serve-http.*rocm-e2e' 2>/dev/null || true | |
| rm -rf /tmp/rocm-e2e-* 2>/dev/null || true | |
| echo "prepared + reclaimed" | |
| # GPU preflight: bounded wait so a missing/wedged/held GPU fails fast (~90s) | |
| # instead of hanging to the job cap. Transient contention (VRAM still | |
| # draining from the reclaim above) self-heals within the ceiling. See the | |
| # e2e-gpu job for the rationale. gfx1151 shares system memory, so the free | |
| # floor is smaller than the Instinct card but still catches a leftover serve. | |
| - name: GPU preflight (bounded wait for an available GPU) | |
| run: | | |
| MIN_FREE_GIB="${GPU_PREFLIGHT_MIN_FREE_GIB:-8}" | |
| CEILING_SECS="${GPU_PREFLIGHT_CEILING_SECS:-90}" | |
| min_free=$(( MIN_FREE_GIB * 1024 * 1024 * 1024 )) | |
| deadline=$(( SECONDS + CEILING_SECS )) | |
| reason="rocm-smi never returned within its timeout (driver wedged or GPU absent)" | |
| while [ "$SECONDS" -lt "$deadline" ]; do | |
| out=$(timeout 15 rocm-smi --showmeminfo vram 2>/dev/null) || { sleep 5; continue; } | |
| total=$(printf '%s\n' "$out" | grep -i 'VRAM Total Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| used=$(printf '%s\n' "$out" | grep -i 'VRAM Total Used Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| if [ -z "$total" ] || [ -z "$used" ]; then | |
| reason="rocm-smi returned no VRAM figures (no AMD GPU detected)" | |
| sleep 5; continue | |
| fi | |
| free=$(( total - used )) | |
| if [ "$free" -ge "$min_free" ]; then | |
| echo "GPU ready: $(( free / 1024 / 1024 / 1024 )) GiB free (>= ${MIN_FREE_GIB} GiB)." | |
| exit 0 | |
| fi | |
| reason="VRAM never dropped below the floor: only $(( free / 1024 / 1024 / 1024 )) GiB free (< ${MIN_FREE_GIB} GiB) — a serve is likely still holding the GPU" | |
| echo "waiting: $(( free / 1024 / 1024 / 1024 )) GiB free (< ${MIN_FREE_GIB} GiB)…" | |
| sleep 5 | |
| done | |
| echo "::error::GPU preflight failed after ${CEILING_SECS}s: ${reason}" | |
| exit 1 | |
| # Bootstrap rustup ourselves with --no-modify-path so it never writes to | |
| # $HOME/.profile (setup-rust-toolchain doesn't expose that flag). | |
| # rust-toolchain.toml pins the exact toolchain, installed on first cargo | |
| # use. Idempotent. | |
| - name: Ensure Rust toolchain | |
| run: | | |
| if ! command -v cargo >/dev/null 2>&1 && [ ! -x "$CARGO_HOME/bin/cargo" ]; then | |
| curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ | |
| | sh -s -- -y --no-modify-path --default-toolchain none | |
| fi | |
| echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" | |
| - name: Run E2E tests on Strix Halo | |
| run: | | |
| export CARGO_TARGET_DIR="$RUNNER_WORKSPACE/e2e-target" | |
| export E2E_SHARED_CACHE_DIR="$RUNNER_WORKSPACE/e2e-shared" | |
| # Share ONE installed managed runtime across serve/chat scenarios, and | |
| # PRE-WARM it in place before the suite (mirrors e2e-gpu). This is not an | |
| # optimization — it is REQUIRED for correctness. `install sdk` bakes | |
| # ABSOLUTE paths (install_root, python_executable, rocm_sdk.*) into the | |
| # runtime manifest. If the first scenario installs into its own isolated | |
| # /tmp/rocm-e2e-XXXX data dir and only the registry is shared onward, those | |
| # baked paths point at that scenario's temp dir — which is deleted when the | |
| # scenario ends. Every later serve then sees `status=unusable (install root | |
| # is missing)` and fails (diagnosed on the box 2026-07-15). Installing in | |
| # place, directly into the persistent shared dir, keeps the baked paths | |
| # valid for all scenarios and for the end-of-run version probe. | |
| prewarm="$RUNNER_WORKSPACE/e2e-prewarm" | |
| export E2E_SHARED_RUNTIMES_DIR="$prewarm/data/runtimes" | |
| # Build the rocm and rocmd binaries once; reuse them for pre-warm + suite. | |
| cargo build --release -p rocm -p rocmd | |
| export ROCM_CLI_BINARY="$CARGO_TARGET_DIR/release/rocm" | |
| export ROCM_CLI_ROCMD_BINARY="$CARGO_TARGET_DIR/release/rocmd" | |
| # Pre-warm once, serially, in place (no mv/symlink). Skipped once the tree | |
| # is populated (persists across runs on RUNNER_WORKSPACE). | |
| if [ ! -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "pre-warming shared runtime (first run on this runner)…" | |
| mkdir -p "$prewarm"/{data,config,cache} | |
| ROCM_CLI_CONFIG_DIR="$prewarm/config" \ | |
| ROCM_CLI_DATA_DIR="$prewarm/data" \ | |
| ROCM_CLI_CACHE_DIR="$prewarm/cache" \ | |
| HF_HOME="$E2E_SHARED_CACHE_DIR/huggingface" \ | |
| "$ROCM_CLI_BINARY" install sdk | |
| if [ -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "shared runtime pre-warmed at $E2E_SHARED_RUNTIMES_DIR" | |
| else | |
| echo "pre-warm did not produce a runtimes registry; scenarios will install their own" >&2 | |
| fi | |
| else | |
| echo "shared runtime already present at $E2E_SHARED_RUNTIMES_DIR — skipping pre-warm" | |
| fi | |
| # Optional scenario-name filter for a scoped manual dispatch. | |
| NAME_FILTER="${{ github.event.inputs.name_filter }}" | |
| if [ -n "$NAME_FILTER" ]; then | |
| echo "name filter active: $NAME_FILTER" | |
| cargo xtask e2e -- --name "$NAME_FILTER" | |
| else | |
| cargo xtask e2e | |
| fi | |
| - name: Upload E2E report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-gpu-strix-ubuntu-report | |
| path: tests/e2e-cucumber/results/ | |
| # First real Windows GPU coverage: the Strix Halo Windows 11 runner. The | |
| # existing windows-build-and-test uses GitHub-hosted windows-latest, which has | |
| # no GPU. Non-blocking so it never gates the PR. | |
| e2e-gpu-strix-windows: | |
| name: E2E tests (Strix Halo, Windows) | |
| # 35min: see e2e-gpu — one collapsed job runs all serves + per-scenario | |
| # install sdk; the cap must exceed the run so the job writes platform.json. | |
| timeout-minutes: 35 | |
| runs-on: [self-hosted, windows, strix-halo, native] | |
| needs: [changes] | |
| # See `e2e-gpu`: no build-and-test gate (cross-workflow); strix-windows. | |
| if: >- | |
| always() | |
| && needs.changes.result == 'success' | |
| && ( | |
| (github.event_name != 'workflow_dispatch' | |
| && needs.changes.outputs.serve == 'true') | |
| || (github.event_name == 'workflow_dispatch' | |
| && (inputs.platform == 'all' || inputs.platform == 'strix-windows')) | |
| ) | |
| continue-on-error: true | |
| env: | |
| # The three Strix lanes share one physical machine, and this workflow now | |
| # runs a third of them, so a TUI frame that renders well inside the 30s | |
| # default on an idle runner can miss it while a sibling lane loads a model. | |
| # Raise the wait budget rather than let that read as a product failure; a | |
| # genuine hang still fails, just later. | |
| E2E_TUI_TIMEOUT_SECS: "90" | |
| # Match the Linux Strix dispatch path: opt into the platform-adaptive | |
| # large-model scenario only when the manual input is enabled. | |
| E2E_INCLUDE_NIGHTLY: "${{ inputs.include_nightly && '1' || '' }}" | |
| # Heavy @merge-queue serves run only in the merge queue; see e2e-gpu. | |
| E2E_MERGE_QUEUE: "${{ github.event_name == 'merge_group' && '1' || '' }}" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # setup-rust-toolchain runs an internal bash script, which this Windows | |
| # runner lacks (bash: command not found). Bootstrap rustup with the | |
| # PowerShell-native installer instead; idempotent, so it only downloads on | |
| # a runner that doesn't already have the toolchain. Use `powershell` | |
| # (Windows PowerShell 5.1, always present) rather than `pwsh` (PowerShell 7), | |
| # which this self-hosted runner does not have installed. | |
| # Reclaim the GPU from any E2E serve leaked by a killed/timed-out prior run | |
| # (see e2e-gpu). PowerShell (5.1) equivalent; best-effort. | |
| - name: Reclaim GPU from stray E2E processes | |
| shell: powershell | |
| run: | | |
| Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | | |
| Where-Object { $_.CommandLine -match 'rocm-e2e|__engine-serve-http|e2e-target|e2e-shared' -and $_.CommandLine -match 'llama-server|vllm|rocm ' } | | |
| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } | |
| Write-Host "reclaimed" | |
| # GPU preflight (best-effort on Windows): bounded wait for the GPU to be | |
| # available so a wedged/held GPU fails fast instead of hanging to the cap. | |
| # BEST-EFFORT because the Windows ROCm GPU query tool isn't verified here — | |
| # if no rocm-smi is found we WARN and continue rather than false-fail a | |
| # working runner. When rocm-smi IS present we poll free VRAM the same way as | |
| # the Linux jobs and fail only after the ceiling. | |
| - name: GPU preflight (bounded wait for an available GPU) | |
| shell: powershell | |
| run: | | |
| $minFreeGiB = if ($env:GPU_PREFLIGHT_MIN_FREE_GIB) { [int]$env:GPU_PREFLIGHT_MIN_FREE_GIB } else { 8 } | |
| $ceilingSecs = if ($env:GPU_PREFLIGHT_CEILING_SECS) { [int]$env:GPU_PREFLIGHT_CEILING_SECS } else { 90 } | |
| if (-not (Get-Command rocm-smi -ErrorAction SilentlyContinue)) { | |
| Write-Host "rocm-smi not found on this Windows runner; skipping GPU preflight (best-effort)." | |
| exit 0 | |
| } | |
| $minFree = [int64]$minFreeGiB * 1GB | |
| $deadline = (Get-Date).AddSeconds($ceilingSecs) | |
| $reason = "rocm-smi never returned usable VRAM figures" | |
| while ((Get-Date) -lt $deadline) { | |
| $out = (rocm-smi --showmeminfo vram 2>$null | Out-String) | |
| $total = ([regex]::Matches($out, 'VRAM Total Memory \(B\):\s*(\d+)') | Select-Object -First 1).Groups[1].Value | |
| $used = ([regex]::Matches($out, 'VRAM Total Used Memory \(B\):\s*(\d+)') | Select-Object -First 1).Groups[1].Value | |
| if (-not $total -or -not $used) { | |
| $reason = "rocm-smi returned no VRAM figures (no AMD GPU detected)" | |
| Start-Sleep -Seconds 5; continue | |
| } | |
| $free = [int64]$total - [int64]$used | |
| if ($free -ge $minFree) { | |
| Write-Host "GPU ready: $([math]::Floor($free/1GB)) GiB free (>= $minFreeGiB GiB)." | |
| exit 0 | |
| } | |
| $reason = "VRAM never dropped below the floor: only $([math]::Floor($free/1GB)) GiB free (< $minFreeGiB GiB) - a serve is likely still holding the GPU" | |
| Write-Host "waiting: $([math]::Floor($free/1GB)) GiB free (< $minFreeGiB GiB)..." | |
| Start-Sleep -Seconds 5 | |
| } | |
| Write-Host "::error::GPU preflight failed after ${ceilingSecs}s: $reason" | |
| exit 1 | |
| - name: Ensure Rust toolchain (PowerShell) | |
| shell: powershell | |
| run: | | |
| if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { | |
| Invoke-WebRequest https://win.rustup.rs/x86_64 -OutFile $env:TEMP\rustup-init.exe | |
| # --default-toolchain none: rust-toolchain.toml pins the exact | |
| # version (1.96.0 + components), auto-installed on first cargo use. | |
| & $env:TEMP\rustup-init.exe -y --default-toolchain none | |
| "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Append | |
| } | |
| - name: Run E2E tests on Strix Halo Windows | |
| shell: powershell | |
| run: | | |
| # Share ONE installed managed runtime across serve/chat scenarios, and | |
| # PRE-WARM it in place before the suite (mirrors e2e-gpu / strix-ubuntu). | |
| # REQUIRED for correctness, not just speed: `install sdk` bakes ABSOLUTE | |
| # paths (install_root, python_executable, rocm_sdk.*, .rocm-cli-runtime.json) | |
| # into the runtime manifest. If the first scenario installs into its own | |
| # isolated temp data dir and only the registry is shared onward, those baked | |
| # paths point at that scenario's dir — deleted when the scenario ends — so | |
| # every later serve sees status=unusable and fails (diagnosed on the Linux | |
| # box 2026-07-15; the Windows scenario-8 cold-download failure is the same | |
| # class). Install in place so the baked paths stay valid for all scenarios. | |
| $prewarm = "$env:RUNNER_WORKSPACE\e2e-prewarm" | |
| $env:E2E_SHARED_RUNTIMES_DIR = "$prewarm\data\runtimes" | |
| # Build the rocm and rocmd binaries once; reuse them for pre-warm + suite. | |
| # This job does not set CARGO_TARGET_DIR, so the binaries land in | |
| # the default target\release (fall back to it when the env var is unset). | |
| cargo build --release -p rocm -p rocmd | |
| if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } | |
| $targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { "target" } | |
| $env:ROCM_CLI_BINARY = "$targetDir\release\rocm.exe" | |
| $env:ROCM_CLI_ROCMD_BINARY = "$targetDir\release\rocmd.exe" | |
| # Pre-warm once, in place (no move/symlink). Skipped once the tree is | |
| # populated (persists across runs on RUNNER_WORKSPACE). | |
| if (-not (Test-Path "$env:E2E_SHARED_RUNTIMES_DIR\registry")) { | |
| Write-Host "pre-warming shared runtime (first run on this runner)..." | |
| New-Item -ItemType Directory -Force -Path "$prewarm\data","$prewarm\config","$prewarm\cache" | Out-Null | |
| $env:ROCM_CLI_CONFIG_DIR = "$prewarm\config" | |
| $env:ROCM_CLI_DATA_DIR = "$prewarm\data" | |
| $env:ROCM_CLI_CACHE_DIR = "$prewarm\cache" | |
| & $env:ROCM_CLI_BINARY install sdk | |
| Remove-Item Env:\ROCM_CLI_CONFIG_DIR,Env:\ROCM_CLI_DATA_DIR,Env:\ROCM_CLI_CACHE_DIR -ErrorAction SilentlyContinue | |
| if (Test-Path "$env:E2E_SHARED_RUNTIMES_DIR\registry") { | |
| Write-Host "shared runtime pre-warmed at $env:E2E_SHARED_RUNTIMES_DIR" | |
| } else { | |
| Write-Host "pre-warm did not produce a runtimes registry; scenarios will install their own" | |
| } | |
| } else { | |
| Write-Host "shared runtime already present at $env:E2E_SHARED_RUNTIMES_DIR - skipping pre-warm" | |
| } | |
| # Optional scenario-name filter for a scoped manual dispatch. | |
| $nameFilter = "${{ github.event.inputs.name_filter }}" | |
| if ($nameFilter) { | |
| Write-Host "name filter active: $nameFilter" | |
| cargo xtask e2e -- --name "$nameFilter" | |
| } else { | |
| cargo xtask e2e | |
| } | |
| - name: Upload E2E report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-gpu-strix-windows-report | |
| path: tests/e2e-cucumber/results/ | |
| # WSL2 coverage on real hardware: the runner is an Ubuntu distro running under | |
| # WSL2 on the Strix Halo Windows box, so this lane exercises the WSL host | |
| # boundary AND whatever GPU access WSL exposes. Same suite as every other | |
| # platform — scenarios the host cannot satisfy resolve to skip from the | |
| # capability probe, so nothing is filtered out here. `wsl` in `runs-on` | |
| # disambiguates it from the `native` Strix Linux runner. Non-blocking while | |
| # GPU-on-WSL is proven out. | |
| e2e-wsl: | |
| name: E2E tests (Strix Halo, WSL2) | |
| # A manual include_nightly dispatch runs the 2400s large-model readiness | |
| # scenario, so match the dedicated nightly lane's cap and leave room for | |
| # build, runtime pre-warm, the rest of the suite, and platform.json. | |
| timeout-minutes: 90 | |
| runs-on: [self-hosted, linux, strix-halo, wsl] | |
| needs: [changes] | |
| # See `e2e-gpu`: no build-and-test gate (cross-workflow); strix-wsl. Gated on | |
| # `serve` like the sibling lanes rather than the broader `heavy`: the | |
| # consolidated report gates on `serve` too, so a heavy-but-not-serve change | |
| # would otherwise run this lane and then discard its artifact unreported. | |
| if: >- | |
| always() | |
| && needs.changes.result == 'success' | |
| && ( | |
| (github.event_name != 'workflow_dispatch' | |
| && needs.changes.outputs.serve == 'true') | |
| || (github.event_name == 'workflow_dispatch' | |
| && (inputs.platform == 'all' || inputs.platform == 'strix-wsl')) | |
| ) | |
| continue-on-error: true | |
| env: | |
| E2E_SERVE_TIMEOUT_SECS: "300" | |
| # The three Strix lanes share one physical machine, and this workflow now | |
| # runs a third of them, so a TUI frame that renders well inside the 30s | |
| # default on an idle runner can miss it while a sibling lane loads a model. | |
| # Raise the wait budget rather than let that read as a product failure; a | |
| # genuine hang still fails, just later. | |
| E2E_TUI_TIMEOUT_SECS: "90" | |
| # Match the other hardware lanes: opt into the platform-adaptive | |
| # large-model scenario only when the manual include_nightly input is on. | |
| E2E_INCLUDE_NIGHTLY: "${{ inputs.include_nightly && '1' || '' }}" | |
| # Heavy @merge-queue serves run only in the merge queue; see e2e-gpu. | |
| E2E_MERGE_QUEUE: "${{ github.event_name == 'merge_group' && '1' || '' }}" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Fail fast and loudly if this job ever lands on a native Linux runner: | |
| # every WSL-tagged scenario would silently resolve to skip instead. | |
| - name: Verify the host really is WSL2 | |
| run: | | |
| proc_version=$(cat /proc/version 2>/dev/null || true) | |
| if [ ! -e /dev/dxg ] \ | |
| && [ -z "${WSL_DISTRO_NAME+x}" ] \ | |
| && ! printf '%s\n' "$proc_version" | grep -qiE 'microsoft|wsl'; then | |
| echo "::error::runner is not a WSL host: $(uname -r)" | |
| exit 1 | |
| fi | |
| printf 'WSL kernel: %s\n' "$(uname -r)" | |
| # The native Strix runners come pre-provisioned; a WSL distro often does | |
| # not. Install the native build deps only when something is missing, and | |
| # only if passwordless sudo is available — otherwise say what is missing | |
| # instead of hanging on a password prompt. | |
| - name: Ensure native build deps | |
| run: | | |
| missing="" | |
| command -v pkg-config >/dev/null 2>&1 || missing="$missing pkg-config" | |
| command -v cc >/dev/null 2>&1 || missing="$missing build-essential" | |
| pkg-config --exists libcap 2>/dev/null || missing="$missing libcap-dev" | |
| if [ -z "$missing" ]; then | |
| echo "native build deps present" | |
| elif sudo -n true 2>/dev/null; then | |
| echo "installing:$missing" | |
| sudo -n apt-get update | |
| # shellcheck disable=SC2086 | |
| sudo -n apt-get install -y $missing | |
| else | |
| echo "::error::missing native build deps:$missing (no passwordless sudo to install them)" | |
| exit 1 | |
| fi | |
| - name: Reclaim GPU from stray E2E processes | |
| run: | | |
| # Reclaim from any serve leaked by a killed/timed-out prior run | |
| # (see e2e-gpu). Scoped to e2e leftovers only. | |
| pkill -f '/tmp/rocm-e2e.*llama-server' 2>/dev/null || true | |
| pkill -f '/tmp/rocm-e2e.*vllm serve' 2>/dev/null || true | |
| pkill -f 'e2e-shared.*llama-server' 2>/dev/null || true | |
| pkill -f '__engine-serve-http.*rocm-e2e' 2>/dev/null || true | |
| rm -rf /tmp/rocm-e2e-* 2>/dev/null || true | |
| echo "reclaimed" | |
| # GPU preflight, bounded like the native lanes — but ADVISORY here. GPU | |
| # access under WSL is exactly what this lane is proving out, so an absent | |
| # rocm-smi is a reported condition, not a job failure: the capability probe | |
| # then resolves @requires-gpu scenarios to skip and the rest still runs. A | |
| # GPU that IS present but stays held by a leftover serve still fails, since | |
| # that would corrupt the serve scenarios' results. | |
| - name: GPU preflight (advisory bounded wait) | |
| run: | | |
| MIN_FREE_GIB="${GPU_PREFLIGHT_MIN_FREE_GIB:-8}" | |
| CEILING_SECS="${GPU_PREFLIGHT_CEILING_SECS:-90}" | |
| if ! command -v rocm-smi >/dev/null 2>&1; then | |
| echo "::warning::rocm-smi not found in this WSL distro — GPU scenarios will resolve to skip" | |
| exit 0 | |
| fi | |
| min_free=$(( MIN_FREE_GIB * 1024 * 1024 * 1024 )) | |
| deadline=$(( SECONDS + CEILING_SECS )) | |
| saw_vram=0 | |
| while [ "$SECONDS" -lt "$deadline" ]; do | |
| out=$(timeout 15 rocm-smi --showmeminfo vram 2>/dev/null) || { sleep 5; continue; } | |
| total=$(printf '%s\n' "$out" | grep -i 'VRAM Total Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| used=$(printf '%s\n' "$out" | grep -i 'VRAM Total Used Memory' | sed 's/.*: *//' | grep -oE '[0-9]+' | tail -1) | |
| if [ -z "$total" ] || [ -z "$used" ]; then | |
| sleep 5; continue | |
| fi | |
| saw_vram=1 | |
| free=$(( total - used )) | |
| if [ "$free" -ge "$min_free" ]; then | |
| echo "GPU ready: $(( free / 1024 / 1024 / 1024 )) GiB free (>= ${MIN_FREE_GIB} GiB)." | |
| exit 0 | |
| fi | |
| echo "waiting: $(( free / 1024 / 1024 / 1024 )) GiB free (< ${MIN_FREE_GIB} GiB)…" | |
| sleep 5 | |
| done | |
| if [ "$saw_vram" -eq 0 ]; then | |
| echo "::warning::rocm-smi reported no VRAM figures under WSL — GPU scenarios will resolve to skip" | |
| exit 0 | |
| fi | |
| echo "::error::GPU preflight failed after ${CEILING_SECS}s: VRAM never dropped below the floor — a serve is likely still holding the GPU" | |
| exit 1 | |
| # Bootstrap rustup with --no-modify-path so it never writes $HOME/.profile | |
| # (setup-rust-toolchain doesn't expose that flag). rust-toolchain.toml pins | |
| # the exact toolchain, installed on first cargo use. Idempotent. | |
| - name: Ensure Rust toolchain | |
| run: | | |
| if ! command -v cargo >/dev/null 2>&1 && [ ! -x "$HOME/.cargo/bin/cargo" ]; then | |
| curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ | |
| | sh -s -- -y --no-modify-path --default-toolchain none | |
| fi | |
| echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" | |
| - name: Run E2E tests on Strix Halo WSL2 | |
| run: | | |
| export CARGO_TARGET_DIR="$RUNNER_WORKSPACE/e2e-target" | |
| export E2E_SHARED_CACHE_DIR="$RUNNER_WORKSPACE/e2e-shared" | |
| # Share ONE installed managed runtime across serve/chat scenarios and | |
| # PRE-WARM it in place (mirrors e2e-gpu-strix-ubuntu). Required for | |
| # correctness, not speed: `install sdk` bakes ABSOLUTE paths into the | |
| # runtime manifest, so installing into a per-scenario temp dir leaves | |
| # every later serve pointing at a deleted install root. | |
| prewarm="$RUNNER_WORKSPACE/e2e-prewarm" | |
| export E2E_SHARED_RUNTIMES_DIR="$prewarm/data/runtimes" | |
| # Build the rocm and rocmd binaries once; reuse them for pre-warm + suite. | |
| cargo build --release -p rocm -p rocmd | |
| export ROCM_CLI_BINARY="$CARGO_TARGET_DIR/release/rocm" | |
| export ROCM_CLI_ROCMD_BINARY="$CARGO_TARGET_DIR/release/rocmd" | |
| # Pre-warm once, serially, in place (no mv/symlink). Skipped once the | |
| # tree is populated (persists across runs on RUNNER_WORKSPACE). | |
| if [ ! -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "pre-warming shared runtime (first run on this runner)…" | |
| mkdir -p "$prewarm"/{data,config,cache} | |
| ROCM_CLI_CONFIG_DIR="$prewarm/config" \ | |
| ROCM_CLI_DATA_DIR="$prewarm/data" \ | |
| ROCM_CLI_CACHE_DIR="$prewarm/cache" \ | |
| HF_HOME="$E2E_SHARED_CACHE_DIR/huggingface" \ | |
| "$ROCM_CLI_BINARY" install sdk | |
| if [ -d "$E2E_SHARED_RUNTIMES_DIR/registry" ]; then | |
| echo "shared runtime pre-warmed at $E2E_SHARED_RUNTIMES_DIR" | |
| else | |
| echo "pre-warm did not produce a runtimes registry; scenarios will install their own" >&2 | |
| fi | |
| else | |
| echo "shared runtime already present at $E2E_SHARED_RUNTIMES_DIR — skipping pre-warm" | |
| fi | |
| # Optional scenario-name filter for a scoped manual dispatch. | |
| NAME_FILTER="${{ github.event.inputs.name_filter }}" | |
| if [ -n "$NAME_FILTER" ]; then | |
| echo "name filter active: $NAME_FILTER" | |
| cargo xtask e2e -- --name "$NAME_FILTER" | |
| else | |
| cargo xtask e2e | |
| fi | |
| - name: Upload E2E report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-gpu-strix-wsl-report | |
| path: tests/e2e-cucumber/results/ | |
| # Consolidate this workflow's self-hosted platform reports into one GPU-side | |
| # cross-platform grid (Summary + merged HTML). Distinct name from ci.yml's | |
| # required `E2E consolidated report` so it does NOT collide with that required | |
| # check; this one is advisory and lives with the lanes it summarizes. Runs on | |
| # GitHub-hosted ubuntu (no GPU needed — it only parses report.json). `always()` | |
| # so a failing/skipped self-hosted platform still appears in the grid. | |
| e2e-report: | |
| name: E2E consolidated report (self-hosted) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: | |
| - changes | |
| - e2e-gpu | |
| - e2e-gpu-strix-ubuntu | |
| - e2e-gpu-strix-windows | |
| - e2e-wsl | |
| # Gate on `serve`: every lane this report consolidates (the GPU jobs) is now | |
| # serve-gated, so a serve-only change runs them and their report must still be | |
| # produced. On dispatch `serve` is unset, so also run when the trigger was | |
| # manual; `always()` still lets it collect partial/failed tiers. | |
| if: >- | |
| always() | |
| && (needs.changes.outputs.serve == 'true' | |
| || github.event_name == 'workflow_dispatch') | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 | |
| # Pull every self-hosted e2e artifact matching the glob; `xtask e2e-report` | |
| # turns each into one labeled platform and the `*-report` glob picks up any | |
| # new self-hosted platform. Layout note: download-artifact@v8 uses a | |
| # per-artifact subdir (e2e-artifacts/<name>/) when several match, but | |
| # flattens into e2e-artifacts/ when EXACTLY ONE matches (its source picks | |
| # the root path when `artifacts.length === 1`, regardless of merge-multiple). | |
| # A single-platform dispatch hits the flattened case; `discover()` handles | |
| # both, labeling a root-level report from platform.json's slug. | |
| - name: Download all self-hosted E2E reports | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: '*-report' | |
| path: e2e-artifacts | |
| - name: Build consolidated report + step summary | |
| run: | | |
| mkdir -p consolidated | |
| cargo xtask e2e-report \ | |
| --artifacts-dir e2e-artifacts \ | |
| --html-out consolidated/index.html >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload consolidated report | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: e2e-consolidated-report-selfhosted | |
| path: consolidated/ |