diff --git a/.github/actions/setup-iron/action.yml b/.github/actions/setup-iron/action.yml new file mode 100644 index 00000000..d321f22b --- /dev/null +++ b/.github/actions/setup-iron/action.yml @@ -0,0 +1,48 @@ +name: Setup iron CLI +description: >- + Download the pre-built `iron` + `__iron_runner` binaries (uploaded once by + the build workflow), put them on PATH, and — on macOS — install the Metal + Toolchain the offline `metal` compiler and the runtime `makeLibrary` + JIT both need. Used by every job that runs `iron build` / `iron test` / + `iron bench` so none of them re-compile the CLI. The calling JOB must run + `actions/checkout` first — a local composite action is read from the + checked-out workspace, and the iron commands read repo files (iron.toml, + baselines/). + +inputs: + artifact: + description: Name of the uploaded iron-binary artifact to download. + required: false + default: iron-binary + +runs: + using: composite + steps: + - name: Download iron binary + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: target/ci + + - name: Make iron executable + shell: bash + run: chmod +x target/ci/iron target/ci/__iron_runner + + - name: Add iron to PATH + shell: bash + run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" + + # The macos-26 image ships Xcode WITHOUT the Metal Toolchain component; + # `xcrun metal` ("cannot execute tool 'metal'") and the runtime + # `makeLibrary(source:)` JIT for cooperative-tensor kernels both need it. + # No-op on non-macOS runners (future CUDA/HIP), so the action stays + # backend-agnostic. `sw_vers` is logged because the cooperative-tensor + # skips are an OS-version gap (< 26.5 can't lower dynamic-extent coop + # tensors) — when the image rolls to 26.5+ the skips should vanish, and + # this line makes that visible instead of mysterious. + - name: Install Metal Toolchain + if: runner.os == 'macOS' + shell: bash + run: | + sw_vers + xcodebuild -downloadComponent MetalToolchain diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 00000000..5f17d8d5 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,40 @@ +name: Setup Rust +description: >- + Install the pinned Rust toolchain and restore the cargo build cache. Shared + by the lint and build workflows so toolchain + cache config lives in one + place. The calling JOB must run `actions/checkout` first — a local composite + action is read from the checked-out workspace, so it can't check itself out. + +inputs: + components: + description: Comma-separated rustup components (e.g. "clippy" or "rustfmt"). + required: false + default: "" + cache-key: + description: >- + Distinguishing key for Swatinem/rust-cache (shared-key). Jobs with + incompatible artifacts (instrumented vs plain) must use different keys. + required: false + default: "shared" + cache: + description: Set "false" to skip the cargo cache entirely (e.g. release builds). + required: false + default: "true" + +runs: + using: composite + steps: + # rust-toolchain.toml pins the channel/edition; this action installs the + # default stable and cargo picks up the toml override automatically. The + # `components` input adds rustfmt / clippy when a job needs them. + - uses: dtolnay/rust-toolchain@stable + with: + components: ${{ inputs.components }} + + - uses: Swatinem/rust-cache@v2 + if: ${{ inputs.cache == 'true' }} + with: + shared-key: ${{ inputs.cache-key }} + # Only the default branch writes the cache; PRs read it. Keeps the + # cache from thrashing on every feature branch. + save-if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/configs/codecov.yml b/.github/configs/codecov.yml index 1ad43609..9188474d 100644 --- a/.github/configs/codecov.yml +++ b/.github/configs/codecov.yml @@ -65,6 +65,18 @@ flag_management: target: auto threshold: 0.5% if_ci_failed: success + # wh-iron-std MINUS the `#[kernel]` DSL bodies (excluded globally in + # `ignore:` below — the proc-macro consumes them, so they never execute + # as Rust). This flag is the kernel HOST / dispatch / registry code, the + # coverable half of the "kernels" the >= 80% goal refers to. + - name: std + paths: + - crates/wh-iron-std/ + statuses: + - type: project + target: auto + threshold: 0.5% + if_ci_failed: success # Files excluded from the line-coverage denominator: # - facade re-exports (no logic) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..666f7b99 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,72 @@ +name: Bench + +# Manual, full-suite GPU benchmark. Not run on PRs (perf needs dedicated, +# consistent hardware to be meaningful — that arrives with the future +# per-arch runners). Dispatch it by hand to capture a baseline for a chip. +# +# `runner` is an input so the same workflow drives macOS / CUDA / HIP hosts +# once those runners exist; it reuses build.yml so the iron binary is built +# the same way as everywhere else. +on: + workflow_dispatch: + inputs: + runner: + description: Runner label to bench on (e.g. macos-26). + required: false + default: macos-26 + +permissions: + contents: read + +env: + CLICOLOR_FORCE: 1 + +jobs: + build: + uses: ./.github/workflows/build.yml + + bench: + name: Bench (full suite) + needs: build + runs-on: ${{ inputs.runner }} + timeout-minutes: 120 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-iron + + # Whole suite in one pass — no heavy/light shard split. If we bench, + # we bench everything. + - name: iron bench + run: iron bench -vv --allow-dirty --json /tmp/bench.json + + - name: Snapshot result + run: | + iron snap \ + --from /tmp/bench.json -o /tmp/bench-snapshot.json \ + --note "manual bench @ ${{ github.sha }} (${{ github.ref_name }})" + + # Best-effort trend vs the committed baseline for this chip (never + # fails the run — the runner chip often has no committed baseline). + - name: Diff vs baseline + run: | + set -uo pipefail + DEVICE_NAME=$(iron device --json | jq -r '.device') + SLUG=$(printf '%s' "$DEVICE_NAME" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//') + BL="baselines/${SLUG}.json" + echo "chip='${DEVICE_NAME}' slug='${SLUG}' baseline='${BL}'" + if [ -f "$BL" ]; then + iron diff "$BL" /tmp/bench.json || true + else + echo "No committed baseline for this chip — snapshot uploaded as the new capture." + fi + + - name: Upload bench snapshot + uses: actions/upload-artifact@v4 + with: + name: bench-snapshot + path: | + /tmp/bench.json + /tmp/bench-snapshot.json + if-no-files-found: error diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..dddbc52a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,78 @@ +name: Build + +# Reusable. Two stages: +# compile — cargo-build the `iron` + `__iron_runner` binaries once, cache +# them, and upload as an artifact every downstream job reuses. +# kernels — run `iron build`: MSL codegen + `xcrun metal` compile-check of +# every registered kernel × dtype. +# This replaces the old iron.yml `compile` + `build` pair (they were a +# pipeline, not a redundancy) and hands the same artifact to test / +# correctness / bench so the CLI is built exactly once per CI run. +on: + workflow_call: + inputs: + artifact: + description: Name to upload the built iron binaries under. + required: false + default: iron-binary + type: string + +permissions: + contents: read + +env: + CLICOLOR_FORCE: 1 + +jobs: + compile: + name: Compile CLI + runs-on: macos-26 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable + + # A hit on the binary cache (keyed on every Rust input) skips the cargo + # build entirely (~3-5 min). The dep cache below is only restored on a + # miss, so a clean build still gets warm ~/.cargo. + - name: Cache iron binary + id: binary-cache + uses: actions/cache@v4 + with: + path: | + target/ci/iron + target/ci/__iron_runner + key: iron-binary-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'crates/**/*.rs', 'rust-toolchain.toml') }} + + - uses: Swatinem/rust-cache@v2 + if: steps.binary-cache.outputs.cache-hit != 'true' + with: + shared-key: iron-macos + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Build iron CLI and runner + if: steps.binary-cache.outputs.cache-hit != 'true' + run: cargo build --profile ci -p wh-iron-cli -p wh-iron-std + + - name: Upload iron binary + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: | + target/ci/iron + target/ci/__iron_runner + if-no-files-found: error + retention-days: 1 + + kernels: + name: Build kernels + runs-on: macos-26 + timeout-minutes: 30 + needs: compile + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-iron + with: + artifact: ${{ inputs.artifact }} + - name: iron build + run: iron build diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 14340ac4..00000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Check - -# Cancel in-progress runs when a new commit is pushed to the same PR. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: - push: - branches: [main] - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: {} - -permissions: - contents: read - pull-requests: write - -env: - CLICOLOR: 1 - -jobs: - typos: - name: Typos - runs-on: ubuntu-latest - steps: - - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: block - allowed-endpoints: > - github.com:443 - release-assets.githubusercontent.com:443 - objects.githubusercontent.com:443 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: crate-ci/typos@v1 - with: - config: .github/configs/typos-cli.toml - - fmt: - name: Format - runs-on: ubuntu-latest - steps: - - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: block - allowed-endpoints: > - github.com:443 - static.crates.io:443 - crates.io:443 - index.crates.io:443 - static.rust-lang.org:443 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - run: cargo fmt --check --all - - # Clippy lints the whole matrix — `--all-features` pulls in the - # cuda / hip / vulkan backends, which only compile-check on Linux (the - # macOS test job below can't build them, there is no CUDA/HIP SDK there). - # Lints only; runs no tests. - clippy: - name: Clippy - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: block - allowed-endpoints: > - github.com:443 - release-assets.githubusercontent.com:443 - objects.githubusercontent.com:443 - static.crates.io:443 - crates.io:443 - index.crates.io:443 - static.rust-lang.org:443 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - run: cargo clippy --all-targets --all-features -- -D warnings - - # Tests run on macOS so the Metal GPU correctness suite ACTUALLY executes. - # `crates/wh-iron-std/tests/kernel_tests_harness.rs` (the - # `all_registered_kernel_tests_pass` GPU-vs-CPU-oracle sweep over every - # registered `#[test_kernel]`) is `#![cfg(target_os = "macos")]` — on a - # Linux runner it compiles to nothing, so `cargo test` there passes - # WITHOUT ever touching the GPU. Running it here on `macos-26` closes that - # silent bypass and also exercises `every_registered_benchspec_codegens` - # on the real Metal codegen path. Default features = the Metal backend. - test: - name: Tests (macOS) - runs-on: macos-26 - timeout-minutes: 30 - steps: - # harden-runner blocking mode is Linux-only; use audit on macOS, - # matching coverage.yml. - - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@stable - # The macos-26 image ships Xcode without the Metal Toolchain — both - # the offline `metal` compiler and the runtime `makeLibrary(source:)` - # JIT path need it, or MPP/bgemm cooperative-tensor kernels fail at - # PSO creation. Install it before any GPU test runs. - - name: Install Metal Toolchain - run: xcodebuild -downloadComponent MetalToolchain - - uses: Swatinem/rust-cache@v2 - with: - # Separate key from the coverage job's instrumented artifacts. - key: macos-test - save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: taiki-e/install-action@nextest - # --no-fail-fast: run the whole GPU suite even after a failure so one - # run surfaces every failing test, not just the first (these jobs are - # ~15 min, so a second diagnostic round is expensive). - - run: cargo nextest run --workspace --no-fail-fast - - commit-hygiene: - name: Commits - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Run hygiene check - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - REPO: ${{ github.repository }} - run: python3 scripts/commit_hygiene.py - -# The Kernels (macOS `iron build` + `iron bench`) job lives in -# `.github/workflows/kernels.yml` so it can be path-filtered to skip -# docs-only PRs without affecting the fast Ubuntu jobs above. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b4043936 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +# Single entry point that orchestrates the reusable workflows so we get real +# cross-job ordering (which separate top-level workflows can't express) and +# build the iron binary exactly once per run: +# +# changes +# lint ──┬── build ── correctness +# ├── test +# └── coverage +# └─(tag only)─ release (needs build + test + correctness) +# +# lint runs on EVERY PR and release. build / test / correctness / coverage +# run on code PRs (docs-only PRs are skipped via the `changes` filter) and +# always on pushes to main/dev, tags, and manual dispatch. coverage gates via +# codecov's project (no-regression) + patch (new code >= 80%) status checks. +# release publishes only on a `v*` tag, and only after the gates are green. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main, dev] + tags: ['v[0-9]+.[0-9]+.[0-9]+*'] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + # Fast ubuntu classifier: is there anything worth spending macOS minutes on? + # Pure docs / markdown / baseline-JSON PRs answer "no" and skip the heavy + # jobs; lint still runs on them. + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'rustfmt.toml' + - 'iron.toml' + - 'scripts/**' + - '.github/workflows/**' + - '.github/actions/**' + - '.github/configs/**' + + lint: + name: Lint + uses: ./.github/workflows/lint.yml + permissions: + contents: read + pull-requests: write + + build: + name: Build + needs: [lint, changes] + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + uses: ./.github/workflows/build.yml + + test: + name: Test + needs: [lint, changes] + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + uses: ./.github/workflows/test.yml + + coverage: + name: Coverage + needs: [lint, changes] + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + permissions: + contents: read + id-token: write # codecov OIDC upload + uses: ./.github/workflows/coverage.yml + + correctness: + name: Correctness + # Needs `build` for the compiled iron binary artifact. + needs: [build, changes] + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + uses: ./.github/workflows/correctness.yml + with: + runner: macos-26 + backend: metal + # Future: add CUDA / HIP runners as extra callers, e.g. + # correctness-cuda: + # uses: ./.github/workflows/correctness.yml + # with: { runner: , backend: cuda } + + release: + name: Release + needs: [build, test, correctness] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + uses: ./.github/workflows/release.yml + with: + tag: ${{ github.ref_name }} diff --git a/.github/workflows/correctness.yml b/.github/workflows/correctness.yml new file mode 100644 index 00000000..065310b1 --- /dev/null +++ b/.github/workflows/correctness.yml @@ -0,0 +1,54 @@ +name: Correctness + +# Reusable, per-backend GPU kernel correctness: `iron test` dispatches every +# registered `#[test_kernel]` on the GPU and checks it against the CPU oracle. +# This is the canonical cross-backend correctness gate — distinct from the +# cargo suite in test.yml. Parameterised by runner + backend so adding an +# NVIDIA (CUDA) or AMD (HIP/ROCm) runner later is a one-line matrix entry in +# ci.yml; today ci.yml calls it once with the macOS/Metal defaults. +# +# NOTE: the iron binary artifact is built on the macOS runner (build.yml), so +# it only runs on macOS today. A Linux CUDA/HIP backend will need build.yml to +# also produce a matching binary — tracked for when those runners land. +on: + workflow_call: + inputs: + runner: + description: Runner label to execute on (e.g. macos-26). + required: false + default: macos-26 + type: string + backend: + description: GPU backend to test (metal | cuda | hip). + required: false + default: metal + type: string + artifact: + description: iron-binary artifact to download. + required: false + default: iron-binary + type: string + +permissions: + contents: read + +env: + CLICOLOR_FORCE: 1 + +jobs: + correctness: + name: iron test (${{ inputs.backend }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-iron + with: + artifact: ${{ inputs.artifact }} + - name: iron test + run: | + if [ "${{ inputs.backend }}" = "metal" ]; then + iron test + else + iron test --backend "${{ inputs.backend }}" + fi diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 078c5df1..e2a8c22d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,24 +1,19 @@ name: Coverage -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - +# Reusable. Instrumented `llvm-cov --workspace` on macOS (the GPU tests must +# run to cover the kernel dispatch / host paths). Called by ci.yml on code +# PRs and pushes so codecov posts the diff-coverage comment and the +# project / patch status checks gate merges. `workflow_dispatch` kept for +# ad-hoc runs. on: - push: - branches: [main] - paths: - - 'crates/**/*.rs' - - 'crates/**/Cargo.toml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - '.github/configs/codecov.yml' - - '.github/workflows/coverage.yml' + workflow_call: {} workflow_dispatch: {} permissions: contents: read + # codecov-action v5 uploads tokenlessly on public repos via GitHub OIDC, + # which needs an id-token. + id-token: write env: CLICOLOR: 1 @@ -28,7 +23,7 @@ jobs: coverage: name: llvm-cov (macOS) runs-on: macos-26 - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: @@ -44,14 +39,21 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - # Instrumented artifacts are NOT cache-compatible with the regular - # `cargo test` artifacts; key this cache separately so the Tests + - # Kernels jobs in check.yml don't get poisoned. + # Instrumented artifacts are NOT cache-compatible with the plain + # `cargo test` artifacts; key this cache separately so the build / + # test / correctness jobs' caches don't get poisoned. key: coverage save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov + # Same as the GPU jobs: the macos-26 image needs the Metal Toolchain for + # the runtime kernel-compile path the instrumented tests exercise. + - name: Install Metal Toolchain + run: | + sw_vers + xcodebuild -downloadComponent MetalToolchain + - name: Generate coverage (codecov format) run: | cargo llvm-cov --workspace --codecov \ diff --git a/.github/workflows/iron.yml b/.github/workflows/iron.yml deleted file mode 100644 index d976265f..00000000 --- a/.github/workflows/iron.yml +++ /dev/null @@ -1,510 +0,0 @@ -name: Iron - -# Cancel in-progress runs when a new commit is pushed to the same PR. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -# Job graph (all macOS runners): -# -# changes -# compile ──┬── build -# ├── test -# ├── bench-heavy ──┐ -# └── bench-light ──┴── bench-merge -# -# `changes` (ubuntu, fast) classifies which paths changed so each bench -# shard only runs when its kernel family group was the sole thing modified. -# The two shards split the 14 kernel families under kernels// into -# two roughly balanced groups: -# - heavy bench: gemm, moe, ssm, quant, kv_cache, hyper_connections, sdpa -# (the compute-heavy matmul / MoE / attention path). Runs only when one -# of those family dirs changed AND no other crates or Rust files did. -# - light bench: norm, ops, sampling, convolution, rope, vision, audio. -# If other crates changed (core IR, codegen, …) neither shard runs — the -# `build` job covers correctness for those changes. Both shards run when -# both families were touched (and nothing else). -# On workflow_dispatch all shards always run regardless of file changes. -# -# `compile` builds the iron binary once (or restores it from the binary -# cache). All downstream jobs download the pre-built artifact — no Rust -# toolchain or cargo invocation needed there. Wall-clock time is dominated -# by the two bench shards (~20-25 min each); build and compile overlap with -# them so no runner sits idle. -# -# Skip on PRs that can't affect kernel emission, dispatch, or measurement — -# docs-only PRs, baseline-JSON additions, etc. -on: - push: - branches: [main, dev] - paths: - - 'crates/**/*.rs' - - 'crates/**/Cargo.toml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'iron.toml' - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'crates/**/*.rs' - - 'crates/**/Cargo.toml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'iron.toml' - - '.github/workflows/iron.yml' - workflow_dispatch: {} - -permissions: - contents: read - # Needed so the bench-diff step can drop a comment on the PR. PRs - # from forks still get the read-only token regardless — the step - # handles that fallback explicitly. - pull-requests: write - -env: - CLICOLOR_FORCE: 1 - -jobs: - # ── Job 0: detect which paths changed ──────────────────────────────────────── - # Fast ubuntu job: classifies changed files into three buckets so the bench - # shards can be gated independently. Uses dorny/paths-filter which compares - # against the PR base (pull_request) or the previous push commit (push). - # On workflow_dispatch there is no diff, so all outputs default to 'false'; - # the bench jobs override this with a workflow_dispatch bypass condition. - changes: - name: Detect changes - runs-on: ubuntu-latest - outputs: - heavy: ${{ steps.filter.outputs.heavy }} - light: ${{ steps.filter.outputs.light }} - other: ${{ steps.filter.outputs.other }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: filter - with: - filters: | - heavy: - - 'crates/wh-iron-std/src/kernels/{gemm,moe,ssm,quant,kv_cache,hyper_connections,sdpa}/**' - light: - - 'crates/wh-iron-std/src/kernels/{norm,ops,sampling,convolution,rope,vision,audio}/**' - other: - - 'crates/**/*.rs' - - '!crates/wh-iron-std/src/kernels/{gemm,moe,ssm,quant,kv_cache,hyper_connections,sdpa}/**' - - '!crates/wh-iron-std/src/kernels/{norm,ops,sampling,convolution,rope,vision,audio}/**' - - 'crates/**/Cargo.toml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'iron.toml' - - # ── Job 1: compile iron binary ─────────────────────────────────────────────── - # Builds `iron` once (or restores from binary cache) and uploads it as a - # workflow artifact. All other jobs download the artifact instead of - # running cargo themselves — one build per workflow run, not one per job. - compile: - name: Compile - runs-on: macos-26 - timeout-minutes: 20 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt,clippy - - # Cache the compiled iron binary keyed on all Rust source inputs. - # A cache hit skips `cargo build` entirely (~3-5 min saved). - - name: Cache iron binary - id: binary-cache - uses: actions/cache@v4 - with: - path: | - target/ci/iron - target/ci/__iron_runner - key: iron-binary-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'crates/**/*.rs', 'rust-toolchain.toml') }} - - - uses: Swatinem/rust-cache@v2 - if: steps.binary-cache.outputs.cache-hit != 'true' - with: - shared-key: iron-macos - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Build iron CLI and runner - if: steps.binary-cache.outputs.cache-hit != 'true' - run: cargo build --profile ci -p wh-iron-cli -p wh-iron-std - - # Upload so every downstream job gets both binaries without any cargo - # invocation. retention-days=1 keeps storage costs near zero. - - name: Upload iron binary - uses: actions/upload-artifact@v4 - with: - name: iron-binary - path: | - target/ci/iron - target/ci/__iron_runner - if-no-files-found: error - retention-days: 1 - - # ── Job 2: compile-check all registered kernels ────────────────────────────── - # Runs `iron build` (MSL codegen + xcrun compile-check for every kernel × - # dtype). Overlaps with the bench shards so all three free macOS runners - # are busy in parallel. - build: - name: Build - runs-on: macos-26 - timeout-minutes: 30 - needs: compile - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - - name: Download iron binary - uses: actions/download-artifact@v4 - with: - name: iron-binary - path: target/ci - - - name: Make iron executable - run: chmod +x target/ci/iron target/ci/__iron_runner - - - name: Add iron to PATH - run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" - - # The macos-26 image ships Xcode without the Metal Toolchain, so the - # offline `metal` compiler that `iron build`'s MSL codegen shells out - # to is missing ("cannot execute tool 'metal'"). Install it first. - - name: Install Metal Toolchain - run: xcodebuild -downloadComponent MetalToolchain - - - name: Build - run: iron build - - # ── Job 3: correctness tests ───────────────────────────────────────────────── - # Runs `iron test` against the CPU oracle for every registered #[test_kernel]. - # Runs in parallel with build and the bench shards. - test: - name: Test - runs-on: macos-26 - timeout-minutes: 30 - needs: compile - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - - name: Download iron binary - uses: actions/download-artifact@v4 - with: - name: iron-binary - path: target/ci - - - name: Make iron executable - run: chmod +x target/ci/iron target/ci/__iron_runner - - - name: Add iron to PATH - run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" - - - name: Test - run: iron test - - # ── Job 4 + 5: GPU benchmark suite (sharded by kernel family) ──────────────── - # Two bench jobs run in parallel — one for the `heavy` family group - # (gemm/moe/ssm/quant/kv_cache/hyper_connections/sdpa) and one for the - # `light` group (norm/ops/sampling/convolution/rope/vision/audio). - # `--match-group` is a regex on the kernel family (the directory below - # kernels/), narrowing each shard to its group so both finish faster than - # a single sequential run. - # - # Each shard only runs when its kernel family was the sole thing modified - # (no other crates or Rust files touched). On workflow_dispatch both shards - # always run so a manual trigger can force a full bench. - # - # Note: matrix context is not available in job-level `if:`, so the two - # shards are spelled out as separate jobs. - bench-heavy: - name: Bench (heavy) - runs-on: macos-26 - timeout-minutes: 60 - needs: [compile, changes] - if: >- - github.event_name == 'workflow_dispatch' || - ( - needs.changes.outputs.other == 'false' && - needs.changes.outputs.heavy == 'true' - ) - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - - name: Download iron binary - uses: actions/download-artifact@v4 - with: - name: iron-binary - path: target/ci - - - name: Make iron executable - run: chmod +x target/ci/iron target/ci/__iron_runner - - - name: Add iron to PATH - run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" - - - name: Install Metal Toolchain - run: xcodebuild -downloadComponent MetalToolchain - - - name: Bench (heavy) - run: iron bench -vv --allow-dirty --match-group '^(gemm|moe|ssm|quant|kv_cache|hyper_connections|sdpa)$' --json /tmp/bench-heavy.json - - - name: Snapshot bench result (heavy) - run: | - iron snap \ - --from /tmp/bench-heavy.json \ - -o /tmp/bench-snapshot-heavy.json \ - --note "CI shard=heavy @ ${{ github.sha }} (${{ github.ref_name }})" - - - name: Upload bench shard - uses: actions/upload-artifact@v4 - with: - name: bench-shard-heavy - path: | - /tmp/bench-heavy.json - /tmp/bench-snapshot-heavy.json - if-no-files-found: error - - bench-light: - name: Bench (light) - runs-on: macos-26 - timeout-minutes: 60 - needs: [compile, changes] - if: >- - github.event_name == 'workflow_dispatch' || - ( - needs.changes.outputs.other == 'false' && - needs.changes.outputs.light == 'true' - ) - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - - name: Download iron binary - uses: actions/download-artifact@v4 - with: - name: iron-binary - path: target/ci - - - name: Make iron executable - run: chmod +x target/ci/iron target/ci/__iron_runner - - - name: Add iron to PATH - run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" - - - name: Install Metal Toolchain - run: xcodebuild -downloadComponent MetalToolchain - - - name: Bench (light) - run: iron bench -vv --allow-dirty --match-group '^(norm|ops|sampling|convolution|rope|vision|audio)$' --json /tmp/bench-light.json - - - name: Snapshot bench result (light) - run: | - iron snap \ - --from /tmp/bench-light.json \ - -o /tmp/bench-snapshot-light.json \ - --note "CI shard=light @ ${{ github.sha }} (${{ github.ref_name }})" - - - name: Upload bench shard - uses: actions/upload-artifact@v4 - with: - name: bench-shard-light - path: | - /tmp/bench-light.json - /tmp/bench-snapshot-light.json - if-no-files-found: error - - # ── Job 5: merge shards + diff comment ─────────────────────────────────────── - # Downloads both shard JSON files, merges them into a combined bench.json, - # snaps a unified baseline, and posts the bench-diff PR comment. - # Only runs when both shards ran (i.e. both families were changed and bench - # succeeded), or on a manual workflow_dispatch. - bench-merge: - name: Bench (merge + diff) - runs-on: macos-26 - timeout-minutes: 15 - needs: [bench-heavy, bench-light, changes] - if: >- - needs.bench-heavy.result == 'success' && needs.bench-light.result == 'success' && - ( - github.event_name == 'workflow_dispatch' || - ( - needs.changes.outputs.other == 'false' && - needs.changes.outputs.heavy == 'true' && - needs.changes.outputs.light == 'true' - ) - ) - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 - - - name: Download iron binary - uses: actions/download-artifact@v4 - with: - name: iron-binary - path: target/ci - - - name: Make iron executable - run: chmod +x target/ci/iron target/ci/__iron_runner - - - name: Add iron to PATH - run: echo "${GITHUB_WORKSPACE}/target/ci" >> "$GITHUB_PATH" - - - name: Download bench shard (heavy) - uses: actions/download-artifact@v4 - with: - name: bench-shard-heavy - path: /tmp/shards/heavy - - - name: Download bench shard (light) - uses: actions/download-artifact@v4 - with: - name: bench-shard-light - path: /tmp/shards/light - - # Merge the two shard JSON files by concatenating their `results` arrays. - # Both files share the same `device` field (same runner pool). - - name: Merge bench shards - run: | - set -euo pipefail - HEAVY=/tmp/shards/heavy/bench-heavy.json - LIGHT=/tmp/shards/light/bench-light.json - - DEVICE=$(jq -r '.device' "$HEAVY") - jq -s ' - { - device: .[0].device, - summary: { - total: ([.[].summary.total] | add), - implemented: ([.[].summary.implemented] | add), - correct: ([.[].summary.correct] | add), - unchecked: ([.[].summary.unchecked] | add) - }, - results: ([.[].results] | add) - } - ' "$HEAVY" "$LIGHT" > /tmp/bench.json - echo "Merged: $(jq '.results | length' /tmp/bench.json) results for device $DEVICE" - - # Snapshot the merged result for artifact upload. - - name: Snapshot merged bench result - run: | - iron snap \ - --from /tmp/bench.json -o /tmp/bench-snapshot.json \ - --note "CI runner capture @ ${{ github.sha }} (${{ github.ref_name }})" - - - name: Upload bench snapshot - uses: actions/upload-artifact@v4 - with: - name: bench-snapshot - path: /tmp/bench-snapshot.json - if-no-files-found: error - - # Soft signal: render `iron diff` against whatever baseline matches - # the runner's chip slug and post it as a PR comment. Never fails - # the job — the runner chip frequently won't match any committed - # baseline, and reviewers want a glanceable trend, not a gate. - - name: Bench diff - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - DEVICE_NAME=$(iron device --json | jq -r '.device') - # Same rule as `cmd::bench::chip_slug` in the Rust code: - # lowercase, collapse non-alphanumeric runs to a single dash, - # trim leading/trailing dashes. - SLUG=$(printf '%s' "$DEVICE_NAME" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//') - BL="baselines/${SLUG}.json" - echo "chip='${DEVICE_NAME}' slug='${SLUG}' baseline='${BL}'" - COMMIT_SHA="${{ github.event.pull_request.head.sha || github.sha }}" - COMMIT_SHORT="${COMMIT_SHA:0:7}" - - { - echo "" - echo "### \`iron bench\` vs \`${BL}\`" - echo - echo "Runner chip: \`${DEVICE_NAME}\` (slug \`${SLUG}\`) | commit \`${COMMIT_SHORT}\`" - echo - if [ ! -f "$BL" ]; then - echo "_No committed baseline for this runner chip — skipping diff._" - if ls baselines/*.json >/dev/null 2>&1; then - echo - echo "Available baselines: $(ls baselines/*.json | xargs -n1 basename | tr '\n' ' ')" - fi - else - echo "
" - echo "iron diff @ commit \`${COMMIT_SHORT}\`" - echo - echo '```' - # Strip ANSI control sequences so the PR comment renders - # cleanly. The old `sed 's/\x1b\[[0-9;]*m//g'` matched - # only SGR colour codes (`ESC [ ... m`); broaden to cover - # non-SGR CSI (any final byte `@`..`~`), OSC (`ESC ] ... - # BEL`), and stray carriage returns — anything else - # surviving renders as garbage in GitHub Markdown. - # Uses `perl` instead of `sed` because BSD sed (the - # macOS runner default) doesn't interpret `\x07` inside - # `[^...]`, which would silently break the OSC arm. perl - # handles `\x07`/`\x1b` consistently on BSD + GNU. - # `|| true` so a regression exit code (1) doesn't kill - # the job. - iron diff "$BL" /tmp/bench.json 2>&1 \ - | perl -pe 's/\x1b\[[0-9;?]*[\x40-\x7e]//g; s/\x1b\][^\x07]*\x07//g; s/\r//g' \ - || true - echo '```' - echo - echo '
' - fi - } > /tmp/bench-diff-comment.md - - echo "---- bench-diff comment body ----" - cat /tmp/bench-diff-comment.md - echo "---- end bench-diff comment body ----" - - # Find an existing bench-diff comment by its marker and edit it. - # If none exists, create a new one. This keeps PR threads clean - # as new commits come in — one rolling comment, not a pile of them. - # - # Fork PRs get a read-only GITHUB_TOKEN regardless of the - # `pull-requests: write` permission above — GitHub blocks - # write tokens to mitigate untrusted-fork attacks. Log a - # warning instead of failing the job; the comment body is - # already in the step log right above. - MARKER="" - # `gh --jq` takes a jq expression but NOT `--arg` (that's a - # jq-binary flag), so we inline the marker via shell - # interpolation instead of `--arg marker $MARKER`. The - # marker is a known-safe HTML comment with the runner-slug - # in it — no shell or jq metachars to escape. - # - # The trailing `2>/dev/null || echo ""` makes the lookup - # graceful: if gh hits a transient API error, a fork-PR - # token rejection, or anything else, EXISTING_ID just ends - # up empty and we fall through to the "post a new comment" - # path below. Better than failing the whole Bench-diff step - # because of a comment-dedup lookup. PR #159 first tried - # `gh pr view --json comments | jq -r --arg marker ...` - # which fixed the `--arg` tokenisation but introduced a - # second failure mode (the pipe propagating jq parse errors - # back through `set -o pipefail` on the shebang). - EXISTING_ID=$(gh pr view "$PR_NUMBER" --json comments --jq \ - "[.comments[] | select(.body | contains(\"$MARKER\")) | .id] | first" \ - 2>/dev/null || echo "") - - REPO="${{ github.repository }}" - if [ -n "$EXISTING_ID" ]; then - echo "Updating existing bench-diff comment #${EXISTING_ID}" - if ! gh api "repos/${REPO}/issues/comments/${EXISTING_ID}" \ - -X PATCH -F body=@/tmp/bench-diff-comment.md --silent; then - echo "::warning::Could not update bench-diff comment (likely a fork PR with read-only token)." - fi - else - echo "Creating new bench-diff comment" - if ! gh pr comment "$PR_NUMBER" --body-file /tmp/bench-diff-comment.md; then - echo "::warning::Could not post bench-diff comment (likely a fork PR with read-only token). Comment body is in this step log." - fi - fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..86c37d35 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,98 @@ +name: Lint + +# Reusable: fast, cheap, Linux-only gates that run on every PR and release +# before the heavier build/test/correctness jobs. Called by ci.yml. +on: + workflow_call: {} + +permissions: + contents: read + # commit-hygiene posts a findings comment and applies a label on failing PRs. + pull-requests: write + +env: + CLICOLOR: 1 + +jobs: + typos: + name: Typos + runs-on: ubuntu-latest + steps: + - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: crate-ci/typos@v1 + with: + config: .github/configs/typos-cli.toml + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + static.crates.io:443 + crates.io:443 + index.crates.io:443 + static.rust-lang.org:443 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-rust + with: + components: rustfmt + cache: "false" + - run: cargo fmt --check --all + + # `--all-features` pulls in the cuda / hip / vulkan backends, which only + # compile-check on Linux (no CUDA/HIP SDK on the macOS test runners), so + # clippy stays on ubuntu. Lints only; runs no tests. + clippy: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + static.crates.io:443 + crates.io:443 + index.crates.io:443 + static.rust-lang.org:443 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-rust + with: + components: clippy + cache-key: clippy + - run: cargo clippy --all-targets --all-features -- -D warnings + + commit-hygiene: + name: Commits + runs-on: ubuntu-latest + # PR-only: the check reads pull_request title/body/commits. On a release + # (tag push) there is no PR context, so it self-skips. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - name: Run hygiene check + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO: ${{ github.repository }} + run: python3 scripts/commit_hygiene.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 46b9814e..b68dc39f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,4 +1,4 @@ -name: PR +name: PR Conventions concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2577c1b4..f4c74746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,35 +1,41 @@ name: Release -# Trigger on semver tags pushed to main (e.g. v0.2.0, v1.0.0-rc.1). -# Also runnable manually to re-publish a tag that already exists. +# Reusable. Builds the release binary and publishes the GitHub Release. +# +# The tag trigger lives in ci.yml, not here: on a `v*` tag push ci.yml runs +# lint + build + test + correctness and only THEN calls this workflow, so a +# release can never publish from a red commit. `workflow_dispatch` stays for +# re-publishing an existing tag by hand (ungated, intentional). on: - push: - tags: ['v[0-9]+.[0-9]+.[0-9]+*'] + workflow_call: + inputs: + tag: + description: Tag to release (e.g. v0.2.0). + required: true + type: string workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g. v0.2.0)' + description: Tag to release (e.g. v0.2.0). required: true permissions: - contents: write # create/update GitHub Releases and upload assets + contents: write # create/update GitHub Releases and upload assets jobs: - build: - name: Build iron binary + publish: + name: Build + publish iron binary runs-on: macos-26 timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 with: - # Use the explicit tag when triggered manually; otherwise the push - # event already has the tag checked out. - ref: ${{ inputs.tag || github.ref }} + ref: ${{ inputs.tag }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: - save-if: false # release builds are rare; don't pollute the cache + save-if: false # release builds are rare; don't pollute the cache - name: Build release binary run: cargo build --release -p wh-iron-cli @@ -42,12 +48,11 @@ jobs: - name: Create GitHub Release env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | - TAG="${{ inputs.tag || github.ref_name }}" + TAG="${{ inputs.tag }}" - # Create the release (idempotent: --target prevents re-creating if - # the tag already exists from a previous manual run). + # Idempotent: create, or upload assets to an existing release. gh release create "$TAG" \ --title "$TAG" \ --generate-notes \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..422a695c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: Test + +# Reusable. The Rust test suite, split by what each half actually needs: +# +# logic (ubuntu) — `cargo nextest --workspace`. The GPU integration +# tests are `#![cfg(target_os = "macos")]`, so on +# Linux they compile to nothing and this runs only +# the fast, backend-agnostic logic: core IR, +# codegen, macros, runtime, CLI, and the +# codegen-every-registered-benchspec check. +# +# gpu-integration (macOS) — the macOS-only cargo GPU integration tests +# (tests/*_gpu.rs, the gdn-wy / moe pipeline and +# ragged-correctness scenarios). Excludes +# `kernel_tests_harness` (`all_registered_kernel_ +# tests_pass`) — that per-kernel CPU-oracle sweep +# is owned by correctness.yml's `iron test`, so we +# don't double-run it here. +on: + workflow_call: {} + +permissions: + contents: read + +env: + CLICOLOR: 1 + +jobs: + iron: + name: Iron Core (ubuntu) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + static.crates.io:443 + crates.io:443 + index.crates.io:443 + static.rust-lang.org:443 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-rust + with: + cache-key: test + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --workspace --no-fail-fast + + gpu-integration: + name: GPU integration (macOS) + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + # harden-runner blocking mode is Linux-only; audit on macOS. + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v5.0.0 + - uses: ./.github/actions/setup-rust + with: + cache-key: test-macos + # Runtime `makeLibrary` JIT of the MPP/bgemm cooperative-tensor kernels + # needs the Metal Toolchain (missing from the base macos-26 image). + - name: Install Metal Toolchain + run: | + sw_vers + xcodebuild -downloadComponent MetalToolchain + - uses: taiki-e/install-action@nextest + # Run wh-iron-std's GPU integration tests; skip the whole-inventory + # kernel harness (correctness.yml covers those kernels via `iron test`). + - run: cargo nextest run -p wh-iron-std -E 'not binary(kernel_tests_harness)' --no-fail-fast diff --git a/crates/wh-iron-cli/src/cmd/test.rs b/crates/wh-iron-cli/src/cmd/test.rs index 12bfa7d0..b86469ab 100644 --- a/crates/wh-iron-cli/src/cmd/test.rs +++ b/crates/wh-iron-cli/src/cmd/test.rs @@ -89,18 +89,25 @@ pub fn run(args: &TestArgs, harness: &crate::harness::Harness) -> Result<(), cra } if results.is_empty() && error_msgs.is_empty() { + // A user-supplied filter that matches nothing is a benign warning. if let Some(pattern) = &filter_args.filter { eprintln!( "{} no tests matched filter {pattern:?}", paint_stderr("warning:", Style::new().fg(Color::Yellow).bold()), ); - } else { - eprintln!( - "{} no #[test_kernel] tests registered", - paint_stderr("warning:", Style::new().fg(Color::Yellow).bold()), - ); + return Ok(()); } - return Ok(()); + // No filter AND zero registered `#[test_kernel]`s is NOT success — it + // means the kernel inventory failed to link (dead-strip / registration + // regression), so the runner had nothing to dispatch. A correctness + // gate that exercised zero kernels must fail loudly, never report + // green. Mirrors the cargo harness's `total > 0` / `n_kernels > 0` + // assertions, which this CLI path is the CI stand-in for. + return Err(crate::CliError::Other( + "no #[test_kernel] tests registered — kernel inventory did not link \ + (dead-strip / registration regression). Refusing to report success." + .into(), + )); } // Group consecutive results by kernel name to produce forge-style suite blocks.