From a797e71b68605a151872bd89ee3af5759299f020 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 11 Mar 2026 15:34:38 -0700 Subject: [PATCH 01/56] Add configure_multi_arch_ci.py scaffold with pipeline architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New script for multi-arch CI configuration, designed as a pipeline of data transformations with typed dataclass interfaces between steps: 1. Parse Inputs (CIInputs from GITHUB_EVENT_PATH) 2. Check Skip CI (gate: skip-ci label, docs-only changes) 3. Select Targets (trigger type + labels → GPU families) 4. Decide Stages (changed files → rebuild/prebuilt per stage) 5. Expand Matrix (families × variant → matrix entries) 6. Write Outputs (JSON → GITHUB_OUTPUT) This commit is the scaffold — dataclasses and pipeline shape with stub implementations. Each step function has a TODO for Phase 2 logic. Tests (19 passing) demonstrate the pattern for exercising each step. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 428 ++++++++++++++++++ .../tests/configure_multi_arch_ci_test.py | 396 ++++++++++++++++ 2 files changed, 824 insertions(+) create mode 100644 build_tools/github_actions/configure_multi_arch_ci.py create mode 100644 build_tools/github_actions/tests/configure_multi_arch_ci_test.py diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py new file mode 100644 index 00000000000..db1dd275e94 --- /dev/null +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +"""Configures CI matrix and stage decisions for multi-arch workflows. + +This script is a pipeline of data transformations: + + 1. Parse Inputs — read GitHub event context → CIInputs + 2. Check Skip CI — gate: should we skip CI entirely? + 3. Select Targets — trigger type + labels → GPU families + 4. Decide Stages — changed files + topology → rebuild/prebuilt per stage + 5. Expand Matrix — families × variant → matrix entries + 6. Write Outputs — JSON → GITHUB_OUTPUT + GITHUB_STEP_SUMMARY + +Each step (except 1 and 6) is a pure function of typed dataclasses, +independently testable without environment variables or filesystem access. + +Inputs: + GITHUB_EVENT_NAME : push, pull_request, schedule, workflow_dispatch + GITHUB_EVENT_PATH : JSON file with event payload (inputs, PR labels, etc.) + GITHUB_REF_NAME : Branch name + GITHUB_OUTPUT : Path to write workflow output variables + GITHUB_STEP_SUMMARY : Path to write workflow summary + BUILD_VARIANT : Build variant (workflow_call input, not in event payload) + +Outputs (written to GITHUB_OUTPUT): + linux_variants : JSON array of matrix entries + windows_variants : JSON array of matrix entries + linux_test_labels : JSON array of test label strings + windows_test_labels : JSON array of test label strings + enable_build_jobs : "true" or "false" + test_type : "smoke" or "full" +""" + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +# --------------------------------------------------------------------------- +# Dataclasses — the typed interfaces between pipeline steps +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CIInputs: + """All external inputs to the CI configuration pipeline. + + Constructed once from the GitHub Actions environment. Every downstream + function takes this (or a subset) as a plain argument — no environment + access needed. + """ + + event_name: str # push, pull_request, schedule, workflow_dispatch + branch_name: str + base_ref: str # Git ref for diffing (PR base or HEAD^1) + build_variant: str # release, asan, tsan + + # PR labels (from event payload for pull_request events) + pr_labels: list[str] = field(default_factory=list) + + # Per-platform workflow_dispatch overrides + linux_amdgpu_families: str = "" + windows_amdgpu_families: str = "" + linux_test_labels: str = "" + windows_test_labels: str = "" + + # Prebuilt configuration (from workflow_dispatch) + prebuilt_stages: str = "" + baseline_run_id: str = "" + + @property + def is_pull_request(self) -> bool: + return self.event_name == "pull_request" + + @property + def is_push(self) -> bool: + return self.event_name == "push" + + @property + def is_schedule(self) -> bool: + return self.event_name == "schedule" + + @property + def is_workflow_dispatch(self) -> bool: + return self.event_name == "workflow_dispatch" + + @staticmethod + def from_environ() -> CIInputs: + """Parse from GitHub Actions environment. + + Reads GITHUB_EVENT_PATH for the event payload and a few standard + env vars. This is the only function in the pipeline that touches + external state. + """ + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + branch_name = os.environ.get("GITHUB_REF_NAME", "") + if not branch_name: + print( + "[ERROR] GITHUB_REF_NAME is not set. Exiting.", + file=sys.stderr, + ) + sys.exit(1) + + # Read the full event payload + event_path = os.environ.get("GITHUB_EVENT_PATH", "") + if event_path and Path(event_path).exists(): + with open(event_path) as f: + event = json.load(f) + else: + event = {} + + # Extract fields based on event type + inputs = event.get("inputs") or {} + pr_labels: list[str] = [] + base_ref = "HEAD^1" + + if event_name == "pull_request": + pr_obj = event.get("pull_request", {}) + pr_labels = [label["name"] for label in pr_obj.get("labels", [])] + # The merge commit's first parent is the PR base + base_ref = "HEAD^" + elif event_name == "push": + base_ref = event.get("before", "HEAD^1") + + # BUILD_VARIANT comes from workflow_call inputs, not the event payload + build_variant = os.environ.get("BUILD_VARIANT", "release") + + return CIInputs( + event_name=event_name, + branch_name=branch_name, + base_ref=base_ref, + build_variant=build_variant, + pr_labels=pr_labels, + linux_amdgpu_families=inputs.get("linux_amdgpu_families", ""), + windows_amdgpu_families=inputs.get("windows_amdgpu_families", ""), + linux_test_labels=inputs.get("linux_test_labels", ""), + windows_test_labels=inputs.get("windows_test_labels", ""), + prebuilt_stages=inputs.get("prebuilt_stages", ""), + baseline_run_id=inputs.get("baseline_run_id", ""), + ) + + +@dataclass(frozen=True) +class SkipDecision: + """Whether to skip CI entirely.""" + + skip: bool + reason: str # e.g. "skip-ci label", "only .md files changed", "" + + +@dataclass(frozen=True) +class TargetSelection: + """Which GPU families to build/test, per platform.""" + + linux_families: list[str] = field(default_factory=list) + windows_families: list[str] = field(default_factory=list) + test_names: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class StageDecision: + """Decision for a single build stage.""" + + action: Literal["rebuild", "prebuilt"] + reason: str + + +@dataclass(frozen=True) +class StageDecisions: + """Per-stage build/prebuilt decisions and test type.""" + + decisions: dict[str, StageDecision] = field(default_factory=dict) + test_type: str = "smoke" + test_type_reason: str = "default" + + @property + def prebuilt_stages(self) -> list[str]: + return [name for name, d in self.decisions.items() if d.action == "prebuilt"] + + @property + def rebuild_stages(self) -> list[str]: + return [name for name, d in self.decisions.items() if d.action == "rebuild"] + + +@dataclass(frozen=True) +class MatrixEntry: + """One row of the GitHub Actions build matrix.""" + + matrix_per_family_json: str # JSON array of per-family info + dist_amdgpu_families: str # Semicolon-separated + artifact_group: str + build_variant_label: str + build_variant_suffix: str + build_variant_cmake_preset: str + expect_failure: bool + build_pytorch: bool + + def to_dict(self) -> dict: + """Convert to dict for JSON serialization.""" + return { + "matrix_per_family_json": self.matrix_per_family_json, + "dist_amdgpu_families": self.dist_amdgpu_families, + "artifact_group": self.artifact_group, + "build_variant_label": self.build_variant_label, + "build_variant_suffix": self.build_variant_suffix, + "build_variant_cmake_preset": self.build_variant_cmake_preset, + "expect_failure": self.expect_failure, + "build_pytorch": self.build_pytorch, + } + + +@dataclass(frozen=True) +class CIOutputs: + """All outputs from the CI configuration pipeline.""" + + linux_variants: list[MatrixEntry] = field(default_factory=list) + windows_variants: list[MatrixEntry] = field(default_factory=list) + linux_test_labels: list[str] = field(default_factory=list) + windows_test_labels: list[str] = field(default_factory=list) + enable_build_jobs: bool = True + test_type: str = "smoke" + # Stage decisions (feeds into prebuilt workflow plumbing) + prebuilt_stages: list[str] = field(default_factory=list) + rebuild_stages: list[str] = field(default_factory=list) + + @staticmethod + def skipped(reason: str) -> CIOutputs: + """Produce empty outputs when CI is skipped.""" + return CIOutputs(enable_build_jobs=False) + + +# --------------------------------------------------------------------------- +# Step 2: Check Skip CI +# --------------------------------------------------------------------------- + + +def check_skip_ci( + inputs: CIInputs, + changed_files: list[str] | None, +) -> SkipDecision: + """Determine whether CI should be skipped entirely. + + Returns SkipDecision(skip=True) for: + - 'skip-ci' PR label + - Only skippable files changed (docs, .md, etc.) + - No files changed + """ + # TODO: Implement — check skip-ci label, call is_ci_run_required() + return SkipDecision(skip=False, reason="") + + +# --------------------------------------------------------------------------- +# Step 3: Select Targets +# --------------------------------------------------------------------------- + + +def select_targets(inputs: CIInputs) -> TargetSelection: + """Determine GPU families and test names based on trigger type and inputs. + + Handles: + - workflow_dispatch: parse explicit family/test inputs + - pull_request: presubmit+postsubmit defaults, PR label opt-ins + - push: presubmit+postsubmit defaults + - schedule: all families + """ + # TODO: Implement — trigger dispatch, label parsing, family validation + return TargetSelection() + + +# --------------------------------------------------------------------------- +# Step 4: Decide Stages +# --------------------------------------------------------------------------- + + +def decide_stages( + inputs: CIInputs, + targets: TargetSelection, + changed_files: list[str] | None, +) -> StageDecisions: + """Determine per-stage rebuild/prebuilt decisions and test type. + + Currently returns "rebuild all" — source-set-aware logic comes in Phase 4. + """ + # TODO: Implement — topology parsing, source-set analysis, propagation + return StageDecisions(test_type="smoke", test_type_reason="default (stub)") + + +# --------------------------------------------------------------------------- +# Step 5: Expand Matrix +# --------------------------------------------------------------------------- + + +def expand_matrix( + families: list[str], + platform: str, + build_variant: str, +) -> list[MatrixEntry]: + """Expand families into multi-arch matrix entries for one platform. + + Groups all families into one entry per build variant (the multi-arch + format), rather than one entry per family (single-arch format). + """ + # TODO: Implement — port generate_multi_arch_matrix logic + return [] + + +# --------------------------------------------------------------------------- +# Step 6: Format and Write Outputs +# --------------------------------------------------------------------------- + + +def format_summary(outputs: CIOutputs) -> str: + """Generate human-readable markdown summary. Pure function.""" + # TODO: Implement — structured markdown with families, stages, reasons + lines = ["## Multi-Arch CI Configuration"] + lines.append("") + lines.append(f"* `enable_build_jobs`: {outputs.enable_build_jobs}") + lines.append(f"* `test_type`: {outputs.test_type}") + return "\n".join(lines) + + +def write_outputs(outputs: CIOutputs) -> None: + """Write results to GITHUB_OUTPUT and GITHUB_STEP_SUMMARY. + + This is the only function with side effects (besides from_environ). + """ + from github_actions_utils import gha_set_output, gha_append_step_summary + + output_vars = { + "linux_variants": json.dumps( + [entry.to_dict() for entry in outputs.linux_variants] + ), + "linux_test_labels": json.dumps(outputs.linux_test_labels), + "windows_variants": json.dumps( + [entry.to_dict() for entry in outputs.windows_variants] + ), + "windows_test_labels": json.dumps(outputs.windows_test_labels), + "enable_build_jobs": json.dumps(outputs.enable_build_jobs), + "test_type": outputs.test_type, + } + gha_set_output(output_vars) + gha_append_step_summary(format_summary(outputs)) + + +# --------------------------------------------------------------------------- +# Pipeline orchestration +# --------------------------------------------------------------------------- + + +def configure(inputs: CIInputs) -> CIOutputs: + """Main pipeline. Each step feeds the next. + + This function is the primary entry point for testing — construct a + CIInputs and assert on the returned CIOutputs. + """ + # Step 1 already done — inputs is the parsed CIInputs. + + # Step 2: Gate — should we skip CI entirely? + # For schedule and workflow_dispatch, always proceed. + changed_files: list[str] | None = None + if inputs.is_pull_request or inputs.is_push: + from configure_ci_path_filters import get_git_modified_paths + + changed_files = get_git_modified_paths(inputs.base_ref) + + skip = check_skip_ci(inputs, changed_files) + if skip.skip: + print(f"Skipping CI: {skip.reason}") + return CIOutputs.skipped(skip.reason) + + # Step 3: Select targets + targets = select_targets(inputs) + + # Step 4: Decide stages (stub: rebuild all) + stage_decisions = decide_stages(inputs, targets, changed_files) + + # Step 5: Expand matrix + linux_matrix = expand_matrix( + targets.linux_families, + "linux", + inputs.build_variant, + ) + windows_matrix = expand_matrix( + targets.windows_families, + "windows", + inputs.build_variant, + ) + + return CIOutputs( + linux_variants=linux_matrix, + windows_variants=windows_matrix, + linux_test_labels=targets.test_names, + windows_test_labels=targets.test_names, + enable_build_jobs=True, + test_type=stage_decisions.test_type, + prebuilt_stages=stage_decisions.prebuilt_stages, + rebuild_stages=stage_decisions.rebuild_stages, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + inputs = CIInputs.from_environ() + + print("Multi-arch CI configuration") + print(f" event: {inputs.event_name}") + print(f" branch: {inputs.branch_name}") + print(f" variant: {inputs.build_variant}") + if inputs.pr_labels: + print(f" pr_labels: {inputs.pr_labels}") + print() + + outputs = configure(inputs) + write_outputs(outputs) + + +if __name__ == "__main__": + main() diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py new file mode 100644 index 00000000000..7536bacc45d --- /dev/null +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -0,0 +1,396 @@ +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +"""Tests for configure_multi_arch_ci.py. + +Each test demonstrates the pattern for testing a pipeline step: +construct the input dataclass, call the function, assert on the output. +No environment variables or filesystem access needed (except from_environ tests). +""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) +import configure_multi_arch_ci as cm + + +# --------------------------------------------------------------------------- +# CIInputs — construction and properties +# --------------------------------------------------------------------------- + + +class TestCIInputs(unittest.TestCase): + """Test CIInputs dataclass and its properties.""" + + def test_pull_request_properties(self): + inputs = cm.CIInputs( + event_name="pull_request", + branch_name="feature-branch", + base_ref="HEAD^", + build_variant="release", + pr_labels=["gfx950", "test:rocprim"], + ) + self.assertTrue(inputs.is_pull_request) + self.assertFalse(inputs.is_push) + self.assertFalse(inputs.is_schedule) + self.assertFalse(inputs.is_workflow_dispatch) + + def test_push_properties(self): + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="abc123", + build_variant="release", + ) + self.assertFalse(inputs.is_pull_request) + self.assertTrue(inputs.is_push) + + def test_schedule_properties(self): + inputs = cm.CIInputs( + event_name="schedule", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + self.assertTrue(inputs.is_schedule) + + def test_workflow_dispatch_properties(self): + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + linux_amdgpu_families="gfx94X, gfx120X", + ) + self.assertTrue(inputs.is_workflow_dispatch) + self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") + + def test_defaults(self): + """Fields with defaults can be omitted.""" + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + self.assertEqual(inputs.pr_labels, []) + self.assertEqual(inputs.linux_amdgpu_families, "") + self.assertEqual(inputs.prebuilt_stages, "") + + +class TestCIInputsFromEnviron(unittest.TestCase): + """Test CIInputs.from_environ() with event payload fixtures.""" + + def test_workflow_dispatch_event(self): + """from_environ reads workflow_dispatch inputs from GITHUB_EVENT_PATH.""" + event_payload = { + "inputs": { + "linux_amdgpu_families": "gfx94X, gfx120X", + "linux_test_labels": "test:rocprim", + "windows_amdgpu_families": "", + "windows_test_labels": "", + "prebuilt_stages": "foundation,compiler-runtime", + "baseline_run_id": "12345", + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(event_payload, f) + event_path = f.name + + try: + env = { + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_EVENT_PATH": event_path, + "GITHUB_REF_NAME": "main", + "BUILD_VARIANT": "release", + } + with patch.dict(os.environ, env, clear=False): + inputs = cm.CIInputs.from_environ() + + self.assertEqual(inputs.event_name, "workflow_dispatch") + self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") + self.assertEqual(inputs.linux_test_labels, "test:rocprim") + self.assertEqual(inputs.prebuilt_stages, "foundation,compiler-runtime") + self.assertEqual(inputs.baseline_run_id, "12345") + finally: + os.unlink(event_path) + + def test_pull_request_event_with_labels(self): + """from_environ extracts PR labels from the event payload.""" + event_payload = { + "pull_request": { + "labels": [ + {"name": "gfx950", "id": 1}, + {"name": "test:rocprim", "id": 2}, + ] + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(event_payload, f) + event_path = f.name + + try: + env = { + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": event_path, + "GITHUB_REF_NAME": "feature-branch", + "BUILD_VARIANT": "release", + } + with patch.dict(os.environ, env, clear=False): + inputs = cm.CIInputs.from_environ() + + self.assertEqual(inputs.event_name, "pull_request") + self.assertEqual(inputs.pr_labels, ["gfx950", "test:rocprim"]) + self.assertEqual(inputs.base_ref, "HEAD^") + finally: + os.unlink(event_path) + + def test_push_event(self): + """from_environ reads 'before' SHA for push events.""" + event_payload = {"before": "abc123def456"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(event_payload, f) + event_path = f.name + + try: + env = { + "GITHUB_EVENT_NAME": "push", + "GITHUB_EVENT_PATH": event_path, + "GITHUB_REF_NAME": "main", + "BUILD_VARIANT": "release", + } + with patch.dict(os.environ, env, clear=False): + inputs = cm.CIInputs.from_environ() + + self.assertEqual(inputs.base_ref, "abc123def456") + finally: + os.unlink(event_path) + + +# --------------------------------------------------------------------------- +# Step 2: Check Skip CI +# --------------------------------------------------------------------------- + + +class TestCheckSkipCI(unittest.TestCase): + """Test the skip CI gate.""" + + def _make_inputs(self, **kwargs) -> cm.CIInputs: + defaults = { + "event_name": "pull_request", + "branch_name": "feature", + "base_ref": "HEAD^", + "build_variant": "release", + } + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_no_skip_by_default(self): + """Default stub does not skip.""" + inputs = self._make_inputs() + result = cm.check_skip_ci(inputs, changed_files=["some/file.cpp"]) + self.assertFalse(result.skip) + + # TODO: Tests for skip-ci label, docs-only changes, no files changed + # These will be filled in when check_skip_ci is implemented (Phase 2). + + +# --------------------------------------------------------------------------- +# Step 3: Select Targets +# --------------------------------------------------------------------------- + + +class TestSelectTargets(unittest.TestCase): + """Test target family selection.""" + + def _make_inputs(self, **kwargs) -> cm.CIInputs: + defaults = { + "event_name": "push", + "branch_name": "main", + "base_ref": "HEAD^1", + "build_variant": "release", + } + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_returns_target_selection(self): + """Stub returns a TargetSelection dataclass.""" + inputs = self._make_inputs() + result = cm.select_targets(inputs) + self.assertIsInstance(result, cm.TargetSelection) + + # TODO: Tests for each trigger type, label parsing, family validation + # These will be filled in when select_targets is implemented (Phase 2). + + +# --------------------------------------------------------------------------- +# Step 4: Decide Stages +# --------------------------------------------------------------------------- + + +class TestDecideStages(unittest.TestCase): + """Test stage decision logic.""" + + def _make_inputs(self, **kwargs) -> cm.CIInputs: + defaults = { + "event_name": "push", + "branch_name": "main", + "base_ref": "HEAD^1", + "build_variant": "release", + } + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_stub_returns_stage_decisions(self): + """Stub returns a StageDecisions with smoke test type.""" + inputs = self._make_inputs() + targets = cm.TargetSelection() + result = cm.decide_stages(inputs, targets, changed_files=None) + self.assertIsInstance(result, cm.StageDecisions) + self.assertEqual(result.test_type, "smoke") + + def test_prebuilt_and_rebuild_stages(self): + """StageDecisions correctly partitions into prebuilt/rebuild lists.""" + decisions = cm.StageDecisions( + decisions={ + "foundation": cm.StageDecision(action="prebuilt", reason="no changes"), + "compiler-runtime": cm.StageDecision( + action="prebuilt", reason="no changes" + ), + "math-libs": cm.StageDecision( + action="rebuild", reason="rocm-libraries changed" + ), + } + ) + self.assertEqual( + sorted(decisions.prebuilt_stages), + ["compiler-runtime", "foundation"], + ) + self.assertEqual(decisions.rebuild_stages, ["math-libs"]) + + +# --------------------------------------------------------------------------- +# Step 5: Expand Matrix +# --------------------------------------------------------------------------- + + +class TestExpandMatrix(unittest.TestCase): + """Test matrix expansion.""" + + def test_empty_families_returns_empty(self): + """No families → no matrix entries.""" + result = cm.expand_matrix([], "linux", "release") + self.assertEqual(result, []) + + def test_matrix_entry_to_dict(self): + """MatrixEntry.to_dict() produces the expected structure.""" + entry = cm.MatrixEntry( + matrix_per_family_json='[{"amdgpu_family": "gfx94X-dcgpu"}]', + dist_amdgpu_families="gfx94X-dcgpu", + artifact_group="multi-arch-release", + build_variant_label="release", + build_variant_suffix="", + build_variant_cmake_preset="", + expect_failure=False, + build_pytorch=True, + ) + d = entry.to_dict() + self.assertEqual(d["artifact_group"], "multi-arch-release") + self.assertFalse(d["expect_failure"]) + self.assertTrue(d["build_pytorch"]) + + # TODO: Tests for actual family expansion logic (Phase 2). + + +# --------------------------------------------------------------------------- +# Step 6: Format Outputs +# --------------------------------------------------------------------------- + + +class TestFormatSummary(unittest.TestCase): + """Test summary formatting (pure function).""" + + def test_skipped_summary(self): + outputs = cm.CIOutputs.skipped("only .md files changed") + summary = cm.format_summary(outputs) + self.assertIn("enable_build_jobs", summary) + self.assertIn("False", summary) + + def test_normal_summary(self): + outputs = cm.CIOutputs(enable_build_jobs=True, test_type="full") + summary = cm.format_summary(outputs) + self.assertIn("True", summary) + self.assertIn("full", summary) + + +# --------------------------------------------------------------------------- +# End-to-end: configure() pipeline +# --------------------------------------------------------------------------- + + +class TestConfigurePipeline(unittest.TestCase): + """Test the full pipeline via configure().""" + + def test_skipped_outputs(self): + """CIOutputs.skipped produces empty, disabled outputs.""" + outputs = cm.CIOutputs.skipped("test reason") + self.assertFalse(outputs.enable_build_jobs) + self.assertEqual(outputs.linux_variants, []) + self.assertEqual(outputs.windows_variants, []) + + @patch("configure_multi_arch_ci.check_skip_ci") + def test_pipeline_skips_when_gate_says_skip(self, mock_skip): + """If check_skip_ci returns skip=True, pipeline short-circuits.""" + mock_skip.return_value = cm.SkipDecision(skip=True, reason="skip-ci label") + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + outputs = cm.configure(inputs) + self.assertFalse(outputs.enable_build_jobs) + self.assertEqual(outputs.linux_variants, []) + + @patch("configure_multi_arch_ci.check_skip_ci") + @patch("configure_multi_arch_ci.select_targets") + @patch("configure_multi_arch_ci.decide_stages") + @patch("configure_multi_arch_ci.expand_matrix") + def test_pipeline_calls_all_steps( + self, mock_expand, mock_stages, mock_targets, mock_skip + ): + """When not skipped, all pipeline steps are called.""" + mock_skip.return_value = cm.SkipDecision(skip=False, reason="") + mock_targets.return_value = cm.TargetSelection( + linux_families=["gfx94x"], + windows_families=[], + test_names=[], + ) + mock_stages.return_value = cm.StageDecisions( + test_type="smoke", test_type_reason="default" + ) + mock_expand.return_value = [] + + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + outputs = cm.configure(inputs) + + self.assertTrue(outputs.enable_build_jobs) + mock_targets.assert_called_once() + mock_stages.assert_called_once() + # expand_matrix called twice: once for linux, once for windows + self.assertEqual(mock_expand.call_count, 2) + + +if __name__ == "__main__": + unittest.main() From ffbed6130c01bd2cfe2b7b3f49d2abe62313597b Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Fri, 13 Mar 2026 14:27:40 -0700 Subject: [PATCH 02/56] Replace StageDecisions with job graph model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI pipeline is a DAG of job groups (build-rocm, test-rocm, build-rocm-python, build-pytorch, test-pytorch), not a flat list of stages. This restructures the data model to match: - JobGroupDecision base class (run/prebuilt/skip) with subclasses for groups that need extra details (BuildRocmDecision for per-stage granularity, TestRocmDecision for test type) - JobDecisions with explicit named fields for each job group node - decide_jobs() replaces decide_stages() - CIOutputs carries JobDecisions instead of flat test_type/stage lists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 146 +++++++++++++----- .../tests/configure_multi_arch_ci_test.py | 82 ++++++---- 2 files changed, 159 insertions(+), 69 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index db1dd275e94..04648b354eb 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -2,20 +2,31 @@ # Copyright Advanced Micro Devices, Inc. # SPDX-License-Identifier: MIT -"""Configures CI matrix and stage decisions for multi-arch workflows. +"""Configures CI matrix and job decisions for multi-arch workflows. This script is a pipeline of data transformations: 1. Parse Inputs — read GitHub event context → CIInputs 2. Check Skip CI — gate: should we skip CI entirely? 3. Select Targets — trigger type + labels → GPU families - 4. Decide Stages — changed files + topology → rebuild/prebuilt per stage + 4. Decide Jobs — changed files + topology → per-job-group decisions 5. Expand Matrix — families × variant → matrix entries 6. Write Outputs — JSON → GITHUB_OUTPUT + GITHUB_STEP_SUMMARY Each step (except 1 and 6) is a pure function of typed dataclasses, independently testable without environment variables or filesystem access. +The CI pipeline is a DAG of job groups: + + build-rocm → test-rocm + → build-rocm-python → build-pytorch → test-pytorch + → build-jax → test-jax (future) + +Step 4 determines which job groups to run, skip, or satisfy with prebuilt +artifacts. Within build-rocm, per-stage rebuild/prebuilt granularity is +available. Test details (which tests to run, smoke vs full) are decided +per test job group. + Inputs: GITHUB_EVENT_NAME : push, pull_request, schedule, workflow_dispatch GITHUB_EVENT_PATH : JSON file with event payload (inputs, PR labels, etc.) @@ -27,8 +38,6 @@ Outputs (written to GITHUB_OUTPUT): linux_variants : JSON array of matrix entries windows_variants : JSON array of matrix entries - linux_test_labels : JSON array of test label strings - windows_test_labels : JSON array of test label strings enable_build_jobs : "true" or "false" test_type : "smoke" or "full" """ @@ -160,32 +169,76 @@ class TargetSelection: linux_families: list[str] = field(default_factory=list) windows_families: list[str] = field(default_factory=list) - test_names: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Job decisions — the CI pipeline as a DAG of job groups +# +# build-rocm → test-rocm +# → build-rocm-python → build-pytorch → test-pytorch +# → build-jax → test-jax (future) +# +# Each node gets a JobGroupDecision (run/prebuilt/skip). Subclasses add +# group-specific details (per-stage granularity, test type, etc.). +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class JobGroupDecision: + """Decision for one node in the CI job graph.""" + + action: Literal["run", "prebuilt", "skip"] + reason: str @dataclass(frozen=True) class StageDecision: - """Decision for a single build stage.""" + """Decision for a single build stage within build-rocm.""" action: Literal["rebuild", "prebuilt"] reason: str @dataclass(frozen=True) -class StageDecisions: - """Per-stage build/prebuilt decisions and test type.""" +class BuildRocmDecision(JobGroupDecision): + """Build-rocm job group with per-stage granularity.""" - decisions: dict[str, StageDecision] = field(default_factory=dict) - test_type: str = "smoke" - test_type_reason: str = "default" + stage_decisions: dict[str, StageDecision] = field(default_factory=dict) @property def prebuilt_stages(self) -> list[str]: - return [name for name, d in self.decisions.items() if d.action == "prebuilt"] + return [ + name for name, d in self.stage_decisions.items() if d.action == "prebuilt" + ] @property def rebuild_stages(self) -> list[str]: - return [name for name, d in self.decisions.items() if d.action == "rebuild"] + return [ + name for name, d in self.stage_decisions.items() if d.action == "rebuild" + ] + + +@dataclass(frozen=True) +class TestRocmDecision(JobGroupDecision): + """Test-rocm job group with test filtering details.""" + + test_type: str = "smoke" # smoke or full + test_type_reason: str = "default" + + +@dataclass(frozen=True) +class JobDecisions: + """Decisions for the entire CI job graph. + + Each field corresponds to a node in the job DAG. The field types show + which groups have extra decision logic beyond run/skip/prebuilt. + """ + + build_rocm: BuildRocmDecision + test_rocm: TestRocmDecision + build_rocm_python: JobGroupDecision + build_pytorch: JobGroupDecision + test_pytorch: JobGroupDecision @dataclass(frozen=True) @@ -219,20 +272,15 @@ def to_dict(self) -> dict: class CIOutputs: """All outputs from the CI configuration pipeline.""" + is_ci_enabled: bool = True linux_variants: list[MatrixEntry] = field(default_factory=list) windows_variants: list[MatrixEntry] = field(default_factory=list) - linux_test_labels: list[str] = field(default_factory=list) - windows_test_labels: list[str] = field(default_factory=list) - enable_build_jobs: bool = True - test_type: str = "smoke" - # Stage decisions (feeds into prebuilt workflow plumbing) - prebuilt_stages: list[str] = field(default_factory=list) - rebuild_stages: list[str] = field(default_factory=list) + jobs: JobDecisions | None = None @staticmethod def skipped(reason: str) -> CIOutputs: """Produce empty outputs when CI is skipped.""" - return CIOutputs(enable_build_jobs=False) + return CIOutputs(is_ci_enabled=False) # --------------------------------------------------------------------------- @@ -274,21 +322,29 @@ def select_targets(inputs: CIInputs) -> TargetSelection: # --------------------------------------------------------------------------- -# Step 4: Decide Stages +# Step 4: Decide Jobs # --------------------------------------------------------------------------- -def decide_stages( +def decide_jobs( inputs: CIInputs, targets: TargetSelection, changed_files: list[str] | None, -) -> StageDecisions: - """Determine per-stage rebuild/prebuilt decisions and test type. +) -> JobDecisions: + """Determine which job groups to run, skip, or satisfy with prebuilt files. - Currently returns "rebuild all" — source-set-aware logic comes in Phase 4. + Currently returns "run everything, rebuild all stages" — subgraph + selection based on changed files comes later. """ - # TODO: Implement — topology parsing, source-set analysis, propagation - return StageDecisions(test_type="smoke", test_type_reason="default (stub)") + # TODO: Implement — classify changed files, find entry point in job DAG, + # propagate forward through reachable nodes, mark unreachable as skip + return JobDecisions( + build_rocm=BuildRocmDecision(action="run", reason="default (stub)"), + test_rocm=TestRocmDecision(action="run", reason="default (stub)"), + build_rocm_python=JobGroupDecision(action="run", reason="default (stub)"), + build_pytorch=JobGroupDecision(action="run", reason="default (stub)"), + test_pytorch=JobGroupDecision(action="run", reason="default (stub)"), + ) # --------------------------------------------------------------------------- @@ -320,8 +376,18 @@ def format_summary(outputs: CIOutputs) -> str: # TODO: Implement — structured markdown with families, stages, reasons lines = ["## Multi-Arch CI Configuration"] lines.append("") - lines.append(f"* `enable_build_jobs`: {outputs.enable_build_jobs}") - lines.append(f"* `test_type`: {outputs.test_type}") + lines.append(f"* `is_ci_enabled`: {outputs.is_ci_enabled}") + if outputs.jobs: + lines.append(f"* `test_type`: {outputs.jobs.test_rocm.test_type}") + for name in ( + "build_rocm", + "test_rocm", + "build_rocm_python", + "build_pytorch", + "test_pytorch", + ): + decision = getattr(outputs.jobs, name) + lines.append(f"* `{name}`: {decision.action} — {decision.reason}") return "\n".join(lines) @@ -332,17 +398,17 @@ def write_outputs(outputs: CIOutputs) -> None: """ from github_actions_utils import gha_set_output, gha_append_step_summary + test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "smoke" output_vars = { "linux_variants": json.dumps( [entry.to_dict() for entry in outputs.linux_variants] ), - "linux_test_labels": json.dumps(outputs.linux_test_labels), "windows_variants": json.dumps( [entry.to_dict() for entry in outputs.windows_variants] ), - "windows_test_labels": json.dumps(outputs.windows_test_labels), - "enable_build_jobs": json.dumps(outputs.enable_build_jobs), - "test_type": outputs.test_type, + # Workflow YAML references this as 'enable_build_jobs' + "enable_build_jobs": json.dumps(outputs.is_ci_enabled), + "test_type": test_type, } gha_set_output(output_vars) gha_append_step_summary(format_summary(outputs)) @@ -377,8 +443,8 @@ def configure(inputs: CIInputs) -> CIOutputs: # Step 3: Select targets targets = select_targets(inputs) - # Step 4: Decide stages (stub: rebuild all) - stage_decisions = decide_stages(inputs, targets, changed_files) + # Step 4: Decide jobs (stub: run everything) + jobs = decide_jobs(inputs, targets, changed_files) # Step 5: Expand matrix linux_matrix = expand_matrix( @@ -393,14 +459,10 @@ def configure(inputs: CIInputs) -> CIOutputs: ) return CIOutputs( + is_ci_enabled=True, linux_variants=linux_matrix, windows_variants=windows_matrix, - linux_test_labels=targets.test_names, - windows_test_labels=targets.test_names, - enable_build_jobs=True, - test_type=stage_decisions.test_type, - prebuilt_stages=stage_decisions.prebuilt_stages, - rebuild_stages=stage_decisions.rebuild_stages, + jobs=jobs, ) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 7536bacc45d..463875dcd06 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -230,12 +230,12 @@ def test_returns_target_selection(self): # --------------------------------------------------------------------------- -# Step 4: Decide Stages +# Step 4: Decide Jobs # --------------------------------------------------------------------------- -class TestDecideStages(unittest.TestCase): - """Test stage decision logic.""" +class TestDecideJobs(unittest.TestCase): + """Test job decision logic.""" def _make_inputs(self, **kwargs) -> cm.CIInputs: defaults = { @@ -247,18 +247,32 @@ def _make_inputs(self, **kwargs) -> cm.CIInputs: defaults.update(kwargs) return cm.CIInputs(**defaults) - def test_stub_returns_stage_decisions(self): - """Stub returns a StageDecisions with smoke test type.""" + def test_stub_returns_job_decisions(self): + """Stub returns JobDecisions with all groups set to run.""" inputs = self._make_inputs() targets = cm.TargetSelection() - result = cm.decide_stages(inputs, targets, changed_files=None) - self.assertIsInstance(result, cm.StageDecisions) - self.assertEqual(result.test_type, "smoke") - - def test_prebuilt_and_rebuild_stages(self): - """StageDecisions correctly partitions into prebuilt/rebuild lists.""" - decisions = cm.StageDecisions( - decisions={ + result = cm.decide_jobs(inputs, targets, changed_files=None) + self.assertIsInstance(result, cm.JobDecisions) + self.assertEqual(result.build_rocm.action, "run") + self.assertEqual(result.test_rocm.action, "run") + self.assertEqual(result.build_rocm_python.action, "run") + self.assertEqual(result.build_pytorch.action, "run") + self.assertEqual(result.test_pytorch.action, "run") + + def test_test_rocm_has_test_type(self): + """TestRocmDecision carries test_type details.""" + inputs = self._make_inputs() + targets = cm.TargetSelection() + result = cm.decide_jobs(inputs, targets, changed_files=None) + self.assertIsInstance(result.test_rocm, cm.TestRocmDecision) + self.assertEqual(result.test_rocm.test_type, "smoke") + + def test_build_rocm_stage_partitioning(self): + """BuildRocmDecision correctly partitions stages into prebuilt/rebuild.""" + decision = cm.BuildRocmDecision( + action="run", + reason="source changes", + stage_decisions={ "foundation": cm.StageDecision(action="prebuilt", reason="no changes"), "compiler-runtime": cm.StageDecision( action="prebuilt", reason="no changes" @@ -266,13 +280,13 @@ def test_prebuilt_and_rebuild_stages(self): "math-libs": cm.StageDecision( action="rebuild", reason="rocm-libraries changed" ), - } + }, ) self.assertEqual( - sorted(decisions.prebuilt_stages), + sorted(decision.prebuilt_stages), ["compiler-runtime", "foundation"], ) - self.assertEqual(decisions.rebuild_stages, ["math-libs"]) + self.assertEqual(decision.rebuild_stages, ["math-libs"]) # --------------------------------------------------------------------------- @@ -319,14 +333,24 @@ class TestFormatSummary(unittest.TestCase): def test_skipped_summary(self): outputs = cm.CIOutputs.skipped("only .md files changed") summary = cm.format_summary(outputs) - self.assertIn("enable_build_jobs", summary) + self.assertIn("is_ci_enabled", summary) self.assertIn("False", summary) def test_normal_summary(self): - outputs = cm.CIOutputs(enable_build_jobs=True, test_type="full") + jobs = cm.JobDecisions( + build_rocm=cm.BuildRocmDecision(action="run", reason="default"), + test_rocm=cm.TestRocmDecision( + action="run", reason="default", test_type="full" + ), + build_rocm_python=cm.JobGroupDecision(action="run", reason="default"), + build_pytorch=cm.JobGroupDecision(action="run", reason="default"), + test_pytorch=cm.JobGroupDecision(action="run", reason="default"), + ) + outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) summary = cm.format_summary(outputs) self.assertIn("True", summary) self.assertIn("full", summary) + self.assertIn("build_rocm", summary) # --------------------------------------------------------------------------- @@ -340,7 +364,7 @@ class TestConfigurePipeline(unittest.TestCase): def test_skipped_outputs(self): """CIOutputs.skipped produces empty, disabled outputs.""" outputs = cm.CIOutputs.skipped("test reason") - self.assertFalse(outputs.enable_build_jobs) + self.assertFalse(outputs.is_ci_enabled) self.assertEqual(outputs.linux_variants, []) self.assertEqual(outputs.windows_variants, []) @@ -355,25 +379,28 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): build_variant="release", ) outputs = cm.configure(inputs) - self.assertFalse(outputs.enable_build_jobs) + self.assertFalse(outputs.is_ci_enabled) self.assertEqual(outputs.linux_variants, []) @patch("configure_multi_arch_ci.check_skip_ci") @patch("configure_multi_arch_ci.select_targets") - @patch("configure_multi_arch_ci.decide_stages") + @patch("configure_multi_arch_ci.decide_jobs") @patch("configure_multi_arch_ci.expand_matrix") def test_pipeline_calls_all_steps( - self, mock_expand, mock_stages, mock_targets, mock_skip + self, mock_expand, mock_jobs, mock_targets, mock_skip ): """When not skipped, all pipeline steps are called.""" mock_skip.return_value = cm.SkipDecision(skip=False, reason="") mock_targets.return_value = cm.TargetSelection( linux_families=["gfx94x"], windows_families=[], - test_names=[], ) - mock_stages.return_value = cm.StageDecisions( - test_type="smoke", test_type_reason="default" + mock_jobs.return_value = cm.JobDecisions( + build_rocm=cm.BuildRocmDecision(action="run", reason="default"), + test_rocm=cm.TestRocmDecision(action="run", reason="default"), + build_rocm_python=cm.JobGroupDecision(action="run", reason="default"), + build_pytorch=cm.JobGroupDecision(action="run", reason="default"), + test_pytorch=cm.JobGroupDecision(action="run", reason="default"), ) mock_expand.return_value = [] @@ -385,9 +412,10 @@ def test_pipeline_calls_all_steps( ) outputs = cm.configure(inputs) - self.assertTrue(outputs.enable_build_jobs) + self.assertTrue(outputs.is_ci_enabled) + self.assertIsNotNone(outputs.jobs) mock_targets.assert_called_once() - mock_stages.assert_called_once() + mock_jobs.assert_called_once() # expand_matrix called twice: once for linux, once for windows self.assertEqual(mock_expand.call_count, 2) From da179a9c7e8cd943381c92614ebea8e46e001785 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Fri, 13 Mar 2026 14:50:26 -0700 Subject: [PATCH 03/56] Style guide compliance and rename enable_build_jobs to is_ci_enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename internal field to is_ci_enabled (output key stays enable_build_jobs for workflow compat) - Remove from __future__ import annotations per style guide - Move inline imports to top of file - Use named arguments for multi-param function calls - Drop unused targets param from decide_jobs() - Note that steps 3 and 4 are independent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 35 ++++++++----------- .../tests/configure_multi_arch_ci_test.py | 6 ++-- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 04648b354eb..e0ac3612e4f 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -42,8 +42,6 @@ test_type : "smoke" or "full" """ -from __future__ import annotations - import json import os import sys @@ -51,6 +49,9 @@ from pathlib import Path from typing import Literal +from configure_ci_path_filters import get_git_modified_paths +from github_actions_utils import gha_append_step_summary, gha_set_output + # --------------------------------------------------------------------------- # Dataclasses — the typed interfaces between pipeline steps # --------------------------------------------------------------------------- @@ -100,7 +101,7 @@ def is_workflow_dispatch(self) -> bool: return self.event_name == "workflow_dispatch" @staticmethod - def from_environ() -> CIInputs: + def from_environ() -> "CIInputs": """Parse from GitHub Actions environment. Reads GITHUB_EVENT_PATH for the event payload and a few standard @@ -278,7 +279,7 @@ class CIOutputs: jobs: JobDecisions | None = None @staticmethod - def skipped(reason: str) -> CIOutputs: + def skipped(reason: str) -> "CIOutputs": """Produce empty outputs when CI is skipped.""" return CIOutputs(is_ci_enabled=False) @@ -328,7 +329,6 @@ def select_targets(inputs: CIInputs) -> TargetSelection: def decide_jobs( inputs: CIInputs, - targets: TargetSelection, changed_files: list[str] | None, ) -> JobDecisions: """Determine which job groups to run, skip, or satisfy with prebuilt files. @@ -396,8 +396,6 @@ def write_outputs(outputs: CIOutputs) -> None: This is the only function with side effects (besides from_environ). """ - from github_actions_utils import gha_set_output, gha_append_step_summary - test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "smoke" output_vars = { "linux_variants": json.dumps( @@ -431,31 +429,28 @@ def configure(inputs: CIInputs) -> CIOutputs: # For schedule and workflow_dispatch, always proceed. changed_files: list[str] | None = None if inputs.is_pull_request or inputs.is_push: - from configure_ci_path_filters import get_git_modified_paths - changed_files = get_git_modified_paths(inputs.base_ref) - skip = check_skip_ci(inputs, changed_files) + skip = check_skip_ci(inputs=inputs, changed_files=changed_files) if skip.skip: print(f"Skipping CI: {skip.reason}") return CIOutputs.skipped(skip.reason) - # Step 3: Select targets + # Steps 3 and 4 are independent: target selection (which GPU families) + # and job decisions (which job groups run) are orthogonal concerns. targets = select_targets(inputs) - - # Step 4: Decide jobs (stub: run everything) - jobs = decide_jobs(inputs, targets, changed_files) + jobs = decide_jobs(inputs=inputs, changed_files=changed_files) # Step 5: Expand matrix linux_matrix = expand_matrix( - targets.linux_families, - "linux", - inputs.build_variant, + families=targets.linux_families, + platform="linux", + build_variant=inputs.build_variant, ) windows_matrix = expand_matrix( - targets.windows_families, - "windows", - inputs.build_variant, + families=targets.windows_families, + platform="windows", + build_variant=inputs.build_variant, ) return CIOutputs( diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 463875dcd06..e6674dad767 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -250,8 +250,7 @@ def _make_inputs(self, **kwargs) -> cm.CIInputs: def test_stub_returns_job_decisions(self): """Stub returns JobDecisions with all groups set to run.""" inputs = self._make_inputs() - targets = cm.TargetSelection() - result = cm.decide_jobs(inputs, targets, changed_files=None) + result = cm.decide_jobs(inputs, changed_files=None) self.assertIsInstance(result, cm.JobDecisions) self.assertEqual(result.build_rocm.action, "run") self.assertEqual(result.test_rocm.action, "run") @@ -262,8 +261,7 @@ def test_stub_returns_job_decisions(self): def test_test_rocm_has_test_type(self): """TestRocmDecision carries test_type details.""" inputs = self._make_inputs() - targets = cm.TargetSelection() - result = cm.decide_jobs(inputs, targets, changed_files=None) + result = cm.decide_jobs(inputs, changed_files=None) self.assertIsInstance(result.test_rocm, cm.TestRocmDecision) self.assertEqual(result.test_rocm.test_type, "smoke") From bd7f1a361229011e998e820390690cd8db9aeb4c Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Fri, 13 Mar 2026 15:25:53 -0700 Subject: [PATCH 04/56] Simplify test structure and reduce boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse 4 event type property tests into 1 mutual exclusivity check - Extract _run_from_environ helper for GITHUB_EVENT_PATH test fixtures with doc link explaining the testing strategy - Remove _make_inputs helper — inline CIInputs construction so each test's inputs are visible at the call site - Use named args on expand_matrix test call - Focus from_environ tests on what's unique per event type (PR labels, workflow_dispatch inputs, push before SHA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../tests/configure_multi_arch_ci_test.py | 276 ++++++++---------- 1 file changed, 122 insertions(+), 154 deletions(-) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index e6674dad767..896e9df5ade 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -20,6 +20,43 @@ import configure_multi_arch_ci as cm +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_from_environ( + event_name: str, + event_payload: dict, + *, + branch_name: str = "main", + build_variant: str = "release", +) -> cm.CIInputs: + """Call CIInputs.from_environ() with a synthetic event payload. + + GitHub Actions sets GITHUB_EVENT_PATH to a JSON file containing the full + webhook event payload. This helper writes a temporary JSON file and patches + the environment to simulate that. + + See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-environment-variables#default-environment-variables + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(event_payload, f) + event_path = f.name + + try: + env = { + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": event_path, + "GITHUB_REF_NAME": branch_name, + "BUILD_VARIANT": build_variant, + } + with patch.dict(os.environ, env, clear=False): + return cm.CIInputs.from_environ() + finally: + os.unlink(event_path) + + # --------------------------------------------------------------------------- # CIInputs — construction and properties # --------------------------------------------------------------------------- @@ -28,49 +65,19 @@ class TestCIInputs(unittest.TestCase): """Test CIInputs dataclass and its properties.""" - def test_pull_request_properties(self): + def test_event_type_properties(self): + """Event type properties are mutually exclusive.""" inputs = cm.CIInputs( event_name="pull_request", - branch_name="feature-branch", + branch_name="feature", base_ref="HEAD^", build_variant="release", - pr_labels=["gfx950", "test:rocprim"], ) self.assertTrue(inputs.is_pull_request) self.assertFalse(inputs.is_push) self.assertFalse(inputs.is_schedule) self.assertFalse(inputs.is_workflow_dispatch) - def test_push_properties(self): - inputs = cm.CIInputs( - event_name="push", - branch_name="main", - base_ref="abc123", - build_variant="release", - ) - self.assertFalse(inputs.is_pull_request) - self.assertTrue(inputs.is_push) - - def test_schedule_properties(self): - inputs = cm.CIInputs( - event_name="schedule", - branch_name="main", - base_ref="HEAD^1", - build_variant="release", - ) - self.assertTrue(inputs.is_schedule) - - def test_workflow_dispatch_properties(self): - inputs = cm.CIInputs( - event_name="workflow_dispatch", - branch_name="main", - base_ref="HEAD^1", - build_variant="release", - linux_amdgpu_families="gfx94X, gfx120X", - ) - self.assertTrue(inputs.is_workflow_dispatch) - self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") - def test_defaults(self): """Fields with defaults can be omitted.""" inputs = cm.CIInputs( @@ -85,92 +92,61 @@ def test_defaults(self): class TestCIInputsFromEnviron(unittest.TestCase): - """Test CIInputs.from_environ() with event payload fixtures.""" - - def test_workflow_dispatch_event(self): - """from_environ reads workflow_dispatch inputs from GITHUB_EVENT_PATH.""" - event_payload = { - "inputs": { - "linux_amdgpu_families": "gfx94X, gfx120X", - "linux_test_labels": "test:rocprim", - "windows_amdgpu_families": "", - "windows_test_labels": "", - "prebuilt_stages": "foundation,compiler-runtime", - "baseline_run_id": "12345", - } - } - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(event_payload, f) - event_path = f.name - - try: - env = { - "GITHUB_EVENT_NAME": "workflow_dispatch", - "GITHUB_EVENT_PATH": event_path, - "GITHUB_REF_NAME": "main", - "BUILD_VARIANT": "release", - } - with patch.dict(os.environ, env, clear=False): - inputs = cm.CIInputs.from_environ() - - self.assertEqual(inputs.event_name, "workflow_dispatch") - self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") - self.assertEqual(inputs.linux_test_labels, "test:rocprim") - self.assertEqual(inputs.prebuilt_stages, "foundation,compiler-runtime") - self.assertEqual(inputs.baseline_run_id, "12345") - finally: - os.unlink(event_path) - - def test_pull_request_event_with_labels(self): - """from_environ extracts PR labels from the event payload.""" - event_payload = { - "pull_request": { - "labels": [ - {"name": "gfx950", "id": 1}, - {"name": "test:rocprim", "id": 2}, - ] - } - } - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(event_payload, f) - event_path = f.name - - try: - env = { - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_EVENT_PATH": event_path, - "GITHUB_REF_NAME": "feature-branch", - "BUILD_VARIANT": "release", - } - with patch.dict(os.environ, env, clear=False): - inputs = cm.CIInputs.from_environ() - - self.assertEqual(inputs.event_name, "pull_request") - self.assertEqual(inputs.pr_labels, ["gfx950", "test:rocprim"]) - self.assertEqual(inputs.base_ref, "HEAD^") - finally: - os.unlink(event_path) - - def test_push_event(self): - """from_environ reads 'before' SHA for push events.""" - event_payload = {"before": "abc123def456"} - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(event_payload, f) - event_path = f.name - - try: - env = { - "GITHUB_EVENT_NAME": "push", - "GITHUB_EVENT_PATH": event_path, - "GITHUB_REF_NAME": "main", - "BUILD_VARIANT": "release", - } - with patch.dict(os.environ, env, clear=False): - inputs = cm.CIInputs.from_environ() - - self.assertEqual(inputs.base_ref, "abc123def456") - finally: - os.unlink(event_path) + """Test CIInputs.from_environ() with event payload fixtures. + + GitHub Actions provides the full webhook event payload as a JSON file + via GITHUB_EVENT_PATH. Each event type has a different payload structure: + - workflow_dispatch: inputs are in event.inputs + - pull_request: PR labels are in event.pull_request.labels + - push: the previous HEAD SHA is in event.before + + See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-environment-variables#default-environment-variables + """ + + def test_workflow_dispatch_reads_inputs(self): + """workflow_dispatch inputs (families, labels, prebuilt config).""" + inputs = _run_from_environ( + event_name="workflow_dispatch", + event_payload={ + "inputs": { + "linux_amdgpu_families": "gfx94X, gfx120X", + "linux_test_labels": "test:rocprim", + "windows_amdgpu_families": "", + "windows_test_labels": "", + "prebuilt_stages": "foundation,compiler-runtime", + "baseline_run_id": "12345", + } + }, + ) + self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") + self.assertEqual(inputs.linux_test_labels, "test:rocprim") + self.assertEqual(inputs.prebuilt_stages, "foundation,compiler-runtime") + self.assertEqual(inputs.baseline_run_id, "12345") + + def test_pull_request_extracts_labels(self): + """PR labels are extracted from event.pull_request.labels.""" + inputs = _run_from_environ( + event_name="pull_request", + event_payload={ + "pull_request": { + "labels": [ + {"name": "gfx950", "id": 1}, + {"name": "test:rocprim", "id": 2}, + ] + } + }, + branch_name="feature-branch", + ) + self.assertEqual(inputs.pr_labels, ["gfx950", "test:rocprim"]) + self.assertEqual(inputs.base_ref, "HEAD^") + + def test_push_reads_before_sha(self): + """Push events use event.before as the diff base.""" + inputs = _run_from_environ( + event_name="push", + event_payload={"before": "abc123def456"}, + ) + self.assertEqual(inputs.base_ref, "abc123def456") # --------------------------------------------------------------------------- @@ -181,19 +157,14 @@ def test_push_event(self): class TestCheckSkipCI(unittest.TestCase): """Test the skip CI gate.""" - def _make_inputs(self, **kwargs) -> cm.CIInputs: - defaults = { - "event_name": "pull_request", - "branch_name": "feature", - "base_ref": "HEAD^", - "build_variant": "release", - } - defaults.update(kwargs) - return cm.CIInputs(**defaults) - def test_no_skip_by_default(self): """Default stub does not skip.""" - inputs = self._make_inputs() + inputs = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + ) result = cm.check_skip_ci(inputs, changed_files=["some/file.cpp"]) self.assertFalse(result.skip) @@ -209,19 +180,14 @@ def test_no_skip_by_default(self): class TestSelectTargets(unittest.TestCase): """Test target family selection.""" - def _make_inputs(self, **kwargs) -> cm.CIInputs: - defaults = { - "event_name": "push", - "branch_name": "main", - "base_ref": "HEAD^1", - "build_variant": "release", - } - defaults.update(kwargs) - return cm.CIInputs(**defaults) - def test_returns_target_selection(self): """Stub returns a TargetSelection dataclass.""" - inputs = self._make_inputs() + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) result = cm.select_targets(inputs) self.assertIsInstance(result, cm.TargetSelection) @@ -237,19 +203,14 @@ def test_returns_target_selection(self): class TestDecideJobs(unittest.TestCase): """Test job decision logic.""" - def _make_inputs(self, **kwargs) -> cm.CIInputs: - defaults = { - "event_name": "push", - "branch_name": "main", - "base_ref": "HEAD^1", - "build_variant": "release", - } - defaults.update(kwargs) - return cm.CIInputs(**defaults) - def test_stub_returns_job_decisions(self): """Stub returns JobDecisions with all groups set to run.""" - inputs = self._make_inputs() + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) result = cm.decide_jobs(inputs, changed_files=None) self.assertIsInstance(result, cm.JobDecisions) self.assertEqual(result.build_rocm.action, "run") @@ -260,7 +221,12 @@ def test_stub_returns_job_decisions(self): def test_test_rocm_has_test_type(self): """TestRocmDecision carries test_type details.""" - inputs = self._make_inputs() + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) result = cm.decide_jobs(inputs, changed_files=None) self.assertIsInstance(result.test_rocm, cm.TestRocmDecision) self.assertEqual(result.test_rocm.test_type, "smoke") @@ -297,7 +263,9 @@ class TestExpandMatrix(unittest.TestCase): def test_empty_families_returns_empty(self): """No families → no matrix entries.""" - result = cm.expand_matrix([], "linux", "release") + result = cm.expand_matrix( + families=[], platform="linux", build_variant="release" + ) self.assertEqual(result, []) def test_matrix_entry_to_dict(self): From 49871520db9b27a6a9b4881cc13394fdf6c72480 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Fri, 13 Mar 2026 16:14:37 -0700 Subject: [PATCH 05/56] Implement select_targets with trigger-type dispatch and PR label parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder pipeline steps: decide_jobs (3) before select_targets (4) so target selection and matrix expansion are adjacent. select_targets policy per trigger type: - workflow_dispatch: explicit per-platform family lists from inputs - pull_request: presubmit defaults + gfx*/run-all-archs-ci label opt-ins - push: presubmit+postsubmit families - schedule: all families - Unknown trigger types raise ValueError Input parsing moved to the boundary: CIInputs.linux_amdgpu_families is now list[str] (parsed from comma-separated input in from_environ). Unknown family names fail fast with ValueError. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 161 ++++++++++--- .../tests/configure_multi_arch_ci_test.py | 215 +++++++++++++++--- 2 files changed, 321 insertions(+), 55 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index e0ac3612e4f..5f0519420bf 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -8,8 +8,8 @@ 1. Parse Inputs — read GitHub event context → CIInputs 2. Check Skip CI — gate: should we skip CI entirely? - 3. Select Targets — trigger type + labels → GPU families - 4. Decide Jobs — changed files + topology → per-job-group decisions + 3. Decide Jobs — changed files + topology → per-job-group decisions + 4. Select Targets — trigger type + labels → per-platform GPU families 5. Expand Matrix — families × variant → matrix entries 6. Write Outputs — JSON → GITHUB_OUTPUT + GITHUB_STEP_SUMMARY @@ -49,9 +49,23 @@ from pathlib import Path from typing import Literal +from amdgpu_family_matrix import get_all_families_for_trigger_types from configure_ci_path_filters import get_git_modified_paths from github_actions_utils import gha_append_step_summary, gha_set_output +# --------------------------------------------------------------------------- +# Input parsing helpers +# --------------------------------------------------------------------------- + + +def _parse_comma_list(raw: str) -> list[str]: + """Parse a comma-separated string into a list of stripped, non-empty names. + + Example: "gfx94X, gfx120X" → ["gfx94X", "gfx120X"] + """ + return [name.strip() for name in raw.split(",") if name.strip()] + + # --------------------------------------------------------------------------- # Dataclasses — the typed interfaces between pipeline steps # --------------------------------------------------------------------------- @@ -74,9 +88,9 @@ class CIInputs: # PR labels (from event payload for pull_request events) pr_labels: list[str] = field(default_factory=list) - # Per-platform workflow_dispatch overrides - linux_amdgpu_families: str = "" - windows_amdgpu_families: str = "" + # Per-platform workflow_dispatch overrides (parsed from comma-separated input) + linux_amdgpu_families: list[str] = field(default_factory=list) + windows_amdgpu_families: list[str] = field(default_factory=list) linux_test_labels: str = "" windows_test_labels: str = "" @@ -147,8 +161,12 @@ def from_environ() -> "CIInputs": base_ref=base_ref, build_variant=build_variant, pr_labels=pr_labels, - linux_amdgpu_families=inputs.get("linux_amdgpu_families", ""), - windows_amdgpu_families=inputs.get("windows_amdgpu_families", ""), + linux_amdgpu_families=_parse_comma_list( + inputs.get("linux_amdgpu_families", "") + ), + windows_amdgpu_families=_parse_comma_list( + inputs.get("windows_amdgpu_families", "") + ), linux_test_labels=inputs.get("linux_test_labels", ""), windows_test_labels=inputs.get("windows_test_labels", ""), prebuilt_stages=inputs.get("prebuilt_stages", ""), @@ -305,25 +323,7 @@ def check_skip_ci( # --------------------------------------------------------------------------- -# Step 3: Select Targets -# --------------------------------------------------------------------------- - - -def select_targets(inputs: CIInputs) -> TargetSelection: - """Determine GPU families and test names based on trigger type and inputs. - - Handles: - - workflow_dispatch: parse explicit family/test inputs - - pull_request: presubmit+postsubmit defaults, PR label opt-ins - - push: presubmit+postsubmit defaults - - schedule: all families - """ - # TODO: Implement — trigger dispatch, label parsing, family validation - return TargetSelection() - - -# --------------------------------------------------------------------------- -# Step 4: Decide Jobs +# Step 3: Decide Jobs # --------------------------------------------------------------------------- @@ -347,6 +347,109 @@ def decide_jobs( ) +# --------------------------------------------------------------------------- +# Step 4: Select Targets +# --------------------------------------------------------------------------- + + +def _validate_family_names( + names: list[str], + known: dict[str, dict], +) -> None: + """Raise ValueError if any family name is not in the known matrix.""" + unknown = [name for name in names if name not in known] + if unknown: + raise ValueError( + f"Unknown GPU families: {unknown}. " + f"Known families: {sorted(known.keys())}" + ) + + +def _filter_families_by_platform( + family_names: list[str], + platform: str, + lookup_matrix: dict[str, dict], +) -> list[str]: + """Return only the family names that have an entry for the given platform.""" + return [ + name + for name in family_names + if name in lookup_matrix and platform in lookup_matrix[name] + ] + + +def select_targets(inputs: CIInputs) -> TargetSelection: + """Determine GPU families per platform based on trigger type and inputs. + + Policy: + - workflow_dispatch: explicit per-platform family inputs + - pull_request: presubmit defaults + PR label opt-ins + - push: presubmit+postsubmit families + - schedule: all families (presubmit+postsubmit+nightly) + + Returns per-platform family lists, filtered to only include families + that have a platform entry in amdgpu_family_matrix.py. + """ + all_families = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + + # Select family names per platform based on trigger type + if inputs.is_workflow_dispatch: + # workflow_dispatch: Family names are taken directly from workflow inputs. + linux_names = list(inputs.linux_amdgpu_families) + windows_names = list(inputs.windows_amdgpu_families) + elif inputs.is_pull_request: + # pull_request: presubmit only; PR labels can extend below. + defaults = list(get_all_families_for_trigger_types(["presubmit"]).keys()) + linux_names = list(defaults) + windows_names = list(defaults) + elif inputs.is_push: + # push: Include presubmit _and_ postsubmit. + defaults = list( + get_all_families_for_trigger_types(["presubmit", "postsubmit"]).keys() + ) + linux_names = list(defaults) + windows_names = list(defaults) + elif inputs.is_schedule: + # schedule: Include all families. + linux_names = list(all_families.keys()) + windows_names = list(all_families.keys()) + else: + raise ValueError(f"Unsupported event type: {inputs.event_name!r}") + + # PR labels can extend the family set (both platforms) + if inputs.is_pull_request: + for label in inputs.pr_labels: + if label == "run-all-archs-ci": + # Override to all families. + linux_names = list(all_families.keys()) + windows_names = list(all_families.keys()) + print(" Label 'run-all-archs-ci' -> all families") + break + if label.startswith("gfx"): + target = label.split("-")[0] + linux_names.append(target) + windows_names.append(target) + print(f" Label '{label}' -> adding target {target}") + + # De-dup, validate, then filter by platform availability. + linux_names = list(dict.fromkeys(linux_names)) + windows_names = list(dict.fromkeys(windows_names)) + _validate_family_names(linux_names, all_families) + _validate_family_names(windows_names, all_families) + # TODO: For workflow_dispatch, a family requested for a specific platform + # but not available there (e.g. gfx94x on windows) is silently dropped. + # Consider validating per-platform and reporting the mismatch. + linux_names = _filter_families_by_platform(linux_names, "linux", all_families) + windows_names = _filter_families_by_platform(windows_names, "windows", all_families) + + return TargetSelection( + linux_families=linux_names, + windows_families=windows_names, + ) + + # --------------------------------------------------------------------------- # Step 5: Expand Matrix # --------------------------------------------------------------------------- @@ -436,10 +539,10 @@ def configure(inputs: CIInputs) -> CIOutputs: print(f"Skipping CI: {skip.reason}") return CIOutputs.skipped(skip.reason) - # Steps 3 and 4 are independent: target selection (which GPU families) - # and job decisions (which job groups run) are orthogonal concerns. - targets = select_targets(inputs) + # Steps 3 and 4 are independent: job decisions (which job groups run) + # and target selection (which GPU families) are orthogonal concerns. jobs = decide_jobs(inputs=inputs, changed_files=changed_files) + targets = select_targets(inputs) # Step 5: Expand matrix linux_matrix = expand_matrix( diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 896e9df5ade..e2373639e05 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -87,7 +87,7 @@ def test_defaults(self): build_variant="release", ) self.assertEqual(inputs.pr_labels, []) - self.assertEqual(inputs.linux_amdgpu_families, "") + self.assertEqual(inputs.linux_amdgpu_families, []) self.assertEqual(inputs.prebuilt_stages, "") @@ -118,7 +118,7 @@ def test_workflow_dispatch_reads_inputs(self): } }, ) - self.assertEqual(inputs.linux_amdgpu_families, "gfx94X, gfx120X") + self.assertEqual(inputs.linux_amdgpu_families, ["gfx94X", "gfx120X"]) self.assertEqual(inputs.linux_test_labels, "test:rocprim") self.assertEqual(inputs.prebuilt_stages, "foundation,compiler-runtime") self.assertEqual(inputs.baseline_run_id, "12345") @@ -173,30 +173,7 @@ def test_no_skip_by_default(self): # --------------------------------------------------------------------------- -# Step 3: Select Targets -# --------------------------------------------------------------------------- - - -class TestSelectTargets(unittest.TestCase): - """Test target family selection.""" - - def test_returns_target_selection(self): - """Stub returns a TargetSelection dataclass.""" - inputs = cm.CIInputs( - event_name="push", - branch_name="main", - base_ref="HEAD^1", - build_variant="release", - ) - result = cm.select_targets(inputs) - self.assertIsInstance(result, cm.TargetSelection) - - # TODO: Tests for each trigger type, label parsing, family validation - # These will be filled in when select_targets is implemented (Phase 2). - - -# --------------------------------------------------------------------------- -# Step 4: Decide Jobs +# Step 3: Decide Jobs # --------------------------------------------------------------------------- @@ -253,6 +230,192 @@ def test_build_rocm_stage_partitioning(self): self.assertEqual(decision.rebuild_stages, ["math-libs"]) +# --------------------------------------------------------------------------- +# Step 4: Select Targets +# --------------------------------------------------------------------------- + + +class TestSelectTargets(unittest.TestCase): + """Test target family selection. + + These tests exercise the trigger-type dispatch and label parsing logic. + Family names and platform availability come from amdgpu_family_matrix.py + (the real data), so tests assert on structural properties rather than + hardcoding specific family names. + """ + + def test_push_includes_postsubmit_families(self): + """Push trigger selects presubmit+postsubmit families.""" + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + result = cm.select_targets(inputs) + # gfx950 is postsubmit-only, should be present for push + self.assertIn("gfx950", result.linux_families) + + def test_schedule_returns_all_families(self): + """Schedule trigger selects all families (presubmit+postsubmit+nightly).""" + inputs = cm.CIInputs( + event_name="schedule", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + result = cm.select_targets(inputs) + # Schedule should have more families than push (nightly families added) + push_inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + push_result = cm.select_targets(push_inputs) + self.assertGreater(len(result.linux_families), len(push_result.linux_families)) + + def test_pull_request_defaults_to_presubmit_only(self): + """PR without labels gets presubmit families only, not postsubmit.""" + inputs = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + ) + result = cm.select_targets(inputs) + self.assertGreater(len(result.linux_families), 0) + # gfx950 is postsubmit-only, should NOT be in PR defaults + self.assertNotIn("gfx950", result.linux_families) + + def test_pull_request_gfx_label_adds_family(self): + """PR with a gfx label adds that family to the defaults.""" + inputs_without = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + ) + inputs_with = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + # gfx906 is nightly-only, not in presubmit+postsubmit defaults + pr_labels=["gfx906"], + ) + result_without = cm.select_targets(inputs_without) + result_with = cm.select_targets(inputs_with) + self.assertNotIn("gfx906", result_without.linux_families) + self.assertIn("gfx906", result_with.linux_families) + + def test_pull_request_run_all_archs_label(self): + """PR with run-all-archs-ci label selects all families.""" + inputs = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + pr_labels=["run-all-archs-ci"], + ) + result = cm.select_targets(inputs) + # Should include nightly-only families + self.assertIn("gfx906", result.linux_families) + + def test_pull_request_unknown_gfx_label_raises(self): + """PR with an unknown gfx label fails fast.""" + inputs = cm.CIInputs( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", + build_variant="release", + pr_labels=["gfx9999"], + ) + with self.assertRaises(ValueError, msg="Unknown GPU families"): + cm.select_targets(inputs) + + def test_workflow_dispatch_per_platform(self): + """workflow_dispatch selects families per platform.""" + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + linux_amdgpu_families=["gfx94x", "gfx110x"], + windows_amdgpu_families=["gfx110x"], + ) + result = cm.select_targets(inputs) + self.assertIn("gfx94x", result.linux_families) + self.assertIn("gfx110x", result.linux_families) + self.assertIn("gfx110x", result.windows_families) + # gfx94x has no windows entry in the matrix + self.assertNotIn("gfx94x", result.windows_families) + + def test_workflow_dispatch_empty_input(self): + """workflow_dispatch with empty lists returns empty families.""" + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + result = cm.select_targets(inputs) + self.assertEqual(result.linux_families, []) + self.assertEqual(result.windows_families, []) + + def test_workflow_dispatch_unknown_family_raises(self): + """workflow_dispatch with unknown family fails fast.""" + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + linux_amdgpu_families=["gfx_bogus"], + ) + with self.assertRaises(ValueError, msg="Unknown GPU families"): + cm.select_targets(inputs) + + @unittest.skip( + "TODO: workflow_dispatch should reject families unavailable on the requested platform" + ) + def test_workflow_dispatch_wrong_platform_raises(self): + """Requesting a family for a platform it doesn't support should fail.""" + inputs = cm.CIInputs( + event_name="workflow_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + # gfx950 has no windows entry — this should be an error, not silently dropped + windows_amdgpu_families=["gfx950"], + ) + with self.assertRaises(ValueError): + cm.select_targets(inputs) + + def test_unsupported_event_type_raises(self): + """Unknown event type raises ValueError.""" + inputs = cm.CIInputs( + event_name="repository_dispatch", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + with self.assertRaises(ValueError, msg="Unsupported event type"): + cm.select_targets(inputs) + + def test_platform_filtering(self): + """Families without a platform entry are excluded from that platform.""" + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + result = cm.select_targets(inputs) + # gfx94x is linux-only (no windows entry in presubmit matrix) + self.assertIn("gfx94x", result.linux_families) + self.assertNotIn("gfx94x", result.windows_families) + + # --------------------------------------------------------------------------- # Step 5: Expand Matrix # --------------------------------------------------------------------------- From 4a556cac5d881f3df704a91f72608761a24cd3d8 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 10:55:54 -0700 Subject: [PATCH 06/56] Document select_targets trigger-type intent and progression Expand comments in select_targets to explain the design rationale: trigger types form a progression from smallest (pull_request) to broadest (schedule), with workflow_dispatch giving full manual control. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 5f0519420bf..a4485f45bae 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -381,11 +381,20 @@ def _filter_families_by_platform( def select_targets(inputs: CIInputs) -> TargetSelection: """Determine GPU families per platform based on trigger type and inputs. - Policy: - - workflow_dispatch: explicit per-platform family inputs - - pull_request: presubmit defaults + PR label opt-ins - - push: presubmit+postsubmit families - - schedule: all families (presubmit+postsubmit+nightly) + Trigger types run progressively larger sets of builds and tests: + + - pull_request: Smallest default set (presubmit families). Designed for + fast feedback on proposed changes. PR labels can opt in to additional + families (gfx* labels) or the full set (run-all-archs-ci). + - push: Broader coverage (presubmit + postsubmit families). Runs on + code that has landed, so we want more thorough validation than PRs + without paying the full nightly cost. + - schedule: Full coverage (all families including nightly-only). Catches + regressions on targets that are too slow or expensive for every push. + - workflow_dispatch: Full manual control. Per-platform family inputs are + taken directly from the workflow inputs, giving the caller the ability + to either replicate what CI does on PRs/push or build/test a narrow + set of targets for investigation. Returns per-platform family lists, filtered to only include families that have a platform entry in amdgpu_family_matrix.py. @@ -394,25 +403,33 @@ def select_targets(inputs: CIInputs) -> TargetSelection: ["presubmit", "postsubmit", "nightly"] ) - # Select family names per platform based on trigger type + # Select family names per platform based on trigger type. + # Ordered from most-specific (workflow_dispatch) to broadest (schedule). if inputs.is_workflow_dispatch: - # workflow_dispatch: Family names are taken directly from workflow inputs. + # Manual trigger: caller specifies exact families per platform. + # Empty input means "no families for that platform" — the caller + # has full control over what runs. linux_names = list(inputs.linux_amdgpu_families) windows_names = list(inputs.windows_amdgpu_families) elif inputs.is_pull_request: - # pull_request: presubmit only; PR labels can extend below. + # Smallest default set for fast PR feedback. PR labels can extend + # the set below (gfx* for individual families, run-all-archs-ci + # for everything). defaults = list(get_all_families_for_trigger_types(["presubmit"]).keys()) linux_names = list(defaults) windows_names = list(defaults) elif inputs.is_push: - # push: Include presubmit _and_ postsubmit. + # Broader than PR: presubmit + postsubmit. Code has landed, so + # we validate on more targets (e.g. gfx950) without paying full + # nightly cost. defaults = list( get_all_families_for_trigger_types(["presubmit", "postsubmit"]).keys() ) linux_names = list(defaults) windows_names = list(defaults) elif inputs.is_schedule: - # schedule: Include all families. + # Full nightly coverage: every known family, including targets + # that are too slow or expensive for per-push CI. linux_names = list(all_families.keys()) windows_names = list(all_families.keys()) else: @@ -441,6 +458,7 @@ def select_targets(inputs: CIInputs) -> TargetSelection: # TODO: For workflow_dispatch, a family requested for a specific platform # but not available there (e.g. gfx94x on windows) is silently dropped. # Consider validating per-platform and reporting the mismatch. + # We could also filter per-platform in get_all_families_for_trigger_types. linux_names = _filter_families_by_platform(linux_names, "linux", all_families) windows_names = _filter_families_by_platform(windows_names, "windows", all_families) From 274fe089f464a735a911e6c8c8e869b820a89fc4 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 11:09:42 -0700 Subject: [PATCH 07/56] Implement expand_matrix with parity test against generate_multi_arch_matrix Port the multi-arch matrix expansion logic from configure_ci.py's generate_multi_arch_matrix into the new pipeline's expand_matrix function. Groups families by build variant into a single MatrixEntry with per-family JSON for downstream per-architecture job expansion. A parity test confirms identical output to the old function for the same inputs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 90 +++++++- .../tests/configure_multi_arch_ci_test.py | 194 +++++++++++++++++- 2 files changed, 278 insertions(+), 6 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index a4485f45bae..0f5075b47a3 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -49,7 +49,7 @@ from pathlib import Path from typing import Literal -from amdgpu_family_matrix import get_all_families_for_trigger_types +from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types from configure_ci_path_filters import get_git_modified_paths from github_actions_utils import gha_append_step_summary, gha_set_output @@ -480,11 +480,91 @@ def expand_matrix( ) -> list[MatrixEntry]: """Expand families into multi-arch matrix entries for one platform. - Groups all families into one entry per build variant (the multi-arch - format), rather than one entry per family (single-arch format). + In multi-arch mode, all families that support the requested build variant + are grouped into a single matrix entry. This produces one entry per + build variant (typically just one — "release"), containing a JSON array + of per-family info that downstream jobs matrix-expand over for + per-architecture stages. + + The per-family info includes: + - amdgpu_family: family name for THEROCK_AMDGPU_FAMILIES + - amdgpu_targets: comma-separated gfx targets for split artifact fetching + - test-runs-on: runner label for testing (empty = no test runner available) + - sanity_check_only_for_family: whether to limit test scope """ - # TODO: Implement — port generate_multi_arch_matrix logic - return [] + all_families = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + platform_build_variants = all_build_variants.get(platform, {}) + + # Collect per-family info, grouped by build variant. Each family may + # support multiple variants (e.g. gfx94x supports release + asan + tsan), + # but we only keep families that match the requested build_variant. + variant_family_info: dict[str, list[dict]] = {} + variant_config: dict[str, dict] = {} + + for family_name in families: + family_entry = all_families.get(family_name) + if not family_entry or platform not in family_entry: + continue + platform_info = family_entry[platform] + + for supported_variant in platform_info.get("build_variants", []): + if supported_variant != build_variant: + continue + + if supported_variant not in variant_family_info: + variant_family_info[supported_variant] = [] + variant_config[supported_variant] = platform_build_variants.get( + supported_variant, {} + ) + + # De-dup by family name (a family can appear once per variant). + existing = [ + f["amdgpu_family"] for f in variant_family_info[supported_variant] + ] + amdgpu_family = platform_info["family"] + if amdgpu_family in existing: + continue + + fetch_gfx_targets = platform_info.get("fetch-gfx-targets", []) + variant_family_info[supported_variant].append( + { + "amdgpu_family": amdgpu_family, + "amdgpu_targets": ",".join(fetch_gfx_targets), + "test-runs-on": platform_info.get("test-runs-on", ""), + "sanity_check_only_for_family": platform_info.get( + "sanity_check_only_for_family", False + ), + } + ) + + # Create one MatrixEntry per build variant. + entries: list[MatrixEntry] = [] + for variant_name, family_info_list in variant_family_info.items(): + config = variant_config[variant_name] + if not config: + continue + + family_names = [f["amdgpu_family"] for f in family_info_list] + expect_failure = config.get("expect_failure", False) + expect_pytorch_failure = config.get("expect_pytorch_failure", False) + suffix = config.get("build_variant_suffix", "") + + entries.append( + MatrixEntry( + matrix_per_family_json=json.dumps(family_info_list), + dist_amdgpu_families=";".join(family_names), + artifact_group=f"multi-arch-{suffix or 'release'}", + build_variant_label=config["build_variant_label"], + build_variant_suffix=suffix, + build_variant_cmake_preset=config["build_variant_cmake_preset"], + expect_failure=expect_failure, + build_pytorch=not expect_failure and not expect_pytorch_failure, + ) + ) + + return entries # --------------------------------------------------------------------------- diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index e2373639e05..d38a1dccc46 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -448,7 +448,199 @@ def test_matrix_entry_to_dict(self): self.assertFalse(d["expect_failure"]) self.assertTrue(d["build_pytorch"]) - # TODO: Tests for actual family expansion logic (Phase 2). + def test_linux_release_presubmit_families(self): + """Presubmit families on linux/release produces one entry with all families.""" + result = cm.expand_matrix( + families=["gfx94x", "gfx110x", "gfx1151", "gfx120x"], + platform="linux", + build_variant="release", + ) + self.assertEqual(len(result), 1) + entry = result[0] + self.assertEqual(entry.build_variant_label, "release") + self.assertEqual(entry.build_variant_suffix, "") + self.assertEqual(entry.artifact_group, "multi-arch-release") + self.assertFalse(entry.expect_failure) + self.assertTrue(entry.build_pytorch) + + # All four presubmit families should appear in the per-family JSON. + per_family = json.loads(entry.matrix_per_family_json) + family_names = [f["amdgpu_family"] for f in per_family] + self.assertIn("gfx94X-dcgpu", family_names) + self.assertIn("gfx110X-all", family_names) + self.assertIn("gfx1151", family_names) + self.assertIn("gfx120X-all", family_names) + + # dist_amdgpu_families is semicolon-separated family names. + self.assertEqual( + entry.dist_amdgpu_families, + ";".join(family_names), + ) + + def test_windows_release_presubmit_families(self): + """Windows release includes only families with a windows platform entry.""" + result = cm.expand_matrix( + families=["gfx94x", "gfx110x", "gfx1151", "gfx120x"], + platform="windows", + build_variant="release", + ) + self.assertEqual(len(result), 1) + entry = result[0] + per_family = json.loads(entry.matrix_per_family_json) + family_names = [f["amdgpu_family"] for f in per_family] + # gfx94x has no windows entry in the matrix. + self.assertNotIn("gfx94X-dcgpu", family_names) + # gfx110x, gfx1151, gfx120x have windows entries. + self.assertIn("gfx110X-all", family_names) + self.assertIn("gfx1151", family_names) + self.assertIn("gfx120X-all", family_names) + + def test_per_family_info_fields(self): + """Per-family info contains expected fields.""" + result = cm.expand_matrix( + families=["gfx94x"], + platform="linux", + build_variant="release", + ) + self.assertEqual(len(result), 1) + per_family = json.loads(result[0].matrix_per_family_json) + self.assertEqual(len(per_family), 1) + info = per_family[0] + self.assertEqual(info["amdgpu_family"], "gfx94X-dcgpu") + self.assertEqual(info["amdgpu_targets"], "gfx942") + self.assertEqual(info["test-runs-on"], "linux-mi325-1gpu-ossci-rocm") + self.assertFalse(info["sanity_check_only_for_family"]) + + def test_sanity_check_flag_propagated(self): + """sanity_check_only_for_family flows through to per-family info.""" + result = cm.expand_matrix( + families=["gfx110x"], + platform="linux", + build_variant="release", + ) + per_family = json.loads(result[0].matrix_per_family_json) + self.assertTrue(per_family[0]["sanity_check_only_for_family"]) + + def test_variant_not_supported_by_family(self): + """A family that doesn't support the requested variant is excluded.""" + # gfx110x only supports "release", not "asan". + result = cm.expand_matrix( + families=["gfx110x"], + platform="linux", + build_variant="asan", + ) + self.assertEqual(result, []) + + def test_asan_variant(self): + """ASAN variant produces a separate entry with correct metadata.""" + result = cm.expand_matrix( + families=["gfx94x"], + platform="linux", + build_variant="asan", + ) + self.assertEqual(len(result), 1) + entry = result[0] + self.assertEqual(entry.build_variant_label, "asan") + self.assertEqual(entry.build_variant_suffix, "asan") + self.assertEqual(entry.artifact_group, "multi-arch-asan") + self.assertEqual(entry.build_variant_cmake_preset, "linux-release-asan") + self.assertFalse(entry.expect_failure) + self.assertTrue(entry.build_pytorch) + + def test_tsan_variant_expect_failure(self): + """TSAN variant has expect_failure=True and build_pytorch=False.""" + result = cm.expand_matrix( + families=["gfx94x"], + platform="linux", + build_variant="tsan", + ) + self.assertEqual(len(result), 1) + entry = result[0] + self.assertEqual(entry.build_variant_label, "tsan") + self.assertTrue(entry.expect_failure) + self.assertFalse(entry.build_pytorch) + + def test_unknown_family_skipped(self): + """A family not in the matrix is silently skipped.""" + result = cm.expand_matrix( + families=["gfx_nonexistent"], + platform="linux", + build_variant="release", + ) + self.assertEqual(result, []) + + def test_unknown_platform_returns_empty(self): + """A platform with no build variants returns empty.""" + result = cm.expand_matrix( + families=["gfx94x"], + platform="macos", + build_variant="release", + ) + self.assertEqual(result, []) + + def test_nightly_family_expect_pytorch_failure(self): + """Nightly families with expect_pytorch_failure set build_pytorch=False.""" + # gfx906 on windows has expect_pytorch_failure in the family matrix data, + # but that's per-family, not per-variant. The variant config for + # windows/release doesn't set expect_pytorch_failure, so build_pytorch + # should be True. (expect_pytorch_failure is a per-family flag handled + # downstream, not in expand_matrix's variant config.) + result = cm.expand_matrix( + families=["gfx906"], + platform="windows", + build_variant="release", + ) + self.assertEqual(len(result), 1) + # The variant config (windows/release) has no expect_pytorch_failure, + # so build_pytorch is True at the matrix level. + self.assertTrue(result[0].build_pytorch) + + def test_multiple_fetch_gfx_targets(self): + """Multiple fetch-gfx-targets are comma-joined in amdgpu_targets.""" + result = cm.expand_matrix( + families=["gfx120x"], + platform="linux", + build_variant="release", + ) + per_family = json.loads(result[0].matrix_per_family_json) + # gfx120x has fetch-gfx-targets: ["gfx1200", "gfx1201"] + self.assertEqual(per_family[0]["amdgpu_targets"], "gfx1200,gfx1201") + + def test_output_matches_generate_multi_arch_matrix(self): + """expand_matrix output matches configure_ci.generate_multi_arch_matrix. + + This is a parity test: the new function should produce identical output + to the old one for the same inputs. + """ + from amdgpu_family_matrix import ( + all_build_variants, + get_all_families_for_trigger_types, + ) + from configure_ci import generate_multi_arch_matrix + + families = ["gfx94x", "gfx110x", "gfx1151", "gfx120x"] + platform = "linux" + variant = "release" + + lookup_matrix = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + old_result = generate_multi_arch_matrix( + target_names=families, + lookup_matrix=lookup_matrix, + platform=platform, + platform_build_variants=all_build_variants[platform], + base_args={"build_variant": variant}, + ) + + new_result = cm.expand_matrix( + families=families, platform=platform, build_variant=variant + ) + new_as_dicts = [entry.to_dict() for entry in new_result] + + self.assertEqual(len(old_result), len(new_as_dicts)) + for old_entry, new_entry in zip(old_result, new_as_dicts): + self.assertEqual(old_entry, new_entry) # --------------------------------------------------------------------------- From 5e8cc2d3640eaf1b46e62bfce638436d15304bcf Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 11:35:43 -0700 Subject: [PATCH 08/56] Simplify expand_matrix: single-variant, both-platform interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Replace variant-grouping loop with a flat membership check — there's only ever one build_variant per workflow run - Add expand_matrices() returning a MatrixExpansion dataclass for both platforms, with _expand_matrix_for_platform as a private helper - Rename lookup_matrix → all_families for consistency with select_targets - Log when a platform has no config for the requested build variant Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 181 +++++++++------- .../tests/configure_multi_arch_ci_test.py | 193 ++++++++++-------- 2 files changed, 210 insertions(+), 164 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 0f5075b47a3..f8e69845d11 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -287,6 +287,14 @@ def to_dict(self) -> dict: } +@dataclass(frozen=True) +class MatrixExpansion: + """Matrix entries for both platforms, produced by expand_matrices.""" + + linux_variants: list[MatrixEntry] = field(default_factory=list) + windows_variants: list[MatrixEntry] = field(default_factory=list) + + @dataclass(frozen=True) class CIOutputs: """All outputs from the CI configuration pipeline.""" @@ -473,35 +481,27 @@ def select_targets(inputs: CIInputs) -> TargetSelection: # --------------------------------------------------------------------------- -def expand_matrix( +def _expand_matrix_for_platform( families: list[str], platform: str, build_variant: str, + all_families: dict[str, dict], + variant_config: dict, ) -> list[MatrixEntry]: - """Expand families into multi-arch matrix entries for one platform. + """Expand families into a single multi-arch matrix entry for one platform. - In multi-arch mode, all families that support the requested build variant - are grouped into a single matrix entry. This produces one entry per - build variant (typically just one — "release"), containing a JSON array - of per-family info that downstream jobs matrix-expand over for - per-architecture stages. + Collects per-family info for all families that support the requested + build variant, then bundles them into one MatrixEntry. Returns an empty + list if no families match (e.g. variant not available on this platform). - The per-family info includes: + Per-family info fields: - amdgpu_family: family name for THEROCK_AMDGPU_FAMILIES - amdgpu_targets: comma-separated gfx targets for split artifact fetching - test-runs-on: runner label for testing (empty = no test runner available) - sanity_check_only_for_family: whether to limit test scope """ - all_families = get_all_families_for_trigger_types( - ["presubmit", "postsubmit", "nightly"] - ) - platform_build_variants = all_build_variants.get(platform, {}) - - # Collect per-family info, grouped by build variant. Each family may - # support multiple variants (e.g. gfx94x supports release + asan + tsan), - # but we only keep families that match the requested build_variant. - variant_family_info: dict[str, list[dict]] = {} - variant_config: dict[str, dict] = {} + per_family_info: list[dict] = [] + seen_families: set[str] = set() for family_name in families: family_entry = all_families.get(family_name) @@ -509,62 +509,93 @@ def expand_matrix( continue platform_info = family_entry[platform] - for supported_variant in platform_info.get("build_variants", []): - if supported_variant != build_variant: - continue - - if supported_variant not in variant_family_info: - variant_family_info[supported_variant] = [] - variant_config[supported_variant] = platform_build_variants.get( - supported_variant, {} - ) - - # De-dup by family name (a family can appear once per variant). - existing = [ - f["amdgpu_family"] for f in variant_family_info[supported_variant] - ] - amdgpu_family = platform_info["family"] - if amdgpu_family in existing: - continue - - fetch_gfx_targets = platform_info.get("fetch-gfx-targets", []) - variant_family_info[supported_variant].append( - { - "amdgpu_family": amdgpu_family, - "amdgpu_targets": ",".join(fetch_gfx_targets), - "test-runs-on": platform_info.get("test-runs-on", ""), - "sanity_check_only_for_family": platform_info.get( - "sanity_check_only_for_family", False - ), - } - ) + # Skip families that don't support this build variant. + if build_variant not in platform_info.get("build_variants", []): + continue - # Create one MatrixEntry per build variant. - entries: list[MatrixEntry] = [] - for variant_name, family_info_list in variant_family_info.items(): - config = variant_config[variant_name] - if not config: + amdgpu_family = platform_info["family"] + if amdgpu_family in seen_families: continue + seen_families.add(amdgpu_family) + + fetch_gfx_targets = platform_info.get("fetch-gfx-targets", []) + per_family_info.append( + { + "amdgpu_family": amdgpu_family, + "amdgpu_targets": ",".join(fetch_gfx_targets), + "test-runs-on": platform_info.get("test-runs-on", ""), + "sanity_check_only_for_family": platform_info.get( + "sanity_check_only_for_family", False + ), + } + ) + + if not per_family_info: + return [] - family_names = [f["amdgpu_family"] for f in family_info_list] - expect_failure = config.get("expect_failure", False) - expect_pytorch_failure = config.get("expect_pytorch_failure", False) - suffix = config.get("build_variant_suffix", "") - - entries.append( - MatrixEntry( - matrix_per_family_json=json.dumps(family_info_list), - dist_amdgpu_families=";".join(family_names), - artifact_group=f"multi-arch-{suffix or 'release'}", - build_variant_label=config["build_variant_label"], - build_variant_suffix=suffix, - build_variant_cmake_preset=config["build_variant_cmake_preset"], - expect_failure=expect_failure, - build_pytorch=not expect_failure and not expect_pytorch_failure, + family_names = [f["amdgpu_family"] for f in per_family_info] + expect_failure = variant_config.get("expect_failure", False) + expect_pytorch_failure = variant_config.get("expect_pytorch_failure", False) + suffix = variant_config.get("build_variant_suffix", "") + + return [ + MatrixEntry( + matrix_per_family_json=json.dumps(per_family_info), + dist_amdgpu_families=";".join(family_names), + artifact_group=f"multi-arch-{suffix or 'release'}", + build_variant_label=variant_config["build_variant_label"], + build_variant_suffix=suffix, + build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], + expect_failure=expect_failure, + build_pytorch=not expect_failure and not expect_pytorch_failure, + ) + ] + + +def expand_matrices( + targets: TargetSelection, + build_variant: str, +) -> MatrixExpansion: + """Expand target families into matrix entries for both platforms. + + Each platform gets either a single-element list (one MatrixEntry bundling + all families) or an empty list (if the build variant isn't available on + that platform or no families match). + """ + all_families = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + + linux_variants: list[MatrixEntry] = [] + windows_variants: list[MatrixEntry] = [] + + for platform, families in [ + ("linux", targets.linux_families), + ("windows", targets.windows_families), + ]: + variant_config = all_build_variants.get(platform, {}).get(build_variant) + if not variant_config: + print( + f" Platform {platform} has no config for build variant " + f"{build_variant}, skipping matrix expansion" ) + continue + entries = _expand_matrix_for_platform( + families=families, + platform=platform, + build_variant=build_variant, + all_families=all_families, + variant_config=variant_config, ) + if platform == "linux": + linux_variants = entries + else: + windows_variants = entries - return entries + return MatrixExpansion( + linux_variants=linux_variants, + windows_variants=windows_variants, + ) # --------------------------------------------------------------------------- @@ -643,21 +674,15 @@ def configure(inputs: CIInputs) -> CIOutputs: targets = select_targets(inputs) # Step 5: Expand matrix - linux_matrix = expand_matrix( - families=targets.linux_families, - platform="linux", - build_variant=inputs.build_variant, - ) - windows_matrix = expand_matrix( - families=targets.windows_families, - platform="windows", + matrices = expand_matrices( + targets=targets, build_variant=inputs.build_variant, ) return CIOutputs( is_ci_enabled=True, - linux_variants=linux_matrix, - windows_variants=windows_matrix, + linux_variants=matrices.linux_variants, + windows_variants=matrices.windows_variants, jobs=jobs, ) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index d38a1dccc46..07feac068d7 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -422,13 +422,32 @@ def test_platform_filtering(self): class TestExpandMatrix(unittest.TestCase): - """Test matrix expansion.""" + """Test matrix expansion (_expand_matrix_for_platform and expand_matrices).""" + + def _expand(self, families, platform, build_variant): + """Helper: look up variant config and call _expand_matrix_for_platform.""" + from amdgpu_family_matrix import ( + all_build_variants, + get_all_families_for_trigger_types, + ) + + all_families = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + variant_config = all_build_variants.get(platform, {}).get(build_variant) + if not variant_config: + return [] + return cm._expand_matrix_for_platform( + families=families, + platform=platform, + build_variant=build_variant, + all_families=all_families, + variant_config=variant_config, + ) def test_empty_families_returns_empty(self): """No families → no matrix entries.""" - result = cm.expand_matrix( - families=[], platform="linux", build_variant="release" - ) + result = self._expand([], "linux", "release") self.assertEqual(result, []) def test_matrix_entry_to_dict(self): @@ -450,10 +469,8 @@ def test_matrix_entry_to_dict(self): def test_linux_release_presubmit_families(self): """Presubmit families on linux/release produces one entry with all families.""" - result = cm.expand_matrix( - families=["gfx94x", "gfx110x", "gfx1151", "gfx120x"], - platform="linux", - build_variant="release", + result = self._expand( + ["gfx94x", "gfx110x", "gfx1151", "gfx120x"], "linux", "release" ) self.assertEqual(len(result), 1) entry = result[0] @@ -479,14 +496,11 @@ def test_linux_release_presubmit_families(self): def test_windows_release_presubmit_families(self): """Windows release includes only families with a windows platform entry.""" - result = cm.expand_matrix( - families=["gfx94x", "gfx110x", "gfx1151", "gfx120x"], - platform="windows", - build_variant="release", + result = self._expand( + ["gfx94x", "gfx110x", "gfx1151", "gfx120x"], "windows", "release" ) self.assertEqual(len(result), 1) - entry = result[0] - per_family = json.loads(entry.matrix_per_family_json) + per_family = json.loads(result[0].matrix_per_family_json) family_names = [f["amdgpu_family"] for f in per_family] # gfx94x has no windows entry in the matrix. self.assertNotIn("gfx94X-dcgpu", family_names) @@ -497,11 +511,7 @@ def test_windows_release_presubmit_families(self): def test_per_family_info_fields(self): """Per-family info contains expected fields.""" - result = cm.expand_matrix( - families=["gfx94x"], - platform="linux", - build_variant="release", - ) + result = self._expand(["gfx94x"], "linux", "release") self.assertEqual(len(result), 1) per_family = json.loads(result[0].matrix_per_family_json) self.assertEqual(len(per_family), 1) @@ -513,31 +523,19 @@ def test_per_family_info_fields(self): def test_sanity_check_flag_propagated(self): """sanity_check_only_for_family flows through to per-family info.""" - result = cm.expand_matrix( - families=["gfx110x"], - platform="linux", - build_variant="release", - ) + result = self._expand(["gfx110x"], "linux", "release") per_family = json.loads(result[0].matrix_per_family_json) self.assertTrue(per_family[0]["sanity_check_only_for_family"]) def test_variant_not_supported_by_family(self): """A family that doesn't support the requested variant is excluded.""" # gfx110x only supports "release", not "asan". - result = cm.expand_matrix( - families=["gfx110x"], - platform="linux", - build_variant="asan", - ) + result = self._expand(["gfx110x"], "linux", "asan") self.assertEqual(result, []) def test_asan_variant(self): - """ASAN variant produces a separate entry with correct metadata.""" - result = cm.expand_matrix( - families=["gfx94x"], - platform="linux", - build_variant="asan", - ) + """ASAN variant produces an entry with correct metadata.""" + result = self._expand(["gfx94x"], "linux", "asan") self.assertEqual(len(result), 1) entry = result[0] self.assertEqual(entry.build_variant_label, "asan") @@ -549,11 +547,7 @@ def test_asan_variant(self): def test_tsan_variant_expect_failure(self): """TSAN variant has expect_failure=True and build_pytorch=False.""" - result = cm.expand_matrix( - families=["gfx94x"], - platform="linux", - build_variant="tsan", - ) + result = self._expand(["gfx94x"], "linux", "tsan") self.assertEqual(len(result), 1) entry = result[0] self.assertEqual(entry.build_variant_label, "tsan") @@ -562,54 +556,76 @@ def test_tsan_variant_expect_failure(self): def test_unknown_family_skipped(self): """A family not in the matrix is silently skipped.""" - result = cm.expand_matrix( - families=["gfx_nonexistent"], - platform="linux", - build_variant="release", - ) + result = self._expand(["gfx_nonexistent"], "linux", "release") self.assertEqual(result, []) def test_unknown_platform_returns_empty(self): """A platform with no build variants returns empty.""" - result = cm.expand_matrix( - families=["gfx94x"], - platform="macos", - build_variant="release", - ) + result = self._expand(["gfx94x"], "macos", "release") self.assertEqual(result, []) def test_nightly_family_expect_pytorch_failure(self): - """Nightly families with expect_pytorch_failure set build_pytorch=False.""" + """expect_pytorch_failure is per-family data, not per-variant config.""" # gfx906 on windows has expect_pytorch_failure in the family matrix data, # but that's per-family, not per-variant. The variant config for # windows/release doesn't set expect_pytorch_failure, so build_pytorch - # should be True. (expect_pytorch_failure is a per-family flag handled - # downstream, not in expand_matrix's variant config.) - result = cm.expand_matrix( - families=["gfx906"], - platform="windows", - build_variant="release", - ) + # is True at the matrix level. + result = self._expand(["gfx906"], "windows", "release") self.assertEqual(len(result), 1) - # The variant config (windows/release) has no expect_pytorch_failure, - # so build_pytorch is True at the matrix level. self.assertTrue(result[0].build_pytorch) def test_multiple_fetch_gfx_targets(self): """Multiple fetch-gfx-targets are comma-joined in amdgpu_targets.""" - result = cm.expand_matrix( - families=["gfx120x"], - platform="linux", - build_variant="release", - ) + result = self._expand(["gfx120x"], "linux", "release") per_family = json.loads(result[0].matrix_per_family_json) # gfx120x has fetch-gfx-targets: ["gfx1200", "gfx1201"] self.assertEqual(per_family[0]["amdgpu_targets"], "gfx1200,gfx1201") + # -- expand_matrices (both-platform wrapper) -- + + def test_expand_matrices_both_platforms(self): + """expand_matrices returns entries for both linux and windows.""" + targets = cm.TargetSelection( + linux_families=["gfx94x", "gfx110x"], + windows_families=["gfx110x", "gfx1151"], + ) + result = cm.expand_matrices(targets=targets, build_variant="release") + self.assertEqual(len(result.linux_variants), 1) + self.assertEqual(len(result.windows_variants), 1) + + linux_families = json.loads(result.linux_variants[0].matrix_per_family_json) + linux_names = [f["amdgpu_family"] for f in linux_families] + self.assertIn("gfx94X-dcgpu", linux_names) + self.assertIn("gfx110X-all", linux_names) + + windows_families = json.loads(result.windows_variants[0].matrix_per_family_json) + windows_names = [f["amdgpu_family"] for f in windows_families] + self.assertIn("gfx110X-all", windows_names) + self.assertIn("gfx1151", windows_names) + + def test_expand_matrices_variant_not_on_windows(self): + """ASAN has no windows config → windows list is empty.""" + targets = cm.TargetSelection( + linux_families=["gfx94x"], + windows_families=["gfx110x"], + ) + result = cm.expand_matrices(targets=targets, build_variant="asan") + self.assertEqual(len(result.linux_variants), 1) + self.assertEqual(result.windows_variants, []) + + def test_expand_matrices_empty_targets(self): + """Empty targets on both platforms → both lists empty.""" + targets = cm.TargetSelection() + result = cm.expand_matrices(targets=targets, build_variant="release") + self.assertEqual(result.linux_variants, []) + self.assertEqual(result.windows_variants, []) + + # -- Parity test -- + def test_output_matches_generate_multi_arch_matrix(self): - """expand_matrix output matches configure_ci.generate_multi_arch_matrix. + """expand_matrices output matches configure_ci.generate_multi_arch_matrix. - This is a parity test: the new function should produce identical output + Parity test: the new functions should produce identical output to the old one for the same inputs. """ from amdgpu_family_matrix import ( @@ -619,28 +635,34 @@ def test_output_matches_generate_multi_arch_matrix(self): from configure_ci import generate_multi_arch_matrix families = ["gfx94x", "gfx110x", "gfx1151", "gfx120x"] - platform = "linux" variant = "release" - lookup_matrix = get_all_families_for_trigger_types( ["presubmit", "postsubmit", "nightly"] ) - old_result = generate_multi_arch_matrix( - target_names=families, - lookup_matrix=lookup_matrix, - platform=platform, - platform_build_variants=all_build_variants[platform], - base_args={"build_variant": variant}, - ) - new_result = cm.expand_matrix( - families=families, platform=platform, build_variant=variant + targets = cm.TargetSelection( + linux_families=families, + windows_families=families, ) - new_as_dicts = [entry.to_dict() for entry in new_result] + result = cm.expand_matrices(targets=targets, build_variant=variant) - self.assertEqual(len(old_result), len(new_as_dicts)) - for old_entry, new_entry in zip(old_result, new_as_dicts): - self.assertEqual(old_entry, new_entry) + for platform, new_result in [ + ("linux", result.linux_variants), + ("windows", result.windows_variants), + ]: + old_result = generate_multi_arch_matrix( + target_names=families, + lookup_matrix=lookup_matrix, + platform=platform, + platform_build_variants=all_build_variants[platform], + base_args={"build_variant": variant}, + ) + new_as_dicts = [entry.to_dict() for entry in new_result] + self.assertEqual( + len(old_result), len(new_as_dicts), f"length mismatch on {platform}" + ) + for old_entry, new_entry in zip(old_result, new_as_dicts): + self.assertEqual(old_entry, new_entry, f"entry mismatch on {platform}") # --------------------------------------------------------------------------- @@ -706,7 +728,7 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): @patch("configure_multi_arch_ci.check_skip_ci") @patch("configure_multi_arch_ci.select_targets") @patch("configure_multi_arch_ci.decide_jobs") - @patch("configure_multi_arch_ci.expand_matrix") + @patch("configure_multi_arch_ci.expand_matrices") def test_pipeline_calls_all_steps( self, mock_expand, mock_jobs, mock_targets, mock_skip ): @@ -723,7 +745,7 @@ def test_pipeline_calls_all_steps( build_pytorch=cm.JobGroupDecision(action="run", reason="default"), test_pytorch=cm.JobGroupDecision(action="run", reason="default"), ) - mock_expand.return_value = [] + mock_expand.return_value = cm.MatrixExpansion() inputs = cm.CIInputs( event_name="workflow_dispatch", @@ -737,8 +759,7 @@ def test_pipeline_calls_all_steps( self.assertIsNotNone(outputs.jobs) mock_targets.assert_called_once() mock_jobs.assert_called_once() - # expand_matrix called twice: once for linux, once for windows - self.assertEqual(mock_expand.call_count, 2) + mock_expand.assert_called_once() if __name__ == "__main__": From 90b9d9eb72572583053a7fe25d1f226f353baa56 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 11:51:25 -0700 Subject: [PATCH 09/56] Move data invariant checks to amdgpu_family_matrix_test.py Add amdgpu_family_matrix_test.py with schema validation tests: no duplicate family names per platform, required fields present, build_variants non-empty. This lets _expand_matrix_for_platform drop its defensive checks (seen_families dedup, .get() defaults for required fields) and index directly, relying on upstream validation from select_targets and data invariant tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 29 ++++---- .../tests/amdgpu_family_matrix_test.py | 69 +++++++++++++++++++ .../tests/configure_multi_arch_ci_test.py | 41 ++++++----- 3 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 build_tools/github_actions/tests/amdgpu_family_matrix_test.py diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index f8e69845d11..8b66e01322b 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -501,29 +501,26 @@ def _expand_matrix_for_platform( - sanity_check_only_for_family: whether to limit test scope """ per_family_info: list[dict] = [] - seen_families: set[str] = set() for family_name in families: - family_entry = all_families.get(family_name) - if not family_entry or platform not in family_entry: - continue - platform_info = family_entry[platform] - - # Skip families that don't support this build variant. - if build_variant not in platform_info.get("build_variants", []): - continue + # select_targets already validates family names and filters by + # platform availability. Family name uniqueness is validated by + # amdgpu_family_matrix_test.py. We can index directly here. + platform_info = all_families[family_name][platform] - amdgpu_family = platform_info["family"] - if amdgpu_family in seen_families: + # Filter out families missing the build variant (e.g. 'asan'). + if build_variant not in platform_info["build_variants"]: + print( + f" Family {family_name} does not support variant " + f"{build_variant} on {platform}, skipping" + ) continue - seen_families.add(amdgpu_family) - fetch_gfx_targets = platform_info.get("fetch-gfx-targets", []) per_family_info.append( { - "amdgpu_family": amdgpu_family, - "amdgpu_targets": ",".join(fetch_gfx_targets), - "test-runs-on": platform_info.get("test-runs-on", ""), + "amdgpu_family": platform_info["family"], + "amdgpu_targets": ",".join(platform_info["fetch-gfx-targets"]), + "test-runs-on": platform_info["test-runs-on"], "sanity_check_only_for_family": platform_info.get( "sanity_check_only_for_family", False ), diff --git a/build_tools/github_actions/tests/amdgpu_family_matrix_test.py b/build_tools/github_actions/tests/amdgpu_family_matrix_test.py new file mode 100644 index 00000000000..412ab41f2af --- /dev/null +++ b/build_tools/github_actions/tests/amdgpu_family_matrix_test.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +"""Tests for data invariants in amdgpu_family_matrix.py.""" + +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) + +from amdgpu_family_matrix import get_all_families_for_trigger_types + +ALL_FAMILIES = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] +) + + +class TestFamilyMatrixInvariants(unittest.TestCase): + """Validate structural invariants on the family matrix data.""" + + def test_no_duplicate_family_names_per_platform(self): + """Each (platform, family) pair must be unique across target names. + + Two target names mapping to the same amdgpu_family on the same + platform would cause silent data loss in matrix expansion. + """ + for platform in ("linux", "windows"): + seen: dict[str, str] = {} # family → target_name + for target_name, entry in ALL_FAMILIES.items(): + if platform not in entry: + continue + family = entry[platform]["family"] + if family in seen: + self.fail( + f"Duplicate family {family!r} on {platform}: " + f"target {target_name!r} and {seen[family]!r}" + ) + seen[family] = target_name + + def test_required_fields_present(self): + """Every platform entry must have the required fields.""" + required = {"family", "fetch-gfx-targets", "test-runs-on", "build_variants"} + for target_name, entry in ALL_FAMILIES.items(): + for platform in ("linux", "windows"): + if platform not in entry: + continue + platform_info = entry[platform] + missing = required - platform_info.keys() + if missing: + self.fail( + f"{target_name}/{platform} missing required fields: {missing}" + ) + + def test_build_variants_non_empty(self): + """Every platform entry must list at least one build variant.""" + for target_name, entry in ALL_FAMILIES.items(): + for platform in ("linux", "windows"): + if platform not in entry: + continue + variants = entry[platform].get("build_variants", []) + if not variants: + self.fail(f"{target_name}/{platform} has empty build_variants") + + +if __name__ == "__main__": + unittest.main() diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 07feac068d7..f4171959ea0 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -495,16 +495,14 @@ def test_linux_release_presubmit_families(self): ) def test_windows_release_presubmit_families(self): - """Windows release includes only families with a windows platform entry.""" - result = self._expand( - ["gfx94x", "gfx110x", "gfx1151", "gfx120x"], "windows", "release" - ) + """Windows release with families that have windows entries.""" + # Only pass families that have windows platform entries — select_targets + # filters per-platform upstream, so _expand_matrix_for_platform expects + # all families to be valid for the platform. + result = self._expand(["gfx110x", "gfx1151", "gfx120x"], "windows", "release") self.assertEqual(len(result), 1) per_family = json.loads(result[0].matrix_per_family_json) family_names = [f["amdgpu_family"] for f in per_family] - # gfx94x has no windows entry in the matrix. - self.assertNotIn("gfx94X-dcgpu", family_names) - # gfx110x, gfx1151, gfx120x have windows entries. self.assertIn("gfx110X-all", family_names) self.assertIn("gfx1151", family_names) self.assertIn("gfx120X-all", family_names) @@ -554,13 +552,13 @@ def test_tsan_variant_expect_failure(self): self.assertTrue(entry.expect_failure) self.assertFalse(entry.build_pytorch) - def test_unknown_family_skipped(self): - """A family not in the matrix is silently skipped.""" - result = self._expand(["gfx_nonexistent"], "linux", "release") - self.assertEqual(result, []) + def test_unknown_family_raises(self): + """A family not in the matrix raises KeyError (upstream should filter).""" + with self.assertRaises(KeyError): + self._expand(["gfx_nonexistent"], "linux", "release") def test_unknown_platform_returns_empty(self): - """A platform with no build variants returns empty.""" + """A platform with no build variants returns empty (filtered in expand_matrices).""" result = self._expand(["gfx94x"], "macos", "release") self.assertEqual(result, []) @@ -634,21 +632,28 @@ def test_output_matches_generate_multi_arch_matrix(self): ) from configure_ci import generate_multi_arch_matrix - families = ["gfx94x", "gfx110x", "gfx1151", "gfx120x"] + all_families_list = ["gfx94x", "gfx110x", "gfx1151", "gfx120x"] variant = "release" lookup_matrix = get_all_families_for_trigger_types( ["presubmit", "postsubmit", "nightly"] ) + # Filter families per platform (as select_targets would). + linux_families = [ + f for f in all_families_list if "linux" in lookup_matrix.get(f, {}) + ] + windows_families = [ + f for f in all_families_list if "windows" in lookup_matrix.get(f, {}) + ] targets = cm.TargetSelection( - linux_families=families, - windows_families=families, + linux_families=linux_families, + windows_families=windows_families, ) result = cm.expand_matrices(targets=targets, build_variant=variant) - for platform, new_result in [ - ("linux", result.linux_variants), - ("windows", result.windows_variants), + for platform, families, new_result in [ + ("linux", linux_families, result.linux_variants), + ("windows", windows_families, result.windows_variants), ]: old_result = generate_multi_arch_matrix( target_names=families, From 4e76b0f5678460e0c02212c72e0e66aed2eb7761 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 13:04:31 -0700 Subject: [PATCH 10/56] Replace matrix array outputs with per-platform BuildConfig objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename MatrixEntry → BuildConfig, expand_matrices → expand_build_configs. The helper returns BuildConfig | None instead of a 0-or-1-element list. BuildConfigs holds linux/windows as Optional fields. write_outputs now emits linux_build_config / windows_build_config as JSON objects (or empty string when skipped) plus linux_build_enabled / windows_build_enabled booleans, replacing the JSON array outputs that existed only to support a matrix strategy we don't need yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 114 +++++------ .../tests/configure_multi_arch_ci_test.py | 186 ++++++++---------- 2 files changed, 143 insertions(+), 157 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 8b66e01322b..809fade3285 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -10,7 +10,7 @@ 2. Check Skip CI — gate: should we skip CI entirely? 3. Decide Jobs — changed files + topology → per-job-group decisions 4. Select Targets — trigger type + labels → per-platform GPU families - 5. Expand Matrix — families × variant → matrix entries + 5. Build Configs — families × variant → per-platform build configs 6. Write Outputs — JSON → GITHUB_OUTPUT + GITHUB_STEP_SUMMARY Each step (except 1 and 6) is a pure function of typed dataclasses, @@ -36,10 +36,12 @@ BUILD_VARIANT : Build variant (workflow_call input, not in event payload) Outputs (written to GITHUB_OUTPUT): - linux_variants : JSON array of matrix entries - windows_variants : JSON array of matrix entries - enable_build_jobs : "true" or "false" - test_type : "smoke" or "full" + linux_build_config : JSON object with build config, or "" if skipped + windows_build_config : JSON object with build config, or "" if skipped + linux_build_enabled : "true" or "false" + windows_build_enabled : "true" or "false" + enable_build_jobs : "true" or "false" + test_type : "smoke" or "full" """ import json @@ -261,8 +263,12 @@ class JobDecisions: @dataclass(frozen=True) -class MatrixEntry: - """One row of the GitHub Actions build matrix.""" +class BuildConfig: + """Build configuration for one platform. + + Produced by expand_matrices, one per platform. Contains per-family info + for downstream per-architecture job expansion and variant metadata. + """ matrix_per_family_json: str # JSON array of per-family info dist_amdgpu_families: str # Semicolon-separated @@ -288,11 +294,11 @@ def to_dict(self) -> dict: @dataclass(frozen=True) -class MatrixExpansion: - """Matrix entries for both platforms, produced by expand_matrices.""" +class BuildConfigs: + """Build configurations for both platforms, produced by expand_matrices.""" - linux_variants: list[MatrixEntry] = field(default_factory=list) - windows_variants: list[MatrixEntry] = field(default_factory=list) + linux: BuildConfig | None = None + windows: BuildConfig | None = None @dataclass(frozen=True) @@ -300,8 +306,7 @@ class CIOutputs: """All outputs from the CI configuration pipeline.""" is_ci_enabled: bool = True - linux_variants: list[MatrixEntry] = field(default_factory=list) - windows_variants: list[MatrixEntry] = field(default_factory=list) + builds: BuildConfigs = field(default_factory=BuildConfigs) jobs: JobDecisions | None = None @staticmethod @@ -477,22 +482,21 @@ def select_targets(inputs: CIInputs) -> TargetSelection: # --------------------------------------------------------------------------- -# Step 5: Expand Matrix +# Step 5: Build Configs # --------------------------------------------------------------------------- -def _expand_matrix_for_platform( +def _expand_build_config_for_platform( families: list[str], platform: str, build_variant: str, all_families: dict[str, dict], variant_config: dict, -) -> list[MatrixEntry]: - """Expand families into a single multi-arch matrix entry for one platform. +) -> BuildConfig | None: + """Build a BuildConfig for one platform, or None if no families match. Collects per-family info for all families that support the requested - build variant, then bundles them into one MatrixEntry. Returns an empty - list if no families match (e.g. variant not available on this platform). + build variant on this platform, then bundles them into a BuildConfig. Per-family info fields: - amdgpu_family: family name for THEROCK_AMDGPU_FAMILIES @@ -528,43 +532,40 @@ def _expand_matrix_for_platform( ) if not per_family_info: - return [] + return None family_names = [f["amdgpu_family"] for f in per_family_info] expect_failure = variant_config.get("expect_failure", False) expect_pytorch_failure = variant_config.get("expect_pytorch_failure", False) suffix = variant_config.get("build_variant_suffix", "") - return [ - MatrixEntry( - matrix_per_family_json=json.dumps(per_family_info), - dist_amdgpu_families=";".join(family_names), - artifact_group=f"multi-arch-{suffix or 'release'}", - build_variant_label=variant_config["build_variant_label"], - build_variant_suffix=suffix, - build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], - expect_failure=expect_failure, - build_pytorch=not expect_failure and not expect_pytorch_failure, - ) - ] + return BuildConfig( + matrix_per_family_json=json.dumps(per_family_info), + dist_amdgpu_families=";".join(family_names), + artifact_group=f"multi-arch-{suffix or 'release'}", + build_variant_label=variant_config["build_variant_label"], + build_variant_suffix=suffix, + build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], + expect_failure=expect_failure, + build_pytorch=not expect_failure and not expect_pytorch_failure, + ) -def expand_matrices( +def expand_build_configs( targets: TargetSelection, build_variant: str, -) -> MatrixExpansion: - """Expand target families into matrix entries for both platforms. +) -> BuildConfigs: + """Build a BuildConfig for each platform that supports the variant. - Each platform gets either a single-element list (one MatrixEntry bundling - all families) or an empty list (if the build variant isn't available on - that platform or no families match). + Returns BuildConfigs with a BuildConfig per platform, or None for + platforms where the variant isn't available or no families match. """ all_families = get_all_families_for_trigger_types( ["presubmit", "postsubmit", "nightly"] ) - linux_variants: list[MatrixEntry] = [] - windows_variants: list[MatrixEntry] = [] + linux_config: BuildConfig | None = None + windows_config: BuildConfig | None = None for platform, families in [ ("linux", targets.linux_families), @@ -574,10 +575,10 @@ def expand_matrices( if not variant_config: print( f" Platform {platform} has no config for build variant " - f"{build_variant}, skipping matrix expansion" + f"{build_variant}, skipping" ) continue - entries = _expand_matrix_for_platform( + config = _expand_build_config_for_platform( families=families, platform=platform, build_variant=build_variant, @@ -585,13 +586,13 @@ def expand_matrices( variant_config=variant_config, ) if platform == "linux": - linux_variants = entries + linux_config = config else: - windows_variants = entries + windows_config = config - return MatrixExpansion( - linux_variants=linux_variants, - windows_variants=windows_variants, + return BuildConfigs( + linux=linux_config, + windows=windows_config, ) @@ -626,13 +627,13 @@ def write_outputs(outputs: CIOutputs) -> None: This is the only function with side effects (besides from_environ). """ test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "smoke" + linux = outputs.builds.linux + windows = outputs.builds.windows output_vars = { - "linux_variants": json.dumps( - [entry.to_dict() for entry in outputs.linux_variants] - ), - "windows_variants": json.dumps( - [entry.to_dict() for entry in outputs.windows_variants] - ), + "linux_build_config": json.dumps(linux.to_dict()) if linux else "", + "windows_build_config": json.dumps(windows.to_dict()) if windows else "", + "linux_build_enabled": json.dumps(linux is not None), + "windows_build_enabled": json.dumps(windows is not None), # Workflow YAML references this as 'enable_build_jobs' "enable_build_jobs": json.dumps(outputs.is_ci_enabled), "test_type": test_type, @@ -670,16 +671,15 @@ def configure(inputs: CIInputs) -> CIOutputs: jobs = decide_jobs(inputs=inputs, changed_files=changed_files) targets = select_targets(inputs) - # Step 5: Expand matrix - matrices = expand_matrices( + # Step 5: Build configs per platform + builds = expand_build_configs( targets=targets, build_variant=inputs.build_variant, ) return CIOutputs( is_ci_enabled=True, - linux_variants=matrices.linux_variants, - windows_variants=matrices.windows_variants, + builds=builds, jobs=jobs, ) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index f4171959ea0..105b6a8c999 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -417,15 +417,15 @@ def test_platform_filtering(self): # --------------------------------------------------------------------------- -# Step 5: Expand Matrix +# Step 5: Build Configs # --------------------------------------------------------------------------- -class TestExpandMatrix(unittest.TestCase): - """Test matrix expansion (_expand_matrix_for_platform and expand_matrices).""" +class TestExpandBuildConfigs(unittest.TestCase): + """Test build config expansion (_expand_build_config_for_platform and expand_build_configs).""" def _expand(self, families, platform, build_variant): - """Helper: look up variant config and call _expand_matrix_for_platform.""" + """Helper: look up variant config and call _expand_build_config_for_platform.""" from amdgpu_family_matrix import ( all_build_variants, get_all_families_for_trigger_types, @@ -436,8 +436,8 @@ def _expand(self, families, platform, build_variant): ) variant_config = all_build_variants.get(platform, {}).get(build_variant) if not variant_config: - return [] - return cm._expand_matrix_for_platform( + return None + return cm._expand_build_config_for_platform( families=families, platform=platform, build_variant=build_variant, @@ -445,14 +445,14 @@ def _expand(self, families, platform, build_variant): variant_config=variant_config, ) - def test_empty_families_returns_empty(self): - """No families → no matrix entries.""" + def test_empty_families_returns_none(self): + """No families → None.""" result = self._expand([], "linux", "release") - self.assertEqual(result, []) + self.assertIsNone(result) - def test_matrix_entry_to_dict(self): - """MatrixEntry.to_dict() produces the expected structure.""" - entry = cm.MatrixEntry( + def test_build_config_to_dict(self): + """BuildConfig.to_dict() produces the expected structure.""" + config = cm.BuildConfig( matrix_per_family_json='[{"amdgpu_family": "gfx94X-dcgpu"}]', dist_amdgpu_families="gfx94X-dcgpu", artifact_group="multi-arch-release", @@ -462,46 +462,40 @@ def test_matrix_entry_to_dict(self): expect_failure=False, build_pytorch=True, ) - d = entry.to_dict() + d = config.to_dict() self.assertEqual(d["artifact_group"], "multi-arch-release") self.assertFalse(d["expect_failure"]) self.assertTrue(d["build_pytorch"]) def test_linux_release_presubmit_families(self): - """Presubmit families on linux/release produces one entry with all families.""" - result = self._expand( + """Presubmit families on linux/release produces a BuildConfig with all families.""" + config = self._expand( ["gfx94x", "gfx110x", "gfx1151", "gfx120x"], "linux", "release" ) - self.assertEqual(len(result), 1) - entry = result[0] - self.assertEqual(entry.build_variant_label, "release") - self.assertEqual(entry.build_variant_suffix, "") - self.assertEqual(entry.artifact_group, "multi-arch-release") - self.assertFalse(entry.expect_failure) - self.assertTrue(entry.build_pytorch) + self.assertIsNotNone(config) + self.assertEqual(config.build_variant_label, "release") + self.assertEqual(config.build_variant_suffix, "") + self.assertEqual(config.artifact_group, "multi-arch-release") + self.assertFalse(config.expect_failure) + self.assertTrue(config.build_pytorch) - # All four presubmit families should appear in the per-family JSON. - per_family = json.loads(entry.matrix_per_family_json) + per_family = json.loads(config.matrix_per_family_json) family_names = [f["amdgpu_family"] for f in per_family] self.assertIn("gfx94X-dcgpu", family_names) self.assertIn("gfx110X-all", family_names) self.assertIn("gfx1151", family_names) self.assertIn("gfx120X-all", family_names) - # dist_amdgpu_families is semicolon-separated family names. self.assertEqual( - entry.dist_amdgpu_families, + config.dist_amdgpu_families, ";".join(family_names), ) def test_windows_release_presubmit_families(self): """Windows release with families that have windows entries.""" - # Only pass families that have windows platform entries — select_targets - # filters per-platform upstream, so _expand_matrix_for_platform expects - # all families to be valid for the platform. - result = self._expand(["gfx110x", "gfx1151", "gfx120x"], "windows", "release") - self.assertEqual(len(result), 1) - per_family = json.loads(result[0].matrix_per_family_json) + config = self._expand(["gfx110x", "gfx1151", "gfx120x"], "windows", "release") + self.assertIsNotNone(config) + per_family = json.loads(config.matrix_per_family_json) family_names = [f["amdgpu_family"] for f in per_family] self.assertIn("gfx110X-all", family_names) self.assertIn("gfx1151", family_names) @@ -509,9 +503,9 @@ def test_windows_release_presubmit_families(self): def test_per_family_info_fields(self): """Per-family info contains expected fields.""" - result = self._expand(["gfx94x"], "linux", "release") - self.assertEqual(len(result), 1) - per_family = json.loads(result[0].matrix_per_family_json) + config = self._expand(["gfx94x"], "linux", "release") + self.assertIsNotNone(config) + per_family = json.loads(config.matrix_per_family_json) self.assertEqual(len(per_family), 1) info = per_family[0] self.assertEqual(info["amdgpu_family"], "gfx94X-dcgpu") @@ -521,62 +515,54 @@ def test_per_family_info_fields(self): def test_sanity_check_flag_propagated(self): """sanity_check_only_for_family flows through to per-family info.""" - result = self._expand(["gfx110x"], "linux", "release") - per_family = json.loads(result[0].matrix_per_family_json) + config = self._expand(["gfx110x"], "linux", "release") + per_family = json.loads(config.matrix_per_family_json) self.assertTrue(per_family[0]["sanity_check_only_for_family"]) def test_variant_not_supported_by_family(self): - """A family that doesn't support the requested variant is excluded.""" - # gfx110x only supports "release", not "asan". - result = self._expand(["gfx110x"], "linux", "asan") - self.assertEqual(result, []) + """A family that doesn't support the requested variant → None.""" + config = self._expand(["gfx110x"], "linux", "asan") + self.assertIsNone(config) def test_asan_variant(self): - """ASAN variant produces an entry with correct metadata.""" - result = self._expand(["gfx94x"], "linux", "asan") - self.assertEqual(len(result), 1) - entry = result[0] - self.assertEqual(entry.build_variant_label, "asan") - self.assertEqual(entry.build_variant_suffix, "asan") - self.assertEqual(entry.artifact_group, "multi-arch-asan") - self.assertEqual(entry.build_variant_cmake_preset, "linux-release-asan") - self.assertFalse(entry.expect_failure) - self.assertTrue(entry.build_pytorch) + """ASAN variant produces a BuildConfig with correct metadata.""" + config = self._expand(["gfx94x"], "linux", "asan") + self.assertIsNotNone(config) + self.assertEqual(config.build_variant_label, "asan") + self.assertEqual(config.build_variant_suffix, "asan") + self.assertEqual(config.artifact_group, "multi-arch-asan") + self.assertEqual(config.build_variant_cmake_preset, "linux-release-asan") + self.assertFalse(config.expect_failure) + self.assertTrue(config.build_pytorch) def test_tsan_variant_expect_failure(self): """TSAN variant has expect_failure=True and build_pytorch=False.""" - result = self._expand(["gfx94x"], "linux", "tsan") - self.assertEqual(len(result), 1) - entry = result[0] - self.assertEqual(entry.build_variant_label, "tsan") - self.assertTrue(entry.expect_failure) - self.assertFalse(entry.build_pytorch) + config = self._expand(["gfx94x"], "linux", "tsan") + self.assertIsNotNone(config) + self.assertEqual(config.build_variant_label, "tsan") + self.assertTrue(config.expect_failure) + self.assertFalse(config.build_pytorch) def test_unknown_family_raises(self): """A family not in the matrix raises KeyError (upstream should filter).""" with self.assertRaises(KeyError): self._expand(["gfx_nonexistent"], "linux", "release") - def test_unknown_platform_returns_empty(self): - """A platform with no build variants returns empty (filtered in expand_matrices).""" - result = self._expand(["gfx94x"], "macos", "release") - self.assertEqual(result, []) + def test_unknown_platform_returns_none(self): + """A platform with no build variants → None.""" + config = self._expand(["gfx94x"], "macos", "release") + self.assertIsNone(config) def test_nightly_family_expect_pytorch_failure(self): """expect_pytorch_failure is per-family data, not per-variant config.""" - # gfx906 on windows has expect_pytorch_failure in the family matrix data, - # but that's per-family, not per-variant. The variant config for - # windows/release doesn't set expect_pytorch_failure, so build_pytorch - # is True at the matrix level. - result = self._expand(["gfx906"], "windows", "release") - self.assertEqual(len(result), 1) - self.assertTrue(result[0].build_pytorch) + config = self._expand(["gfx906"], "windows", "release") + self.assertIsNotNone(config) + self.assertTrue(config.build_pytorch) def test_multiple_fetch_gfx_targets(self): """Multiple fetch-gfx-targets are comma-joined in amdgpu_targets.""" - result = self._expand(["gfx120x"], "linux", "release") - per_family = json.loads(result[0].matrix_per_family_json) - # gfx120x has fetch-gfx-targets: ["gfx1200", "gfx1201"] + config = self._expand(["gfx120x"], "linux", "release") + per_family = json.loads(config.matrix_per_family_json) self.assertEqual(per_family[0]["amdgpu_targets"], "gfx1200,gfx1201") # -- expand_matrices (both-platform wrapper) -- @@ -587,41 +573,41 @@ def test_expand_matrices_both_platforms(self): linux_families=["gfx94x", "gfx110x"], windows_families=["gfx110x", "gfx1151"], ) - result = cm.expand_matrices(targets=targets, build_variant="release") - self.assertEqual(len(result.linux_variants), 1) - self.assertEqual(len(result.windows_variants), 1) + result = cm.expand_build_configs(targets=targets, build_variant="release") + self.assertIsNotNone(result.linux) + self.assertIsNotNone(result.windows) - linux_families = json.loads(result.linux_variants[0].matrix_per_family_json) + linux_families = json.loads(result.linux.matrix_per_family_json) linux_names = [f["amdgpu_family"] for f in linux_families] self.assertIn("gfx94X-dcgpu", linux_names) self.assertIn("gfx110X-all", linux_names) - windows_families = json.loads(result.windows_variants[0].matrix_per_family_json) + windows_families = json.loads(result.windows.matrix_per_family_json) windows_names = [f["amdgpu_family"] for f in windows_families] self.assertIn("gfx110X-all", windows_names) self.assertIn("gfx1151", windows_names) - def test_expand_matrices_variant_not_on_windows(self): - """ASAN has no windows config → windows list is empty.""" + def test_variant_not_on_windows(self): + """ASAN has no windows config → windows is None.""" targets = cm.TargetSelection( linux_families=["gfx94x"], windows_families=["gfx110x"], ) - result = cm.expand_matrices(targets=targets, build_variant="asan") - self.assertEqual(len(result.linux_variants), 1) - self.assertEqual(result.windows_variants, []) + result = cm.expand_build_configs(targets=targets, build_variant="asan") + self.assertIsNotNone(result.linux) + self.assertIsNone(result.windows) - def test_expand_matrices_empty_targets(self): - """Empty targets on both platforms → both lists empty.""" + def test_empty_targets(self): + """Empty targets on both platforms → both None.""" targets = cm.TargetSelection() - result = cm.expand_matrices(targets=targets, build_variant="release") - self.assertEqual(result.linux_variants, []) - self.assertEqual(result.windows_variants, []) + result = cm.expand_build_configs(targets=targets, build_variant="release") + self.assertIsNone(result.linux) + self.assertIsNone(result.windows) # -- Parity test -- def test_output_matches_generate_multi_arch_matrix(self): - """expand_matrices output matches configure_ci.generate_multi_arch_matrix. + """expand_build_configs output matches configure_ci.generate_multi_arch_matrix. Parity test: the new functions should produce identical output to the old one for the same inputs. @@ -649,11 +635,11 @@ def test_output_matches_generate_multi_arch_matrix(self): linux_families=linux_families, windows_families=windows_families, ) - result = cm.expand_matrices(targets=targets, build_variant=variant) + result = cm.expand_build_configs(targets=targets, build_variant=variant) - for platform, families, new_result in [ - ("linux", linux_families, result.linux_variants), - ("windows", windows_families, result.windows_variants), + for platform, families, new_config in [ + ("linux", linux_families, result.linux), + ("windows", windows_families, result.windows), ]: old_result = generate_multi_arch_matrix( target_names=families, @@ -662,12 +648,12 @@ def test_output_matches_generate_multi_arch_matrix(self): platform_build_variants=all_build_variants[platform], base_args={"build_variant": variant}, ) - new_as_dicts = [entry.to_dict() for entry in new_result] + self.assertIsNotNone(new_config, f"expected config for {platform}") + # Old function returns a single-element list; compare the one entry. + self.assertEqual(len(old_result), 1, f"expected 1 entry for {platform}") self.assertEqual( - len(old_result), len(new_as_dicts), f"length mismatch on {platform}" + old_result[0], new_config.to_dict(), f"mismatch on {platform}" ) - for old_entry, new_entry in zip(old_result, new_as_dicts): - self.assertEqual(old_entry, new_entry, f"entry mismatch on {platform}") # --------------------------------------------------------------------------- @@ -713,8 +699,8 @@ def test_skipped_outputs(self): """CIOutputs.skipped produces empty, disabled outputs.""" outputs = cm.CIOutputs.skipped("test reason") self.assertFalse(outputs.is_ci_enabled) - self.assertEqual(outputs.linux_variants, []) - self.assertEqual(outputs.windows_variants, []) + self.assertIsNone(outputs.builds.linux) + self.assertIsNone(outputs.builds.windows) @patch("configure_multi_arch_ci.check_skip_ci") def test_pipeline_skips_when_gate_says_skip(self, mock_skip): @@ -728,12 +714,12 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): ) outputs = cm.configure(inputs) self.assertFalse(outputs.is_ci_enabled) - self.assertEqual(outputs.linux_variants, []) + self.assertIsNone(outputs.builds.linux) @patch("configure_multi_arch_ci.check_skip_ci") @patch("configure_multi_arch_ci.select_targets") @patch("configure_multi_arch_ci.decide_jobs") - @patch("configure_multi_arch_ci.expand_matrices") + @patch("configure_multi_arch_ci.expand_build_configs") def test_pipeline_calls_all_steps( self, mock_expand, mock_jobs, mock_targets, mock_skip ): @@ -750,7 +736,7 @@ def test_pipeline_calls_all_steps( build_pytorch=cm.JobGroupDecision(action="run", reason="default"), test_pytorch=cm.JobGroupDecision(action="run", reason="default"), ) - mock_expand.return_value = cm.MatrixExpansion() + mock_expand.return_value = cm.BuildConfigs() inputs = cm.CIInputs( event_name="workflow_dispatch", From c6f160ed04f8fed7feba8763d4ba1ad0073190f1 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 14:23:10 -0700 Subject: [PATCH 11/56] Rewrite expand_build_configs tests as structural, not change-detector Test through the public expand_build_configs API, not the private per-platform helper. Verify structural properties (schema, consistency between dist_amdgpu_families and matrix_per_family_json, filtering behavior) rather than specific data values from amdgpu_family_matrix.py. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/configure_multi_arch_ci_test.py | 275 +++++++----------- 1 file changed, 110 insertions(+), 165 deletions(-) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 105b6a8c999..f83ba95791d 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -422,39 +422,18 @@ def test_platform_filtering(self): class TestExpandBuildConfigs(unittest.TestCase): - """Test build config expansion (_expand_build_config_for_platform and expand_build_configs).""" + """Test expand_build_configs: TargetSelection × build_variant → BuildConfigs. - def _expand(self, families, platform, build_variant): - """Helper: look up variant config and call _expand_build_config_for_platform.""" - from amdgpu_family_matrix import ( - all_build_variants, - get_all_families_for_trigger_types, - ) - - all_families = get_all_families_for_trigger_types( - ["presubmit", "postsubmit", "nightly"] - ) - variant_config = all_build_variants.get(platform, {}).get(build_variant) - if not variant_config: - return None - return cm._expand_build_config_for_platform( - families=families, - platform=platform, - build_variant=build_variant, - all_families=all_families, - variant_config=variant_config, - ) - - def test_empty_families_returns_none(self): - """No families → None.""" - result = self._expand([], "linux", "release") - self.assertIsNone(result) + Tests verify structural properties of the output, not specific data values + from amdgpu_family_matrix.py. Changing a runner label or flipping + expect_failure in the matrix data should not require test updates here. + """ - def test_build_config_to_dict(self): - """BuildConfig.to_dict() produces the expected structure.""" + def test_build_config_to_dict_round_trips(self): + """BuildConfig.to_dict() produces all expected keys.""" config = cm.BuildConfig( - matrix_per_family_json='[{"amdgpu_family": "gfx94X-dcgpu"}]', - dist_amdgpu_families="gfx94X-dcgpu", + matrix_per_family_json="[]", + dist_amdgpu_families="", artifact_group="multi-arch-release", build_variant_label="release", build_variant_suffix="", @@ -463,155 +442,122 @@ def test_build_config_to_dict(self): build_pytorch=True, ) d = config.to_dict() - self.assertEqual(d["artifact_group"], "multi-arch-release") - self.assertFalse(d["expect_failure"]) - self.assertTrue(d["build_pytorch"]) - - def test_linux_release_presubmit_families(self): - """Presubmit families on linux/release produces a BuildConfig with all families.""" - config = self._expand( - ["gfx94x", "gfx110x", "gfx1151", "gfx120x"], "linux", "release" - ) - self.assertIsNotNone(config) - self.assertEqual(config.build_variant_label, "release") - self.assertEqual(config.build_variant_suffix, "") - self.assertEqual(config.artifact_group, "multi-arch-release") - self.assertFalse(config.expect_failure) - self.assertTrue(config.build_pytorch) - - per_family = json.loads(config.matrix_per_family_json) - family_names = [f["amdgpu_family"] for f in per_family] - self.assertIn("gfx94X-dcgpu", family_names) - self.assertIn("gfx110X-all", family_names) - self.assertIn("gfx1151", family_names) - self.assertIn("gfx120X-all", family_names) + expected_keys = { + "matrix_per_family_json", + "dist_amdgpu_families", + "artifact_group", + "build_variant_label", + "build_variant_suffix", + "build_variant_cmake_preset", + "expect_failure", + "build_pytorch", + } + self.assertEqual(set(d.keys()), expected_keys) - self.assertEqual( - config.dist_amdgpu_families, - ";".join(family_names), - ) - - def test_windows_release_presubmit_families(self): - """Windows release with families that have windows entries.""" - config = self._expand(["gfx110x", "gfx1151", "gfx120x"], "windows", "release") - self.assertIsNotNone(config) - per_family = json.loads(config.matrix_per_family_json) - family_names = [f["amdgpu_family"] for f in per_family] - self.assertIn("gfx110X-all", family_names) - self.assertIn("gfx1151", family_names) - self.assertIn("gfx120X-all", family_names) - - def test_per_family_info_fields(self): - """Per-family info contains expected fields.""" - config = self._expand(["gfx94x"], "linux", "release") - self.assertIsNotNone(config) - per_family = json.loads(config.matrix_per_family_json) - self.assertEqual(len(per_family), 1) - info = per_family[0] - self.assertEqual(info["amdgpu_family"], "gfx94X-dcgpu") - self.assertEqual(info["amdgpu_targets"], "gfx942") - self.assertEqual(info["test-runs-on"], "linux-mi325-1gpu-ossci-rocm") - self.assertFalse(info["sanity_check_only_for_family"]) - - def test_sanity_check_flag_propagated(self): - """sanity_check_only_for_family flows through to per-family info.""" - config = self._expand(["gfx110x"], "linux", "release") - per_family = json.loads(config.matrix_per_family_json) - self.assertTrue(per_family[0]["sanity_check_only_for_family"]) - - def test_variant_not_supported_by_family(self): - """A family that doesn't support the requested variant → None.""" - config = self._expand(["gfx110x"], "linux", "asan") - self.assertIsNone(config) - - def test_asan_variant(self): - """ASAN variant produces a BuildConfig with correct metadata.""" - config = self._expand(["gfx94x"], "linux", "asan") - self.assertIsNotNone(config) - self.assertEqual(config.build_variant_label, "asan") - self.assertEqual(config.build_variant_suffix, "asan") - self.assertEqual(config.artifact_group, "multi-arch-asan") - self.assertEqual(config.build_variant_cmake_preset, "linux-release-asan") - self.assertFalse(config.expect_failure) - self.assertTrue(config.build_pytorch) - - def test_tsan_variant_expect_failure(self): - """TSAN variant has expect_failure=True and build_pytorch=False.""" - config = self._expand(["gfx94x"], "linux", "tsan") - self.assertIsNotNone(config) - self.assertEqual(config.build_variant_label, "tsan") - self.assertTrue(config.expect_failure) - self.assertFalse(config.build_pytorch) - - def test_unknown_family_raises(self): - """A family not in the matrix raises KeyError (upstream should filter).""" - with self.assertRaises(KeyError): - self._expand(["gfx_nonexistent"], "linux", "release") - - def test_unknown_platform_returns_none(self): - """A platform with no build variants → None.""" - config = self._expand(["gfx94x"], "macos", "release") - self.assertIsNone(config) - - def test_nightly_family_expect_pytorch_failure(self): - """expect_pytorch_failure is per-family data, not per-variant config.""" - config = self._expand(["gfx906"], "windows", "release") - self.assertIsNotNone(config) - self.assertTrue(config.build_pytorch) - - def test_multiple_fetch_gfx_targets(self): - """Multiple fetch-gfx-targets are comma-joined in amdgpu_targets.""" - config = self._expand(["gfx120x"], "linux", "release") - per_family = json.loads(config.matrix_per_family_json) - self.assertEqual(per_family[0]["amdgpu_targets"], "gfx1200,gfx1201") - - # -- expand_matrices (both-platform wrapper) -- - - def test_expand_matrices_both_platforms(self): - """expand_matrices returns entries for both linux and windows.""" + def test_empty_targets_both_none(self): + """Empty targets on both platforms → both None.""" + targets = cm.TargetSelection() + result = cm.expand_build_configs(targets=targets, build_variant="release") + self.assertIsNone(result.linux) + self.assertIsNone(result.windows) + + def test_release_produces_configs_for_both_platforms(self): + """Release variant with families on both platforms produces both configs + with correctly structured per-family info.""" + inputs = cm.CIInputs( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + targets = cm.select_targets(inputs) + result = cm.expand_build_configs(targets=targets, build_variant="release") + required_keys = { + "amdgpu_family", + "amdgpu_targets", + "test-runs-on", + "sanity_check_only_for_family", + } + for config in [result.linux, result.windows]: + self.assertIsNotNone(config) + per_family = json.loads(config.matrix_per_family_json) + self.assertGreater(len(per_family), 0) + for entry in per_family: + self.assertEqual( + set(entry.keys()), + required_keys, + f"unexpected keys in per-family info: {entry}", + ) + + def test_build_config_structure(self): + """BuildConfig has correct structure: families, metadata, consistency. + + BuildConfig carries two representations of the family list for + different workflow consumers: + + matrix_per_family_json — JSON array with per-family metadata for + test and per-arch artifact jobs (fromJSON matrix expansion): + + [ + { + "amdgpu_family": "gfx94X-dcgpu", + "amdgpu_targets": "gfx942", + "test-runs-on": "linux-mi325-1gpu-ossci-rocm", + "sanity_check_only_for_family": false + }, + ... + ] + + dist_amdgpu_families — semicolon-separated family names for CMake + (THEROCK_DIST_AMDGPU_TARGETS) and configure_stage.py: + + "gfx94X-dcgpu;gfx110X-all" + + Both must contain the same set of families. + """ targets = cm.TargetSelection( linux_families=["gfx94x", "gfx110x"], - windows_families=["gfx110x", "gfx1151"], + windows_families=["gfx110x"], ) result = cm.expand_build_configs(targets=targets, build_variant="release") - self.assertIsNotNone(result.linux) - self.assertIsNotNone(result.windows) - - linux_families = json.loads(result.linux.matrix_per_family_json) - linux_names = [f["amdgpu_family"] for f in linux_families] - self.assertIn("gfx94X-dcgpu", linux_names) - self.assertIn("gfx110X-all", linux_names) - - windows_families = json.loads(result.windows.matrix_per_family_json) - windows_names = [f["amdgpu_family"] for f in windows_families] - self.assertIn("gfx110X-all", windows_names) - self.assertIn("gfx1151", windows_names) - def test_variant_not_on_windows(self): - """ASAN has no windows config → windows is None.""" + # All target families that support the variant appear in output. + linux_per_family = json.loads(result.linux.matrix_per_family_json) + self.assertEqual(len(linux_per_family), 2) + windows_per_family = json.loads(result.windows.matrix_per_family_json) + self.assertEqual(len(windows_per_family), 1) + + # The two family representations carry the same set of families. + dist_set = set(result.linux.dist_amdgpu_families.split(";")) + json_set = {f["amdgpu_family"] for f in linux_per_family} + self.assertEqual(dist_set, json_set) + + # Variant metadata is populated. + config = result.linux + self.assertTrue(len(config.build_variant_label) > 0) + self.assertIn("release", config.artifact_group) + self.assertIsInstance(config.expect_failure, bool) + self.assertIsInstance(config.build_pytorch, bool) + + def test_variant_filters_by_platform_and_family_support(self): + """ASAN: only gfx94x on linux supports it, gfx110x doesn't, windows has no ASAN config.""" + # gfx94x supports asan, gfx110x is release-only, windows has no asan variant. targets = cm.TargetSelection( - linux_families=["gfx94x"], + linux_families=["gfx94x", "gfx110x"], windows_families=["gfx110x"], ) result = cm.expand_build_configs(targets=targets, build_variant="asan") + # Only gfx94x on linux survives. self.assertIsNotNone(result.linux) + linux_per_family = json.loads(result.linux.matrix_per_family_json) + self.assertEqual(len(linux_per_family), 1) + # Windows has no asan variant config at all. self.assertIsNone(result.windows) - def test_empty_targets(self): - """Empty targets on both platforms → both None.""" - targets = cm.TargetSelection() - result = cm.expand_build_configs(targets=targets, build_variant="release") - self.assertIsNone(result.linux) - self.assertIsNone(result.windows) - - # -- Parity test -- + # -- Parity test (useful during transition, may be removed later) -- def test_output_matches_generate_multi_arch_matrix(self): - """expand_build_configs output matches configure_ci.generate_multi_arch_matrix. - - Parity test: the new functions should produce identical output - to the old one for the same inputs. - """ + """expand_build_configs output matches configure_ci.generate_multi_arch_matrix.""" from amdgpu_family_matrix import ( all_build_variants, get_all_families_for_trigger_types, @@ -649,7 +595,6 @@ def test_output_matches_generate_multi_arch_matrix(self): base_args={"build_variant": variant}, ) self.assertIsNotNone(new_config, f"expected config for {platform}") - # Old function returns a single-element list; compare the one entry. self.assertEqual(len(old_result), 1, f"expected 1 entry for {platform}") self.assertEqual( old_result[0], new_config.to_dict(), f"mismatch on {platform}" From d8cab4957328e44a32c04c343666087da7add2c4 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 14:51:57 -0700 Subject: [PATCH 12/56] Implement check_skip_ci with skip-ci label and path filtering Check skip-ci PR label first (takes priority), then delegate to is_ci_run_required() from configure_ci_path_filters.py for docs-only / no-files-changed detection. schedule and workflow_dispatch always proceed (changed_files is None). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 21 ++++++--- .../tests/configure_multi_arch_ci_test.py | 45 ++++++++++++++++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 809fade3285..ec798f601c0 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -52,7 +52,7 @@ from typing import Literal from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types -from configure_ci_path_filters import get_git_modified_paths +from configure_ci_path_filters import get_git_modified_paths, is_ci_run_required from github_actions_utils import gha_append_step_summary, gha_set_output # --------------------------------------------------------------------------- @@ -330,8 +330,17 @@ def check_skip_ci( - 'skip-ci' PR label - Only skippable files changed (docs, .md, etc.) - No files changed + + schedule and workflow_dispatch always proceed (changed_files is None + for those triggers, and they have no PR labels). """ - # TODO: Implement — check skip-ci label, call is_ci_run_required() + if "skip-ci" in inputs.pr_labels: + return SkipDecision(skip=True, reason="skip-ci label") + + # changed_files is None for schedule/workflow_dispatch — always proceed. + if changed_files is not None and not is_ci_run_required(changed_files): + return SkipDecision(skip=True, reason="no CI-relevant files changed") + return SkipDecision(skip=False, reason="") @@ -661,10 +670,10 @@ def configure(inputs: CIInputs) -> CIOutputs: if inputs.is_pull_request or inputs.is_push: changed_files = get_git_modified_paths(inputs.base_ref) - skip = check_skip_ci(inputs=inputs, changed_files=changed_files) - if skip.skip: - print(f"Skipping CI: {skip.reason}") - return CIOutputs.skipped(skip.reason) + skip_decision = check_skip_ci(inputs=inputs, changed_files=changed_files) + if skip_decision.skip: + print(f"Skipping CI: {skip_decision.reason}") + return CIOutputs.skipped(skip_decision.reason) # Steps 3 and 4 are independent: job decisions (which job groups run) # and target selection (which GPU families) are orthogonal concerns. diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index f83ba95791d..a2b7a739363 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -155,21 +155,52 @@ def test_push_reads_before_sha(self): class TestCheckSkipCI(unittest.TestCase): - """Test the skip CI gate.""" + """Test the skip CI gate. - def test_no_skip_by_default(self): - """Default stub does not skip.""" - inputs = cm.CIInputs( + Path filtering logic is tested in configure_ci_path_filters_test.py. + These tests mock is_ci_run_required and verify check_skip_ci's own + logic: label handling, None changed_files passthrough, and delegation. + """ + + def _inputs(self, **kwargs): + defaults = dict( event_name="pull_request", branch_name="feature", base_ref="HEAD^", build_variant="release", ) - result = cm.check_skip_ci(inputs, changed_files=["some/file.cpp"]) + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_skip_ci_label(self): + """PR with skip-ci label skips CI regardless of changed files.""" + inputs = self._inputs(pr_labels=["skip-ci", "gfx950"]) + result = cm.check_skip_ci(inputs, changed_files=["CMakeLists.txt"]) + self.assertTrue(result.skip) + self.assertIn("skip-ci", result.reason) + + @patch("configure_multi_arch_ci.is_ci_run_required", return_value=False) + def test_path_filter_says_skip(self, mock_filter): + """When is_ci_run_required returns False, skip CI.""" + inputs = self._inputs() + result = cm.check_skip_ci(inputs, changed_files=["docs/README.md"]) + self.assertTrue(result.skip) + mock_filter.assert_called_once_with(["docs/README.md"]) + + @patch("configure_multi_arch_ci.is_ci_run_required", return_value=True) + def test_path_filter_says_required(self, mock_filter): + """When is_ci_run_required returns True, don't skip.""" + inputs = self._inputs() + result = cm.check_skip_ci(inputs, changed_files=["CMakeLists.txt"]) self.assertFalse(result.skip) - # TODO: Tests for skip-ci label, docs-only changes, no files changed - # These will be filled in when check_skip_ci is implemented (Phase 2). + @patch("configure_multi_arch_ci.is_ci_run_required") + def test_none_changed_files_skips_path_filter(self, mock_filter): + """schedule/workflow_dispatch pass None → path filter not called.""" + inputs = self._inputs(event_name="schedule") + result = cm.check_skip_ci(inputs, changed_files=None) + self.assertFalse(result.skip) + mock_filter.assert_not_called() # --------------------------------------------------------------------------- From eefe93c7e030c01fc8bad464242cdc135c914499 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 15:25:20 -0700 Subject: [PATCH 13/56] Implement decide_jobs test_type logic and extract GitContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_type determination to decide_jobs: quick (default), comprehensive (schedule), full (submodule changes or test labels), with test_filter: PR label override. Adopts the test type names from PR #3992. Extract GitContext dataclass to separate git-derived data from CIInputs. configure() is now fully pure — takes CIInputs + GitContext, no git calls. main() is the only place that builds GitContext.from_repo(). Tests construct GitContext directly. Rename parameters to ci_inputs/git_context throughout for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 236 ++++++++++++++---- .../tests/configure_multi_arch_ci_test.py | 115 +++++++-- 2 files changed, 277 insertions(+), 74 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index ec798f601c0..4f2413bd8a9 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -52,7 +52,11 @@ from typing import Literal from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types -from configure_ci_path_filters import get_git_modified_paths, is_ci_run_required +from configure_ci_path_filters import ( + get_git_modified_paths, + get_git_submodule_paths, + is_ci_run_required, +) from github_actions_utils import gha_append_step_summary, gha_set_output # --------------------------------------------------------------------------- @@ -176,6 +180,34 @@ def from_environ() -> "CIInputs": ) +@dataclass(frozen=True) +class GitContext: + """Git-derived data for the current commit/PR. + + Separated from CIInputs because these require git operations to compute, + while CIInputs is parsed from the GitHub Actions environment. Tests + construct GitContext directly without touching git. + """ + + changed_files: list[str] | None = None + submodule_paths: list[str] | None = None + + @staticmethod + def from_repo(base_ref: str) -> "GitContext": + """Compute from the actual repo. Only called from main().""" + changed_files = get_git_modified_paths(base_ref) + submodule_paths = list(get_git_submodule_paths() or []) + return GitContext( + changed_files=changed_files, + submodule_paths=submodule_paths, + ) + + @staticmethod + def empty() -> "GitContext": + """No git data (schedule/workflow_dispatch).""" + return GitContext() + + @dataclass(frozen=True) class SkipDecision: """Whether to skip CI entirely.""" @@ -241,9 +273,16 @@ def rebuild_stages(self) -> list[str]: @dataclass(frozen=True) class TestRocmDecision(JobGroupDecision): - """Test-rocm job group with test filtering details.""" + """Test-rocm job group with test filtering details. + + test_type levels (from least to most testing): + - "quick" — default for PRs and push + - "standard" — via test_filter:standard PR label + - "comprehensive" — schedule/nightly + - "full" — submodule changes, test:* labels, or test_filter:full + """ - test_type: str = "smoke" # smoke or full + test_type: str = "quick" test_type_reason: str = "default" @@ -321,8 +360,8 @@ def skipped(reason: str) -> "CIOutputs": def check_skip_ci( - inputs: CIInputs, - changed_files: list[str] | None, + ci_inputs: CIInputs, + git_context: GitContext, ) -> SkipDecision: """Determine whether CI should be skipped entirely. @@ -334,11 +373,13 @@ def check_skip_ci( schedule and workflow_dispatch always proceed (changed_files is None for those triggers, and they have no PR labels). """ - if "skip-ci" in inputs.pr_labels: + if "skip-ci" in ci_inputs.pr_labels: return SkipDecision(skip=True, reason="skip-ci label") # changed_files is None for schedule/workflow_dispatch — always proceed. - if changed_files is not None and not is_ci_run_required(changed_files): + if git_context.changed_files is not None and not is_ci_run_required( + git_context.changed_files + ): return SkipDecision(skip=True, reason="no CI-relevant files changed") return SkipDecision(skip=False, reason="") @@ -349,23 +390,116 @@ def check_skip_ci( # --------------------------------------------------------------------------- +_VALID_TEST_FILTER_TYPES = {"quick", "standard", "comprehensive", "full"} + + +def _has_test_labels(ci_inputs: CIInputs) -> bool: + """Check whether any test labels were specified (workflow_dispatch or PR).""" + if ci_inputs.linux_test_labels or ci_inputs.windows_test_labels: + return True + return any(label.startswith("test:") for label in ci_inputs.pr_labels) + + +def _determine_test_type( + ci_inputs: CIInputs, + git_context: GitContext, +) -> tuple[str, str]: + """Determine test_type and reason based on trigger, labels, and changed files. + + Test types from least to most testing: + + - "quick": Fast sanity checks. Default for PRs and push where only + build infra or non-submodule files changed. Keeps CI fast for + routine changes that are unlikely to break GPU-specific behavior. + - "standard": More thorough than quick, but not full nightly coverage. + Only available via explicit test_filter:standard PR label. + - "comprehensive": Full nightly test suite. Used for scheduled runs + to catch regressions across all components without requiring a + submodule change to trigger it. + - "full": Everything, including tests for specific components named + by test:* labels. Triggered when a submodule changes (the actual + GPU libraries changed, so we need thorough validation) or when + test labels explicitly request specific component tests. + + The test_filter: PR label can override any of the above, giving + developers manual control (e.g. test_filter:comprehensive on a PR + to get nightly-level coverage before merge). + + Returns (test_type, reason). + """ + # Default: quick tests for fast CI feedback. + test_type = "quick" + reason = "default" + + # Schedule runs the full nightly suite — comprehensive coverage on + # a cadence, catching regressions that quick tests miss. + if ci_inputs.is_schedule: + test_type = "comprehensive" + reason = "scheduled run" + elif ( + git_context.changed_files is not None + and git_context.submodule_paths is not None + ): + # A submodule change means actual library code changed (e.g. + # rocBLAS, MIOpen). These need full testing since the change + # could affect any downstream consumer. + matching = set(git_context.submodule_paths) & set(git_context.changed_files) + if matching: + test_type = "full" + reason = f"submodule(s) changed: {sorted(matching)}" + + # test:* labels request specific component tests (e.g. test:rocprim). + # When someone explicitly asks for tests, run the full suite for those + # components — they're investigating something specific. + if _has_test_labels(ci_inputs): + test_type = "full" + reason = "test labels specified" + + # test_filter: PR label gives developers manual override to any level. + # This is the escape hatch: run comprehensive on a PR before merge, + # or downgrade to quick if you know the change is safe. + for label in ci_inputs.pr_labels: + if not label.startswith("test_filter:"): + continue + filter_type = label.split(":")[1] + if filter_type in _VALID_TEST_FILTER_TYPES: + test_type = filter_type + reason = f"test_filter label: {label}" + break + print(f" Ignoring unrecognized test_filter value: {filter_type!r}") + + return test_type, reason + + def decide_jobs( - inputs: CIInputs, - changed_files: list[str] | None, + ci_inputs: CIInputs, + git_context: GitContext, ) -> JobDecisions: """Determine which job groups to run, skip, or satisfy with prebuilt files. - Currently returns "run everything, rebuild all stages" — subgraph - selection based on changed files comes later. + All job groups currently run unconditionally — subgraph selection based + on changed files (Phase 4) will add skip/prebuilt decisions. + + test_type is determined here based on trigger type, labels, and + changed files. """ - # TODO: Implement — classify changed files, find entry point in job DAG, - # propagate forward through reachable nodes, mark unreachable as skip + test_type, test_type_reason = _determine_test_type( + ci_inputs=ci_inputs, + git_context=git_context, + ) + print(f" test_type: {test_type} ({test_type_reason})") + return JobDecisions( - build_rocm=BuildRocmDecision(action="run", reason="default (stub)"), - test_rocm=TestRocmDecision(action="run", reason="default (stub)"), - build_rocm_python=JobGroupDecision(action="run", reason="default (stub)"), - build_pytorch=JobGroupDecision(action="run", reason="default (stub)"), - test_pytorch=JobGroupDecision(action="run", reason="default (stub)"), + build_rocm=BuildRocmDecision(action="run", reason="default"), + test_rocm=TestRocmDecision( + action="run", + reason="default", + test_type=test_type, + test_type_reason=test_type_reason, + ), + build_rocm_python=JobGroupDecision(action="run", reason="default"), + build_pytorch=JobGroupDecision(action="run", reason="default"), + test_pytorch=JobGroupDecision(action="run", reason="default"), ) @@ -400,7 +534,7 @@ def _filter_families_by_platform( ] -def select_targets(inputs: CIInputs) -> TargetSelection: +def select_targets(ci_inputs: CIInputs) -> TargetSelection: """Determine GPU families per platform based on trigger type and inputs. Trigger types run progressively larger sets of builds and tests: @@ -427,20 +561,20 @@ def select_targets(inputs: CIInputs) -> TargetSelection: # Select family names per platform based on trigger type. # Ordered from most-specific (workflow_dispatch) to broadest (schedule). - if inputs.is_workflow_dispatch: + if ci_inputs.is_workflow_dispatch: # Manual trigger: caller specifies exact families per platform. # Empty input means "no families for that platform" — the caller # has full control over what runs. - linux_names = list(inputs.linux_amdgpu_families) - windows_names = list(inputs.windows_amdgpu_families) - elif inputs.is_pull_request: + linux_names = list(ci_inputs.linux_amdgpu_families) + windows_names = list(ci_inputs.windows_amdgpu_families) + elif ci_inputs.is_pull_request: # Smallest default set for fast PR feedback. PR labels can extend # the set below (gfx* for individual families, run-all-archs-ci # for everything). defaults = list(get_all_families_for_trigger_types(["presubmit"]).keys()) linux_names = list(defaults) windows_names = list(defaults) - elif inputs.is_push: + elif ci_inputs.is_push: # Broader than PR: presubmit + postsubmit. Code has landed, so # we validate on more targets (e.g. gfx950) without paying full # nightly cost. @@ -449,17 +583,17 @@ def select_targets(inputs: CIInputs) -> TargetSelection: ) linux_names = list(defaults) windows_names = list(defaults) - elif inputs.is_schedule: + elif ci_inputs.is_schedule: # Full nightly coverage: every known family, including targets # that are too slow or expensive for per-push CI. linux_names = list(all_families.keys()) windows_names = list(all_families.keys()) else: - raise ValueError(f"Unsupported event type: {inputs.event_name!r}") + raise ValueError(f"Unsupported event type: {ci_inputs.event_name!r}") # PR labels can extend the family set (both platforms) - if inputs.is_pull_request: - for label in inputs.pr_labels: + if ci_inputs.is_pull_request: + for label in ci_inputs.pr_labels: if label == "run-all-archs-ci": # Override to all families. linux_names = list(all_families.keys()) @@ -635,7 +769,7 @@ def write_outputs(outputs: CIOutputs) -> None: This is the only function with side effects (besides from_environ). """ - test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "smoke" + test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "quick" linux = outputs.builds.linux windows = outputs.builds.windows output_vars = { @@ -656,34 +790,28 @@ def write_outputs(outputs: CIOutputs) -> None: # --------------------------------------------------------------------------- -def configure(inputs: CIInputs) -> CIOutputs: +def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: """Main pipeline. Each step feeds the next. - This function is the primary entry point for testing — construct a - CIInputs and assert on the returned CIOutputs. + This function is the primary entry point for testing — construct + CIInputs and GitContext directly and assert on the returned CIOutputs. + No git operations or environment access needed. """ - # Step 1 already done — inputs is the parsed CIInputs. - # Step 2: Gate — should we skip CI entirely? - # For schedule and workflow_dispatch, always proceed. - changed_files: list[str] | None = None - if inputs.is_pull_request or inputs.is_push: - changed_files = get_git_modified_paths(inputs.base_ref) - - skip_decision = check_skip_ci(inputs=inputs, changed_files=changed_files) + skip_decision = check_skip_ci(ci_inputs=ci_inputs, git_context=git_context) if skip_decision.skip: print(f"Skipping CI: {skip_decision.reason}") return CIOutputs.skipped(skip_decision.reason) # Steps 3 and 4 are independent: job decisions (which job groups run) # and target selection (which GPU families) are orthogonal concerns. - jobs = decide_jobs(inputs=inputs, changed_files=changed_files) - targets = select_targets(inputs) + jobs = decide_jobs(ci_inputs=ci_inputs, git_context=git_context) + targets = select_targets(ci_inputs) # Step 5: Build configs per platform builds = expand_build_configs( targets=targets, - build_variant=inputs.build_variant, + build_variant=ci_inputs.build_variant, ) return CIOutputs( @@ -699,17 +827,25 @@ def configure(inputs: CIInputs) -> CIOutputs: def main(): - inputs = CIInputs.from_environ() + ci_inputs = CIInputs.from_environ() print("Multi-arch CI configuration") - print(f" event: {inputs.event_name}") - print(f" branch: {inputs.branch_name}") - print(f" variant: {inputs.build_variant}") - if inputs.pr_labels: - print(f" pr_labels: {inputs.pr_labels}") + print(f" event: {ci_inputs.event_name}") + print(f" branch: {ci_inputs.branch_name}") + print(f" variant: {ci_inputs.build_variant}") + if ci_inputs.pr_labels: + print(f" pr_labels: {ci_inputs.pr_labels}") print() - outputs = configure(inputs) + # Build git context for push/PR triggers (need changed files for + # skip-ci and test_type decisions). Schedule/workflow_dispatch don't + # need git data. + if ci_inputs.is_pull_request or ci_inputs.is_push: + git_context = GitContext.from_repo(base_ref=ci_inputs.base_ref) + else: + git_context = GitContext.empty() + + outputs = configure(ci_inputs, git_context) write_outputs(outputs) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index a2b7a739363..01291b06301 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -175,7 +175,8 @@ def _inputs(self, **kwargs): def test_skip_ci_label(self): """PR with skip-ci label skips CI regardless of changed files.""" inputs = self._inputs(pr_labels=["skip-ci", "gfx950"]) - result = cm.check_skip_ci(inputs, changed_files=["CMakeLists.txt"]) + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.check_skip_ci(inputs, git) self.assertTrue(result.skip) self.assertIn("skip-ci", result.reason) @@ -183,7 +184,8 @@ def test_skip_ci_label(self): def test_path_filter_says_skip(self, mock_filter): """When is_ci_run_required returns False, skip CI.""" inputs = self._inputs() - result = cm.check_skip_ci(inputs, changed_files=["docs/README.md"]) + git = cm.GitContext(changed_files=["docs/README.md"]) + result = cm.check_skip_ci(inputs, git) self.assertTrue(result.skip) mock_filter.assert_called_once_with(["docs/README.md"]) @@ -191,14 +193,16 @@ def test_path_filter_says_skip(self, mock_filter): def test_path_filter_says_required(self, mock_filter): """When is_ci_run_required returns True, don't skip.""" inputs = self._inputs() - result = cm.check_skip_ci(inputs, changed_files=["CMakeLists.txt"]) + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.check_skip_ci(inputs, git) self.assertFalse(result.skip) @patch("configure_multi_arch_ci.is_ci_run_required") def test_none_changed_files_skips_path_filter(self, mock_filter): """schedule/workflow_dispatch pass None → path filter not called.""" inputs = self._inputs(event_name="schedule") - result = cm.check_skip_ci(inputs, changed_files=None) + git = cm.GitContext() + result = cm.check_skip_ci(inputs, git) self.assertFalse(result.skip) mock_filter.assert_not_called() @@ -209,17 +213,21 @@ def test_none_changed_files_skips_path_filter(self, mock_filter): class TestDecideJobs(unittest.TestCase): - """Test job decision logic.""" + """Test job decision logic and test_type determination.""" - def test_stub_returns_job_decisions(self): - """Stub returns JobDecisions with all groups set to run.""" - inputs = cm.CIInputs( - event_name="push", - branch_name="main", - base_ref="HEAD^1", + def _inputs(self, **kwargs): + defaults = dict( + event_name="pull_request", + branch_name="feature", + base_ref="HEAD^", build_variant="release", ) - result = cm.decide_jobs(inputs, changed_files=None) + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_all_job_groups_run(self): + """All job groups are set to run (subgraph selection is Phase 4).""" + result = cm.decide_jobs(self._inputs(), git_context=cm.GitContext()) self.assertIsInstance(result, cm.JobDecisions) self.assertEqual(result.build_rocm.action, "run") self.assertEqual(result.test_rocm.action, "run") @@ -227,17 +235,76 @@ def test_stub_returns_job_decisions(self): self.assertEqual(result.build_pytorch.action, "run") self.assertEqual(result.test_pytorch.action, "run") - def test_test_rocm_has_test_type(self): - """TestRocmDecision carries test_type details.""" - inputs = cm.CIInputs( - event_name="push", - branch_name="main", - base_ref="HEAD^1", - build_variant="release", + def test_default_test_type_is_quick(self): + """Default test_type for PR/push with no special conditions.""" + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.decide_jobs(self._inputs(), git_context=git) + self.assertEqual(result.test_rocm.test_type, "quick") + + def test_schedule_is_comprehensive(self): + """Schedule trigger → comprehensive tests.""" + result = cm.decide_jobs( + self._inputs(event_name="schedule"), git_context=cm.GitContext() + ) + self.assertEqual(result.test_rocm.test_type, "comprehensive") + + def test_submodule_change_is_full(self): + """Changed files matching a submodule path → full tests.""" + git = cm.GitContext( + changed_files=["rocm-libraries", "CMakeLists.txt"], + submodule_paths=["rocm-libraries", "rocm-systems"], + ) + result = cm.decide_jobs(self._inputs(), git_context=git) + self.assertEqual(result.test_rocm.test_type, "full") + self.assertIn("submodule", result.test_rocm.test_type_reason) + + def test_no_submodule_change_stays_quick(self): + """Changed files not matching any submodule → stays quick.""" + git = cm.GitContext( + changed_files=["CMakeLists.txt"], + submodule_paths=["rocm-libraries", "rocm-systems"], + ) + result = cm.decide_jobs(self._inputs(), git_context=git) + self.assertEqual(result.test_rocm.test_type, "quick") + + def test_pr_test_label_is_full(self): + """PR with test:* label → full tests.""" + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.decide_jobs( + self._inputs(pr_labels=["test:rocprim"]), git_context=git + ) + self.assertEqual(result.test_rocm.test_type, "full") + + def test_workflow_dispatch_test_labels_is_full(self): + """workflow_dispatch with test labels → full tests.""" + result = cm.decide_jobs( + self._inputs( + event_name="workflow_dispatch", + linux_test_labels="test:rocprim", + ), + git_context=cm.GitContext(), + ) + self.assertEqual(result.test_rocm.test_type, "full") + + def test_test_filter_label_overrides(self): + """test_filter: PR label overrides the computed test_type.""" + # Even though schedule would set comprehensive, test_filter overrides. + result = cm.decide_jobs( + self._inputs( + event_name="schedule", + pr_labels=["test_filter:standard"], + ), + git_context=cm.GitContext(), + ) + self.assertEqual(result.test_rocm.test_type, "standard") + + def test_test_filter_invalid_ignored(self): + """Unrecognized test_filter value is ignored.""" + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.decide_jobs( + self._inputs(pr_labels=["test_filter:bogus"]), git_context=git ) - result = cm.decide_jobs(inputs, changed_files=None) - self.assertIsInstance(result.test_rocm, cm.TestRocmDecision) - self.assertEqual(result.test_rocm.test_type, "smoke") + self.assertEqual(result.test_rocm.test_type, "quick") def test_build_rocm_stage_partitioning(self): """BuildRocmDecision correctly partitions stages into prebuilt/rebuild.""" @@ -688,7 +755,7 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): base_ref="HEAD^1", build_variant="release", ) - outputs = cm.configure(inputs) + outputs = cm.configure(inputs, cm.GitContext()) self.assertFalse(outputs.is_ci_enabled) self.assertIsNone(outputs.builds.linux) @@ -720,7 +787,7 @@ def test_pipeline_calls_all_steps( base_ref="HEAD^1", build_variant="release", ) - outputs = cm.configure(inputs) + outputs = cm.configure(inputs, cm.GitContext()) self.assertTrue(outputs.is_ci_enabled) self.assertIsNotNone(outputs.jobs) From 37d23de01c68dd39ed88b91055d7c280a66f8af8 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 15:30:31 -0700 Subject: [PATCH 14/56] Restructure _determine_test_type with early returns and fail-fast Replace mutable test_type/reason with early returns in priority order: test_filter label > test:* labels > schedule > submodule change > default. Eliminates risk of fall-through overwrites between conditions. Raise ValueError on unrecognized test_filter values instead of silently ignoring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 67 +++++++++---------- .../tests/configure_multi_arch_ci_test.py | 12 ++-- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 4f2413bd8a9..dd0ef1eb182 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -425,50 +425,47 @@ def _determine_test_type( developers manual control (e.g. test_filter:comprehensive on a PR to get nightly-level coverage before merge). - Returns (test_type, reason). + Returns (test_type, reason). Checked in priority order — highest + priority overrides win and return early. """ - # Default: quick tests for fast CI feedback. - test_type = "quick" - reason = "default" + # Priority 1: test_filter: PR label is an explicit manual override. + # This is the escape hatch: run comprehensive on a PR before merge, + # or downgrade to quick if you know the change is safe. + for label in ci_inputs.pr_labels: + if not label.startswith("test_filter:"): + continue + filter_type = label.split(":")[1] + if filter_type not in _VALID_TEST_FILTER_TYPES: + raise ValueError( + f"Unrecognized test_filter value: {filter_type!r}. " + f"Valid values: {sorted(_VALID_TEST_FILTER_TYPES)}" + ) + return filter_type, f"test_filter label: {label}" - # Schedule runs the full nightly suite — comprehensive coverage on - # a cadence, catching regressions that quick tests miss. + # Priority 2: test:* labels request specific component tests (e.g. + # test:rocprim). When someone explicitly asks for tests, run the full + # suite — they're investigating something specific. + if _has_test_labels(ci_inputs): + return "full", "test labels specified" + + # Priority 3: schedule runs the full nightly suite — comprehensive + # coverage on a cadence, catching regressions that quick tests miss. if ci_inputs.is_schedule: - test_type = "comprehensive" - reason = "scheduled run" - elif ( + return "comprehensive", "scheduled run" + + # Priority 4: a submodule change means actual library code changed + # (e.g. rocBLAS, MIOpen). These need full testing since the change + # could affect any downstream consumer. + if ( git_context.changed_files is not None and git_context.submodule_paths is not None ): - # A submodule change means actual library code changed (e.g. - # rocBLAS, MIOpen). These need full testing since the change - # could affect any downstream consumer. matching = set(git_context.submodule_paths) & set(git_context.changed_files) if matching: - test_type = "full" - reason = f"submodule(s) changed: {sorted(matching)}" + return "full", f"submodule(s) changed: {sorted(matching)}" - # test:* labels request specific component tests (e.g. test:rocprim). - # When someone explicitly asks for tests, run the full suite for those - # components — they're investigating something specific. - if _has_test_labels(ci_inputs): - test_type = "full" - reason = "test labels specified" - - # test_filter: PR label gives developers manual override to any level. - # This is the escape hatch: run comprehensive on a PR before merge, - # or downgrade to quick if you know the change is safe. - for label in ci_inputs.pr_labels: - if not label.startswith("test_filter:"): - continue - filter_type = label.split(":")[1] - if filter_type in _VALID_TEST_FILTER_TYPES: - test_type = filter_type - reason = f"test_filter label: {label}" - break - print(f" Ignoring unrecognized test_filter value: {filter_type!r}") - - return test_type, reason + # Default: quick tests for fast CI feedback. + return "quick", "default" def decide_jobs( diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 01291b06301..59915f6de4c 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -298,13 +298,13 @@ def test_test_filter_label_overrides(self): ) self.assertEqual(result.test_rocm.test_type, "standard") - def test_test_filter_invalid_ignored(self): - """Unrecognized test_filter value is ignored.""" + def test_test_filter_invalid_raises(self): + """Unrecognized test_filter value raises ValueError.""" git = cm.GitContext(changed_files=["CMakeLists.txt"]) - result = cm.decide_jobs( - self._inputs(pr_labels=["test_filter:bogus"]), git_context=git - ) - self.assertEqual(result.test_rocm.test_type, "quick") + with self.assertRaises(ValueError, msg="Unrecognized test_filter"): + cm.decide_jobs( + self._inputs(pr_labels=["test_filter:bogus"]), git_context=git + ) def test_build_rocm_stage_partitioning(self): """BuildRocmDecision correctly partitions stages into prebuilt/rebuild.""" From 4aa7805319a51a3989b4f2072b0952d2b6aaba23 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 15:50:11 -0700 Subject: [PATCH 15/56] Add structured logging via log() methods on dataclasses Each pipeline dataclass (CIInputs, GitContext, TargetSelection, JobDecisions, BuildConfigs) now has a log() method for CI diagnostics. configure() calls them after each step, replacing ad-hoc prints in main() and step functions. Remove getattr loops in favor of explicit field access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 117 ++++++++++++++---- 1 file changed, 93 insertions(+), 24 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index dd0ef1eb182..29f677f51e6 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -104,6 +104,25 @@ class CIInputs: prebuilt_stages: str = "" baseline_run_id: str = "" + def log(self) -> None: + """Log parsed inputs for CI diagnostics.""" + print("CIInputs:") + print(f" event: {self.event_name}") + print(f" branch: {self.branch_name}") + print(f" variant: {self.build_variant}") + if self.pr_labels: + print(f" pr_labels: {self.pr_labels}") + if self.linux_amdgpu_families: + print(f" linux_amdgpu_families: {self.linux_amdgpu_families}") + if self.windows_amdgpu_families: + print(f" windows_amdgpu_families: {self.windows_amdgpu_families}") + if self.linux_test_labels: + print(f" linux_test_labels: {self.linux_test_labels}") + if self.windows_test_labels: + print(f" windows_test_labels: {self.windows_test_labels}") + if self.prebuilt_stages: + print(f" prebuilt_stages: {self.prebuilt_stages}") + @property def is_pull_request(self) -> bool: return self.event_name == "pull_request" @@ -207,6 +226,17 @@ def empty() -> "GitContext": """No git data (schedule/workflow_dispatch).""" return GitContext() + def log(self) -> None: + """Log git context for CI diagnostics.""" + if self.changed_files is None: + print("GitContext: no changed files (schedule/workflow_dispatch)") + return + print(f"GitContext: {len(self.changed_files)} changed file(s)") + for path in self.changed_files[:20]: + print(f" {path}") + if len(self.changed_files) > 20: + print(f" ... and {len(self.changed_files) - 20} more") + @dataclass(frozen=True) class SkipDecision: @@ -223,6 +253,12 @@ class TargetSelection: linux_families: list[str] = field(default_factory=list) windows_families: list[str] = field(default_factory=list) + def log(self) -> None: + """Log selected targets for CI diagnostics.""" + print("TargetSelection:") + print(f" linux: {self.linux_families}") + print(f" windows: {self.windows_families}") + # --------------------------------------------------------------------------- # Job decisions — the CI pipeline as a DAG of job groups @@ -300,6 +336,19 @@ class JobDecisions: build_pytorch: JobGroupDecision test_pytorch: JobGroupDecision + def log(self) -> None: + """Log job decisions for CI diagnostics.""" + print("JobDecisions:") + print( + f" test_type: {self.test_rocm.test_type} " + f"({self.test_rocm.test_type_reason})" + ) + print(f" build_rocm: {self.build_rocm.action}") + print(f" test_rocm: {self.test_rocm.action}") + print(f" build_rocm_python: {self.build_rocm_python.action}") + print(f" build_pytorch: {self.build_pytorch.action}") + print(f" test_pytorch: {self.test_pytorch.action}") + @dataclass(frozen=True) class BuildConfig: @@ -334,11 +383,26 @@ def to_dict(self) -> dict: @dataclass(frozen=True) class BuildConfigs: - """Build configurations for both platforms, produced by expand_matrices.""" + """Build configurations for both platforms, produced by expand_build_configs.""" linux: BuildConfig | None = None windows: BuildConfig | None = None + def _log_platform(self, name: str, config: BuildConfig | None) -> None: + if config is None: + print(f" {name}: skipped") + else: + print( + f" {name}: {config.artifact_group} " + f"families={config.dist_amdgpu_families}" + ) + + def log(self) -> None: + """Log build configs for CI diagnostics.""" + print("BuildConfigs:") + self._log_platform("linux", self.linux) + self._log_platform("windows", self.windows) + @dataclass(frozen=True) class CIOutputs: @@ -474,17 +538,17 @@ def decide_jobs( ) -> JobDecisions: """Determine which job groups to run, skip, or satisfy with prebuilt files. - All job groups currently run unconditionally — subgraph selection based - on changed files (Phase 4) will add skip/prebuilt decisions. + All job groups currently run unconditionally. test_type is determined + based on trigger type, labels, and changed files. - test_type is determined here based on trigger type, labels, and - changed files. + TODO(#3399): Use changed files and BUILD_TOPOLOGY.toml to set per-stage + prebuilt decisions in BuildRocmDecision.stage_decisions, and skip job + groups that aren't reachable from the changed files. """ test_type, test_type_reason = _determine_test_type( ci_inputs=ci_inputs, git_context=git_context, ) - print(f" test_type: {test_type} ({test_type_reason})") return JobDecisions( build_rocm=BuildRocmDecision(action="run", reason="default"), @@ -748,16 +812,23 @@ def format_summary(outputs: CIOutputs) -> str: lines.append("") lines.append(f"* `is_ci_enabled`: {outputs.is_ci_enabled}") if outputs.jobs: - lines.append(f"* `test_type`: {outputs.jobs.test_rocm.test_type}") - for name in ( - "build_rocm", - "test_rocm", - "build_rocm_python", - "build_pytorch", - "test_pytorch", - ): - decision = getattr(outputs.jobs, name) - lines.append(f"* `{name}`: {decision.action} — {decision.reason}") + jobs = outputs.jobs + lines.append(f"* `test_type`: {jobs.test_rocm.test_type}") + lines.append( + f"* `build_rocm`: {jobs.build_rocm.action} — {jobs.build_rocm.reason}" + ) + lines.append( + f"* `test_rocm`: {jobs.test_rocm.action} — {jobs.test_rocm.reason}" + ) + lines.append( + f"* `build_rocm_python`: {jobs.build_rocm_python.action} — {jobs.build_rocm_python.reason}" + ) + lines.append( + f"* `build_pytorch`: {jobs.build_pytorch.action} — {jobs.build_pytorch.reason}" + ) + lines.append( + f"* `test_pytorch`: {jobs.test_pytorch.action} — {jobs.test_pytorch.reason}" + ) return "\n".join(lines) @@ -794,6 +865,9 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: CIInputs and GitContext directly and assert on the returned CIOutputs. No git operations or environment access needed. """ + ci_inputs.log() + git_context.log() + # Step 2: Gate — should we skip CI entirely? skip_decision = check_skip_ci(ci_inputs=ci_inputs, git_context=git_context) if skip_decision.skip: @@ -803,13 +877,16 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: # Steps 3 and 4 are independent: job decisions (which job groups run) # and target selection (which GPU families) are orthogonal concerns. jobs = decide_jobs(ci_inputs=ci_inputs, git_context=git_context) + jobs.log() targets = select_targets(ci_inputs) + targets.log() # Step 5: Build configs per platform builds = expand_build_configs( targets=targets, build_variant=ci_inputs.build_variant, ) + builds.log() return CIOutputs( is_ci_enabled=True, @@ -826,14 +903,6 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: def main(): ci_inputs = CIInputs.from_environ() - print("Multi-arch CI configuration") - print(f" event: {ci_inputs.event_name}") - print(f" branch: {ci_inputs.branch_name}") - print(f" variant: {ci_inputs.build_variant}") - if ci_inputs.pr_labels: - print(f" pr_labels: {ci_inputs.pr_labels}") - print() - # Build git context for push/PR triggers (need changed files for # skip-ci and test_type decisions). Schedule/workflow_dispatch don't # need git data. From 2fe8c53467dd55505f3b0754c3b7e7da26ce1f36 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 16 Mar 2026 16:16:35 -0700 Subject: [PATCH 16/56] Wire configure_multi_arch_ci.py into multi_arch_ci.yml Add setup_multi_arch.yml: checkout, call configure_multi_arch_ci.py (reads GITHUB_EVENT_PATH directly, no env var pass-through chain), compute package version. Update multi_arch_ci.yml to call setup_multi_arch.yml instead of setup.yml. Replace matrix strategy with direct fromJSON property access on per-platform build config objects. Route prebuilt_stages and baseline_run_id through the job graph model (BuildRocmDecision.stage_decisions and .baseline_run_id) rather than as raw pass-through strings. write_outputs derives the output values from the structured decisions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/multi_arch_ci.yml | 56 ++++++------- .github/workflows/setup_multi_arch.yml | 82 +++++++++++++++++++ .../github_actions/configure_multi_arch_ci.py | 35 +++++++- .../tests/configure_multi_arch_ci_test.py | 21 +++++ 4 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/setup_multi_arch.yml diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 29fcc85b405..1909d329890 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -60,37 +60,31 @@ concurrency: jobs: setup: - uses: ./.github/workflows/setup.yml + uses: ./.github/workflows/setup_multi_arch.yml with: build_variant: "release" - multi_arch: true - prebuilt_stages: ${{ inputs.prebuilt_stages }} linux_build_and_test: - name: Linux::${{ matrix.variant.build_variant_label }} + name: Linux needs: setup if: >- ${{ - needs.setup.outputs.linux_variants != '[]' && + needs.setup.outputs.linux_build_enabled == 'true' && needs.setup.outputs.enable_build_jobs == 'true' }} - strategy: - fail-fast: false - matrix: - variant: ${{ fromJSON(needs.setup.outputs.linux_variants) }} uses: ./.github/workflows/multi_arch_ci_linux.yml secrets: inherit with: - matrix_per_family_json: ${{ matrix.variant.matrix_per_family_json }} - dist_amdgpu_families: ${{ matrix.variant.dist_amdgpu_families }} - artifact_group: ${{ matrix.variant.artifact_group }} - build_variant_label: ${{ matrix.variant.build_variant_label }} - build_variant_cmake_preset: ${{ matrix.variant.build_variant_cmake_preset }} - build_variant_suffix: ${{ matrix.variant.build_variant_suffix }} + matrix_per_family_json: ${{ fromJSON(needs.setup.outputs.linux_build_config).matrix_per_family_json }} + dist_amdgpu_families: ${{ fromJSON(needs.setup.outputs.linux_build_config).dist_amdgpu_families }} + artifact_group: ${{ fromJSON(needs.setup.outputs.linux_build_config).artifact_group }} + build_variant_label: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_label }} + build_variant_cmake_preset: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_cmake_preset }} + build_variant_suffix: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_suffix }} test_labels: ${{ needs.setup.outputs.linux_test_labels }} - expect_failure: ${{ matrix.variant.expect_failure == true }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} - baseline_run_id: ${{ inputs.baseline_run_id }} + expect_failure: ${{ fromJSON(needs.setup.outputs.linux_build_config).expect_failure == true }} + prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} + baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} build_pytorch: ${{ matrix.variant.build_pytorch == true }} @@ -99,30 +93,26 @@ jobs: id-token: write windows_build_and_test: - name: Windows::${{ matrix.variant.build_variant_label }} + name: Windows needs: setup if: >- ${{ - needs.setup.outputs.windows_variants != '[]' && + needs.setup.outputs.windows_build_enabled == 'true' && needs.setup.outputs.enable_build_jobs == 'true' }} - strategy: - fail-fast: false - matrix: - variant: ${{ fromJSON(needs.setup.outputs.windows_variants) }} uses: ./.github/workflows/multi_arch_ci_windows.yml secrets: inherit with: - matrix_per_family_json: ${{ matrix.variant.matrix_per_family_json }} - dist_amdgpu_families: ${{ matrix.variant.dist_amdgpu_families }} - artifact_group: ${{ matrix.variant.artifact_group }} - build_variant_label: ${{ matrix.variant.build_variant_label }} - build_variant_cmake_preset: ${{ matrix.variant.build_variant_cmake_preset }} - build_variant_suffix: ${{ matrix.variant.build_variant_suffix }} + matrix_per_family_json: ${{ fromJSON(needs.setup.outputs.windows_build_config).matrix_per_family_json }} + dist_amdgpu_families: ${{ fromJSON(needs.setup.outputs.windows_build_config).dist_amdgpu_families }} + artifact_group: ${{ fromJSON(needs.setup.outputs.windows_build_config).artifact_group }} + build_variant_label: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_label }} + build_variant_cmake_preset: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_cmake_preset }} + build_variant_suffix: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_suffix }} test_labels: ${{ needs.setup.outputs.windows_test_labels }} - expect_failure: ${{ matrix.variant.expect_failure == true }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} - baseline_run_id: ${{ inputs.baseline_run_id }} + expect_failure: ${{ fromJSON(needs.setup.outputs.windows_build_config).expect_failure == true }} + prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} + baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} build_pytorch: ${{ matrix.variant.build_pytorch == true }} diff --git a/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml new file mode 100644 index 00000000000..292dab71001 --- /dev/null +++ b/.github/workflows/setup_multi_arch.yml @@ -0,0 +1,82 @@ +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +name: Setup (Multi-Arch) + +on: + workflow_call: + inputs: + build_variant: + type: string + default: "release" + outputs: + enable_build_jobs: + description: Whether to enable build jobs. + value: ${{ jobs.setup.outputs.enable_build_jobs }} + linux_build_config: + description: JSON object with Linux build configuration, or empty string if skipped. + value: ${{ jobs.setup.outputs.linux_build_config }} + linux_build_enabled: + description: Whether Linux builds are enabled. + value: ${{ jobs.setup.outputs.linux_build_enabled }} + windows_build_config: + description: JSON object with Windows build configuration, or empty string if skipped. + value: ${{ jobs.setup.outputs.windows_build_config }} + windows_build_enabled: + description: Whether Windows builds are enabled. + value: ${{ jobs.setup.outputs.windows_build_enabled }} + test_type: + description: "The test type to run (quick, standard, comprehensive, full)." + value: ${{ jobs.setup.outputs.test_type }} + linux_test_labels: + description: ROCm projects to run Linux tests on. Optional filter. + value: ${{ jobs.setup.outputs.linux_test_labels }} + windows_test_labels: + description: ROCm projects to run Windows tests on. Optional filter. + value: ${{ jobs.setup.outputs.windows_test_labels }} + prebuilt_stages: + description: "Comma-separated build stages to use prebuilt artifacts for." + value: ${{ jobs.setup.outputs.prebuilt_stages }} + baseline_run_id: + description: "Workflow run ID to copy prebuilt stage artifacts from." + value: ${{ jobs.setup.outputs.baseline_run_id }} + rocm_package_version: + description: ROCm package version (primarily for Python packages). + value: ${{ jobs.setup.outputs.rocm_package_version }} + +permissions: + contents: read + +jobs: + setup: + runs-on: ubuntu-24.04 + outputs: + enable_build_jobs: ${{ steps.configure.outputs.enable_build_jobs }} + linux_build_config: ${{ steps.configure.outputs.linux_build_config }} + linux_build_enabled: ${{ steps.configure.outputs.linux_build_enabled }} + windows_build_config: ${{ steps.configure.outputs.windows_build_config }} + windows_build_enabled: ${{ steps.configure.outputs.windows_build_enabled }} + test_type: ${{ steps.configure.outputs.test_type }} + linux_test_labels: ${{ steps.configure.outputs.linux_test_labels }} + windows_test_labels: ${{ steps.configure.outputs.windows_test_labels }} + prebuilt_stages: ${{ steps.configure.outputs.prebuilt_stages }} + baseline_run_id: ${{ steps.configure.outputs.baseline_run_id }} + rocm_package_version: ${{ steps.rocm_package_version.outputs.rocm_package_version }} + steps: + - name: Checking out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # We need the parent commit to do a diff + fetch-depth: 2 + + # The script reads GITHUB_EVENT_PATH directly for PR labels and + # workflow_dispatch inputs — no env var pass-through needed. + - name: Configuring CI options + id: configure + env: + BUILD_VARIANT: ${{ inputs.build_variant }} + run: python ./build_tools/github_actions/configure_multi_arch_ci.py + + - name: Compute package version + id: rocm_package_version + run: python ./build_tools/compute_rocm_package_version.py --release-type=dev diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 29f677f51e6..ec4a6afd9e6 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -293,6 +293,10 @@ class BuildRocmDecision(JobGroupDecision): """Build-rocm job group with per-stage granularity.""" stage_decisions: dict[str, StageDecision] = field(default_factory=dict) + # Run ID to fetch prebuilt stage artifacts from. Currently passed through + # from workflow_dispatch input; TODO(#3399): derive automatically from + # the current commit's parent workflow run. + baseline_run_id: str = "" @property def prebuilt_stages(self) -> list[str]: @@ -411,6 +415,9 @@ class CIOutputs: is_ci_enabled: bool = True builds: BuildConfigs = field(default_factory=BuildConfigs) jobs: JobDecisions | None = None + # Test labels pass through from inputs to outputs for downstream workflows. + linux_test_labels: str = "" + windows_test_labels: str = "" @staticmethod def skipped(reason: str) -> "CIOutputs": @@ -550,8 +557,24 @@ def decide_jobs( git_context=git_context, ) + # Parse explicit prebuilt stages from workflow_dispatch input. + # TODO(#3399): Also derive prebuilt stages automatically from changed + # files and BUILD_TOPOLOGY.toml for pull_request triggers. + stage_decisions: dict[str, StageDecision] = {} + if ci_inputs.prebuilt_stages: + for stage in _parse_comma_list(ci_inputs.prebuilt_stages): + stage_decisions[stage] = StageDecision( + action="prebuilt", + reason="explicit workflow_dispatch input", + ) + return JobDecisions( - build_rocm=BuildRocmDecision(action="run", reason="default"), + build_rocm=BuildRocmDecision( + action="run", + reason="default", + stage_decisions=stage_decisions, + baseline_run_id=ci_inputs.baseline_run_id, + ), test_rocm=TestRocmDecision( action="run", reason="default", @@ -848,6 +871,14 @@ def write_outputs(outputs: CIOutputs) -> None: # Workflow YAML references this as 'enable_build_jobs' "enable_build_jobs": json.dumps(outputs.is_ci_enabled), "test_type": test_type, + "linux_test_labels": outputs.linux_test_labels, + "windows_test_labels": outputs.windows_test_labels, + "prebuilt_stages": ",".join( + outputs.jobs.build_rocm.prebuilt_stages if outputs.jobs else [] + ), + "baseline_run_id": ( + outputs.jobs.build_rocm.baseline_run_id if outputs.jobs else "" + ), } gha_set_output(output_vars) gha_append_step_summary(format_summary(outputs)) @@ -892,6 +923,8 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: is_ci_enabled=True, builds=builds, jobs=jobs, + linux_test_labels=ci_inputs.linux_test_labels, + windows_test_labels=ci_inputs.windows_test_labels, ) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 59915f6de4c..3105a30ab99 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -306,6 +306,27 @@ def test_test_filter_invalid_raises(self): self._inputs(pr_labels=["test_filter:bogus"]), git_context=git ) + def test_explicit_prebuilt_stages(self): + """workflow_dispatch prebuilt_stages input → stage_decisions on BuildRocmDecision.""" + result = cm.decide_jobs( + self._inputs( + event_name="workflow_dispatch", + prebuilt_stages="foundation,compiler-runtime", + ), + git_context=cm.GitContext(), + ) + self.assertEqual( + sorted(result.build_rocm.prebuilt_stages), + ["compiler-runtime", "foundation"], + ) + self.assertEqual(result.build_rocm.rebuild_stages, []) + + def test_no_prebuilt_stages_by_default(self): + """Without explicit prebuilt_stages, no stage decisions are set.""" + result = cm.decide_jobs(self._inputs(), git_context=cm.GitContext()) + self.assertEqual(result.build_rocm.prebuilt_stages, []) + self.assertEqual(result.build_rocm.stage_decisions, {}) + def test_build_rocm_stage_partitioning(self): """BuildRocmDecision correctly partitions stages into prebuilt/rebuild.""" decision = cm.BuildRocmDecision( From 69928a4259acac159e5d50cb0719a10fde4ae7b0 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 11:52:16 -0700 Subject: [PATCH 17/56] Pre-PR cleanup: fix docstring, sys.exit, and variable naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix stale test_type values in module docstring (smoke/full → quick/ standard/comprehensive/full) - Replace sys.exit(1) with raise RuntimeError in from_environ() - Rename lookup_matrix → all_families in _filter_families_by_platform for consistency with the rest of the file - Remove unused sys import Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index ec4a6afd9e6..eec6545e1be 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -41,12 +41,11 @@ linux_build_enabled : "true" or "false" windows_build_enabled : "true" or "false" enable_build_jobs : "true" or "false" - test_type : "smoke" or "full" + test_type : "quick", "standard", "comprehensive", or "full" """ import json import os -import sys from dataclasses import dataclass, field from pathlib import Path from typing import Literal @@ -150,11 +149,7 @@ def from_environ() -> "CIInputs": event_name = os.environ.get("GITHUB_EVENT_NAME", "") branch_name = os.environ.get("GITHUB_REF_NAME", "") if not branch_name: - print( - "[ERROR] GITHUB_REF_NAME is not set. Exiting.", - file=sys.stderr, - ) - sys.exit(1) + raise RuntimeError("GITHUB_REF_NAME is not set.") # Read the full event payload event_path = os.environ.get("GITHUB_EVENT_PATH", "") @@ -608,13 +603,13 @@ def _validate_family_names( def _filter_families_by_platform( family_names: list[str], platform: str, - lookup_matrix: dict[str, dict], + all_families: dict[str, dict], ) -> list[str]: """Return only the family names that have an entry for the given platform.""" return [ name for name in family_names - if name in lookup_matrix and platform in lookup_matrix[name] + if name in all_families and platform in all_families[name] ] From be23fa5afdca1f4978f9aceba429852567153ab8 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 13:16:25 -0700 Subject: [PATCH 18/56] Sanitize some inputs with `.lower()`. --- build_tools/github_actions/configure_multi_arch_ci.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index eec6545e1be..3e632582e60 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -64,11 +64,11 @@ def _parse_comma_list(raw: str) -> list[str]: - """Parse a comma-separated string into a list of stripped, non-empty names. + """Parse a comma-separated string into a list of stripped, lowercased, non-empty names. - Example: "gfx94X, gfx120X" → ["gfx94X", "gfx120X"] + Example: "gfx94X, gfx120X" → ["gfx94x", "gfx120x"] """ - return [name.strip() for name in raw.split(",") if name.strip()] + return [name.strip().lower() for name in raw.split(",") if name.strip()] # --------------------------------------------------------------------------- @@ -166,7 +166,7 @@ def from_environ() -> "CIInputs": if event_name == "pull_request": pr_obj = event.get("pull_request", {}) - pr_labels = [label["name"] for label in pr_obj.get("labels", [])] + pr_labels = [label["name"].lower() for label in pr_obj.get("labels", [])] # The merge commit's first parent is the PR base base_ref = "HEAD^" elif event_name == "push": From 2cb9bfb00c1d02eb98196cf6c9bdc228fa3e4317 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 13:26:14 -0700 Subject: [PATCH 19/56] Normalize inputs to lowercase at parse boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowercase family names in _parse_comma_list and PR labels in from_environ(). Matrix keys are lowercase (gfx94x) but workflow_dispatch descriptions show uppercase (gfx94X) — normalizing at the input boundary so internal functions don't need to care. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/tests/configure_multi_arch_ci_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 3105a30ab99..3352ebfae57 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -118,7 +118,7 @@ def test_workflow_dispatch_reads_inputs(self): } }, ) - self.assertEqual(inputs.linux_amdgpu_families, ["gfx94X", "gfx120X"]) + self.assertEqual(inputs.linux_amdgpu_families, ["gfx94x", "gfx120x"]) self.assertEqual(inputs.linux_test_labels, "test:rocprim") self.assertEqual(inputs.prebuilt_stages, "foundation,compiler-runtime") self.assertEqual(inputs.baseline_run_id, "12345") From d1a1854a4235bf7893a3ca0a73a72cc97fb6a3a1 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 13:41:47 -0700 Subject: [PATCH 20/56] Improve format_summary with platform table and job group decisions Replace the minimal bullet list with structured markdown: test type with reason, platform build config table (families, variant, artifact group), job group decision table, and prebuilt stages when set. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 73 ++++++++++++++----- .../tests/configure_multi_arch_ci_test.py | 13 +--- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 3e632582e60..1b9b00d838c 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -823,30 +823,63 @@ def expand_build_configs( # --------------------------------------------------------------------------- +def _format_build_config_rows(label: str, config: BuildConfig | None) -> str: + """Format one platform's build config as a markdown table row.""" + if config is None: + return f"| {label} | — | — | — |" + families = config.dist_amdgpu_families.replace(";", ", ") + variant = config.build_variant_label + if config.expect_failure: + variant += " (expect failure)" + return f"| {label} | {families} | {variant} | {config.artifact_group} |" + + def format_summary(outputs: CIOutputs) -> str: - """Generate human-readable markdown summary. Pure function.""" - # TODO: Implement — structured markdown with families, stages, reasons - lines = ["## Multi-Arch CI Configuration"] + """Generate human-readable markdown summary for GITHUB_STEP_SUMMARY.""" + lines = ["## Multi-Arch CI Configuration", ""] + + if not outputs.is_ci_enabled: + lines.append("**CI skipped.**") + return "\n".join(lines) + + if not outputs.jobs: + return "\n".join(lines) + + jobs = outputs.jobs + + # Test type + lines.append( + f"**Test type:** `{jobs.test_rocm.test_type}` " + f"({jobs.test_rocm.test_type_reason})" + ) lines.append("") - lines.append(f"* `is_ci_enabled`: {outputs.is_ci_enabled}") - if outputs.jobs: - jobs = outputs.jobs - lines.append(f"* `test_type`: {jobs.test_rocm.test_type}") - lines.append( - f"* `build_rocm`: {jobs.build_rocm.action} — {jobs.build_rocm.reason}" - ) - lines.append( - f"* `test_rocm`: {jobs.test_rocm.action} — {jobs.test_rocm.reason}" - ) - lines.append( - f"* `build_rocm_python`: {jobs.build_rocm_python.action} — {jobs.build_rocm_python.reason}" - ) - lines.append( - f"* `build_pytorch`: {jobs.build_pytorch.action} — {jobs.build_pytorch.reason}" - ) + + # Platform build configs + lines.append("| Platform | Families | Variant | Artifact Group |") + lines.append("|----------|----------|---------|----------------|") + lines.append(_format_build_config_rows("Linux", outputs.builds.linux)) + lines.append(_format_build_config_rows("Windows", outputs.builds.windows)) + lines.append("") + + # Job group decisions + lines.append("| Job Group | Decision |") + lines.append("|-----------|----------|") + lines.append(f"| build-rocm | {jobs.build_rocm.action} |") + lines.append(f"| test-rocm | {jobs.test_rocm.action} |") + lines.append(f"| build-rocm-python | {jobs.build_rocm_python.action} |") + lines.append(f"| build-pytorch | {jobs.build_pytorch.action} |") + lines.append(f"| test-pytorch | {jobs.test_pytorch.action} |") + lines.append("") + + # Prebuilt stages + prebuilt = jobs.build_rocm.prebuilt_stages + if prebuilt: lines.append( - f"* `test_pytorch`: {jobs.test_pytorch.action} — {jobs.test_pytorch.reason}" + f"**Prebuilt stages:** {', '.join(prebuilt)} " + f"(baseline run: `{jobs.build_rocm.baseline_run_id or 'none'}`)" ) + lines.append("") + return "\n".join(lines) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 3352ebfae57..6f32366b580 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -728,13 +728,11 @@ def test_output_matches_generate_multi_arch_matrix(self): class TestFormatSummary(unittest.TestCase): """Test summary formatting (pure function).""" - def test_skipped_summary(self): + def test_skipped_summary_does_not_raise(self): outputs = cm.CIOutputs.skipped("only .md files changed") - summary = cm.format_summary(outputs) - self.assertIn("is_ci_enabled", summary) - self.assertIn("False", summary) + cm.format_summary(outputs) - def test_normal_summary(self): + def test_normal_summary_does_not_raise(self): jobs = cm.JobDecisions( build_rocm=cm.BuildRocmDecision(action="run", reason="default"), test_rocm=cm.TestRocmDecision( @@ -745,10 +743,7 @@ def test_normal_summary(self): test_pytorch=cm.JobGroupDecision(action="run", reason="default"), ) outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) - summary = cm.format_summary(outputs) - self.assertIn("True", summary) - self.assertIn("full", summary) - self.assertIn("build_rocm", summary) + cm.format_summary(outputs) # --------------------------------------------------------------------------- From a1bc352285c1988023789f7cf7e3393d6bf1371a Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 15:28:12 -0700 Subject: [PATCH 21/56] Extract format_summary into configure_multi_arch_ci_summary.py Rich step summary with DAG visualization, per-family test runner table, build-pytorch details, and non-default configuration callouts. Moved to a separate file to keep the main script focused on pipeline logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 81 ++---- .../configure_multi_arch_ci_summary.py | 249 ++++++++++++++++++ .../tests/configure_multi_arch_ci_test.py | 19 +- 3 files changed, 284 insertions(+), 65 deletions(-) create mode 100644 build_tools/github_actions/configure_multi_arch_ci_summary.py diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 1b9b00d838c..10a82cc559a 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -823,67 +823,11 @@ def expand_build_configs( # --------------------------------------------------------------------------- -def _format_build_config_rows(label: str, config: BuildConfig | None) -> str: - """Format one platform's build config as a markdown table row.""" - if config is None: - return f"| {label} | — | — | — |" - families = config.dist_amdgpu_families.replace(";", ", ") - variant = config.build_variant_label - if config.expect_failure: - variant += " (expect failure)" - return f"| {label} | {families} | {variant} | {config.artifact_group} |" - - -def format_summary(outputs: CIOutputs) -> str: - """Generate human-readable markdown summary for GITHUB_STEP_SUMMARY.""" - lines = ["## Multi-Arch CI Configuration", ""] - - if not outputs.is_ci_enabled: - lines.append("**CI skipped.**") - return "\n".join(lines) - - if not outputs.jobs: - return "\n".join(lines) - - jobs = outputs.jobs - - # Test type - lines.append( - f"**Test type:** `{jobs.test_rocm.test_type}` " - f"({jobs.test_rocm.test_type_reason})" - ) - lines.append("") - - # Platform build configs - lines.append("| Platform | Families | Variant | Artifact Group |") - lines.append("|----------|----------|---------|----------------|") - lines.append(_format_build_config_rows("Linux", outputs.builds.linux)) - lines.append(_format_build_config_rows("Windows", outputs.builds.windows)) - lines.append("") - - # Job group decisions - lines.append("| Job Group | Decision |") - lines.append("|-----------|----------|") - lines.append(f"| build-rocm | {jobs.build_rocm.action} |") - lines.append(f"| test-rocm | {jobs.test_rocm.action} |") - lines.append(f"| build-rocm-python | {jobs.build_rocm_python.action} |") - lines.append(f"| build-pytorch | {jobs.build_pytorch.action} |") - lines.append(f"| test-pytorch | {jobs.test_pytorch.action} |") - lines.append("") - - # Prebuilt stages - prebuilt = jobs.build_rocm.prebuilt_stages - if prebuilt: - lines.append( - f"**Prebuilt stages:** {', '.join(prebuilt)} " - f"(baseline run: `{jobs.build_rocm.baseline_run_id or 'none'}`)" - ) - lines.append("") - - return "\n".join(lines) - - -def write_outputs(outputs: CIOutputs) -> None: +def write_outputs( + ci_inputs: CIInputs, + git_context: GitContext, + outputs: CIOutputs, +) -> None: """Write results to GITHUB_OUTPUT and GITHUB_STEP_SUMMARY. This is the only function with side effects (besides from_environ). @@ -909,7 +853,18 @@ def write_outputs(outputs: CIOutputs) -> None: ), } gha_set_output(output_vars) - gha_append_step_summary(format_summary(outputs)) + + # Lazy import: configure_multi_arch_ci_summary imports types from this + # module, so importing it at the top level would create a circular import. + from configure_multi_arch_ci_summary import format_summary + + gha_append_step_summary( + format_summary( + ci_inputs=ci_inputs, + git_context=git_context, + outputs=outputs, + ) + ) # --------------------------------------------------------------------------- @@ -973,7 +928,7 @@ def main(): git_context = GitContext.empty() outputs = configure(ci_inputs, git_context) - write_outputs(outputs) + write_outputs(ci_inputs=ci_inputs, git_context=git_context, outputs=outputs) if __name__ == "__main__": diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py new file mode 100644 index 00000000000..81b18f339ee --- /dev/null +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -0,0 +1,249 @@ +"""Formats the GITHUB_STEP_SUMMARY markdown for configure_multi_arch_ci.py. + +Produces human-readable markdown explaining what CI will do and why. +See reviews/summary_format_v5.md in the claude-rocm-workspace for the +design rationale and example outputs. +""" + +import json + +from configure_multi_arch_ci import ( + BuildConfig, + CIInputs, + CIOutputs, + GitContext, +) + +_DAG = """\ +``` +build-rocm ──┬── test-rocm + └── build-rocm-python ── build-pytorch +```""" + +_PATH_FILTERS_URL = ( + "https://github.com/ROCm/TheRock/blob/main/" + "build_tools/github_actions/configure_ci_path_filters.py" +) + + +def format_summary( + ci_inputs: CIInputs, + git_context: GitContext, + outputs: CIOutputs, +) -> str: + """Generate the full step summary markdown.""" + lines = ["## Multi-Arch CI Configuration", ""] + + if not outputs.is_ci_enabled: + return _format_skipped(lines, git_context) + + if not outputs.jobs: + return "\n".join(lines) + + # One-liner: trigger, branch, variant + lines.append( + f"{ci_inputs.event_name.replace('_', ' ').title()} " + f"to `{ci_inputs.branch_name}`, `{ci_inputs.build_variant}` variant." + ) + lines.append("") + + # Non-default callout + callouts = _non_default_callouts(ci_inputs, outputs) + if callouts: + lines.append("> **Non-default configuration:**") + for callout in callouts: + lines.append(f"> - {callout}") + lines.append("") + + # Fixed DAG + lines.append(_DAG) + lines.append("") + + # build-rocm + lines.append("### build-rocm") + lines.append("") + _append_build_rocm(lines, outputs, ci_inputs.build_variant) + + # test-rocm + lines.append("### test-rocm") + lines.append("") + _append_test_rocm(lines, outputs) + + # build-pytorch + lines.append("### build-pytorch") + lines.append("") + _append_build_pytorch(lines, outputs) + + return "\n".join(lines) + + +def _format_skipped(lines: list[str], git_context: GitContext) -> str: + lines.append( + f"CI was **skipped**: no CI-relevant files changed " + f"(see [configure_ci_path_filters.py]({_PATH_FILTERS_URL}) " + f"for skip patterns)." + ) + if git_context.changed_files: + lines.append("") + lines.append("Changed files:") + lines.append("```") + for path in git_context.changed_files: + lines.append(path) + lines.append("```") + return "\n".join(lines) + + +def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: + callouts: list[str] = [] + jobs = outputs.jobs + + # Explicit family selection (workflow_dispatch) + if ci_inputs.is_workflow_dispatch: + if ci_inputs.linux_amdgpu_families or ci_inputs.windows_amdgpu_families: + parts = [] + if ci_inputs.linux_amdgpu_families: + fams = ", ".join(f"`{f}`" for f in ci_inputs.linux_amdgpu_families) + parts.append(f"Linux ({fams})") + if ci_inputs.windows_amdgpu_families: + fams = ", ".join(f"`{f}`" for f in ci_inputs.windows_amdgpu_families) + parts.append(f"Windows ({fams})") + callouts.append(f"Explicit family selection: {', '.join(parts)}") + + # PR labels that affect behavior + for label in ci_inputs.pr_labels: + if label.startswith("gfx"): + callouts.append( + f"Label `{label}`: added family `{label}` " + f"(not in default presubmit set)" + ) + elif label.startswith("test_filter:"): + callouts.append( + f"Label `{label}`: overrode test level " f"(default would be `quick`)" + ) + elif label.startswith("test:"): + callouts.append(f"Label `{label}`: requested component tests") + + # Prebuilt stages + if jobs and jobs.build_rocm.prebuilt_stages: + stages = ", ".join(f"`{s}`" for s in jobs.build_rocm.prebuilt_stages) + run_id = jobs.build_rocm.baseline_run_id + repo = _repo_slug() + callouts.append( + f"Prebuilt stages: {stages} from run " + f"[{run_id}](https://github.com/{repo}/actions/runs/{run_id})" + ) + + return callouts + + +def _append_build_rocm( + lines: list[str], outputs: CIOutputs, build_variant: str +) -> None: + jobs = outputs.jobs + + # Prebuilt info + prebuilt = jobs.build_rocm.prebuilt_stages + if prebuilt: + stage_list = ", ".join(prebuilt) + run_id = jobs.build_rocm.baseline_run_id + repo = _repo_slug() + lines.append( + f"Using prebuilt artifacts for stages: `[{stage_list}]` " + f"from run [{run_id}]" + f"(https://github.com/{repo}/actions/runs/{run_id}). " + f"Remaining stages build from source." + ) + else: + lines.append("Building all stages from source.") + lines.append("") + + # Platform table + lines.append("| Platform | Families | Artifact Group |") + lines.append("|----------|----------|----------------|") + for platform, config in [ + ("Linux", outputs.builds.linux), + ("Windows", outputs.builds.windows), + ]: + if config is None: + lines.append(f"| {platform} | — (no `{build_variant}` config) | — |") + else: + families = ", ".join( + f"`{f}`" for f in config.dist_amdgpu_families.split(";") + ) + lines.append(f"| {platform} | {families} | `{config.artifact_group}` |") + lines.append("") + + +def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: + jobs = outputs.jobs + test_rocm = jobs.test_rocm + + lines.append( + f"Test level: **{test_rocm.test_type}** ({test_rocm.test_type_reason})." + ) + + # Component test labels + test_labels = [] + if outputs.linux_test_labels: + test_labels.append(outputs.linux_test_labels) + if outputs.windows_test_labels: + test_labels.append(outputs.windows_test_labels) + if test_labels: + labels_str = ", ".join(f"`{t}`" for t in test_labels) + lines.append(f"Component tests: {labels_str}.") + lines.append("") + + # Per-family test runner table + lines.append("| Platform | Family | Runner | Scope |") + lines.append("|----------|--------|--------|-------|") + for platform, config in [ + ("Linux", outputs.builds.linux), + ("Windows", outputs.builds.windows), + ]: + if config is None: + continue + per_family = json.loads(config.matrix_per_family_json) + for entry in per_family: + family = f"`{entry['amdgpu_family']}`" + runner = f"`{entry['test-runs-on']}`" if entry["test-runs-on"] else "—" + if entry.get("sanity_check_only_for_family"): + scope = "sanity check only" + else: + scope = test_rocm.test_type + lines.append(f"| {platform} | {family} | {runner} | {scope} |") + lines.append("") + + +def _append_build_pytorch(lines: list[str], outputs: CIOutputs) -> None: + linux = outputs.builds.linux + windows = outputs.builds.windows + + if linux and not linux.build_pytorch and windows and not windows.build_pytorch: + lines.append(f"Not building pytorch (`{linux.build_variant_label}` variant).") + lines.append("") + return + + has_any = False + lines.append("| Platform | Families |") + lines.append("|----------|----------|") + for platform, config in [("Linux", linux), ("Windows", windows)]: + if config is None or not config.build_pytorch: + continue + families = ", ".join(f"`{f}`" for f in config.dist_amdgpu_families.split(";")) + lines.append(f"| {platform} | {families} |") + has_any = True + + if not has_any: + # Remove the empty table header + lines.pop() + lines.pop() + lines.append("Not building pytorch.") + + lines.append("") + + +def _repo_slug() -> str: + """Return OWNER/REPO from GITHUB_REPOSITORY, or a placeholder.""" + import os + + return os.environ.get("GITHUB_REPOSITORY", "ROCm/TheRock") diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 6f32366b580..b427dc14107 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -728,11 +728,26 @@ def test_output_matches_generate_multi_arch_matrix(self): class TestFormatSummary(unittest.TestCase): """Test summary formatting (pure function).""" + def _inputs(self, **kwargs): + defaults = dict( + event_name="push", + branch_name="main", + base_ref="HEAD^1", + build_variant="release", + ) + defaults.update(kwargs) + return cm.CIInputs(**defaults) + def test_skipped_summary_does_not_raise(self): + from configure_multi_arch_ci_summary import format_summary + outputs = cm.CIOutputs.skipped("only .md files changed") - cm.format_summary(outputs) + git = cm.GitContext(changed_files=["docs/README.md"]) + format_summary(self._inputs(), git, outputs) def test_normal_summary_does_not_raise(self): + from configure_multi_arch_ci_summary import format_summary + jobs = cm.JobDecisions( build_rocm=cm.BuildRocmDecision(action="run", reason="default"), test_rocm=cm.TestRocmDecision( @@ -743,7 +758,7 @@ def test_normal_summary_does_not_raise(self): test_pytorch=cm.JobGroupDecision(action="run", reason="default"), ) outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) - cm.format_summary(outputs) + format_summary(self._inputs(), cm.GitContext(), outputs) # --------------------------------------------------------------------------- From 994155b0fb83b25a43ce8aa91e057e5106f7cbed Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 15:41:24 -0700 Subject: [PATCH 22/56] Polish step summary: alert syntax, mechanical trigger line, remove pytorch - Use GitHub alert syntax (> [!NOTE]) for non-default callouts - Mechanical trigger line: "Trigger: `event` on `branch`, `variant` variant." - "Runner Label" column header in test-rocm table - Add "# Build graph" comment to DAG - Remove build-pytorch section (not enough detail yet to be useful) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../configure_multi_arch_ci_summary.py | 43 +++---------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 81b18f339ee..499926d3229 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -16,6 +16,7 @@ _DAG = """\ ``` +# Build graph build-rocm ──┬── test-rocm └── build-rocm-python ── build-pytorch ```""" @@ -42,14 +43,15 @@ def format_summary( # One-liner: trigger, branch, variant lines.append( - f"{ci_inputs.event_name.replace('_', ' ').title()} " - f"to `{ci_inputs.branch_name}`, `{ci_inputs.build_variant}` variant." + f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.branch_name}`, " + f"`{ci_inputs.build_variant}` variant." ) lines.append("") # Non-default callout callouts = _non_default_callouts(ci_inputs, outputs) if callouts: + lines.append("> [!NOTE]") lines.append("> **Non-default configuration:**") for callout in callouts: lines.append(f"> - {callout}") @@ -69,11 +71,6 @@ def format_summary( lines.append("") _append_test_rocm(lines, outputs) - # build-pytorch - lines.append("### build-pytorch") - lines.append("") - _append_build_pytorch(lines, outputs) - return "\n".join(lines) @@ -194,8 +191,8 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: lines.append("") # Per-family test runner table - lines.append("| Platform | Family | Runner | Scope |") - lines.append("|----------|--------|--------|-------|") + lines.append("| Platform | Family | Runner Label | Scope |") + lines.append("|----------|--------|--------------|-------|") for platform, config in [ ("Linux", outputs.builds.linux), ("Windows", outputs.builds.windows), @@ -214,34 +211,6 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: lines.append("") -def _append_build_pytorch(lines: list[str], outputs: CIOutputs) -> None: - linux = outputs.builds.linux - windows = outputs.builds.windows - - if linux and not linux.build_pytorch and windows and not windows.build_pytorch: - lines.append(f"Not building pytorch (`{linux.build_variant_label}` variant).") - lines.append("") - return - - has_any = False - lines.append("| Platform | Families |") - lines.append("|----------|----------|") - for platform, config in [("Linux", linux), ("Windows", windows)]: - if config is None or not config.build_pytorch: - continue - families = ", ".join(f"`{f}`" for f in config.dist_amdgpu_families.split(";")) - lines.append(f"| {platform} | {families} |") - has_any = True - - if not has_any: - # Remove the empty table header - lines.pop() - lines.pop() - lines.append("Not building pytorch.") - - lines.append("") - - def _repo_slug() -> str: """Return OWNER/REPO from GITHUB_REPOSITORY, or a placeholder.""" import os From 4d314b1babe756b9c8251a7e41cae1b83e224e2a Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 15:58:15 -0700 Subject: [PATCH 23/56] Formatting cleanup --- .../configure_multi_arch_ci_summary.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 499926d3229..c6a619eba80 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -8,7 +8,6 @@ import json from configure_multi_arch_ci import ( - BuildConfig, CIInputs, CIOutputs, GitContext, @@ -43,7 +42,7 @@ def format_summary( # One-liner: trigger, branch, variant lines.append( - f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.branch_name}`, " + f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.branch_name}` branch, " f"`{ci_inputs.build_variant}` variant." ) lines.append("") @@ -99,12 +98,12 @@ def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: if ci_inputs.linux_amdgpu_families or ci_inputs.windows_amdgpu_families: parts = [] if ci_inputs.linux_amdgpu_families: - fams = ", ".join(f"`{f}`" for f in ci_inputs.linux_amdgpu_families) - parts.append(f"Linux ({fams})") + fams = ", ".join(ci_inputs.linux_amdgpu_families) + parts.append(f"Linux: `[{fams}]`") if ci_inputs.windows_amdgpu_families: - fams = ", ".join(f"`{f}`" for f in ci_inputs.windows_amdgpu_families) - parts.append(f"Windows ({fams})") - callouts.append(f"Explicit family selection: {', '.join(parts)}") + fams = ", ".join(ci_inputs.windows_amdgpu_families) + parts.append(f"Windows: `[{fams}]`") + callouts.append(f"Explicit family selection — {', '.join(parts)}") # PR labels that affect behavior for label in ci_inputs.pr_labels: @@ -122,11 +121,11 @@ def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: # Prebuilt stages if jobs and jobs.build_rocm.prebuilt_stages: - stages = ", ".join(f"`{s}`" for s in jobs.build_rocm.prebuilt_stages) + stage_list = ", ".join(jobs.build_rocm.prebuilt_stages) run_id = jobs.build_rocm.baseline_run_id repo = _repo_slug() callouts.append( - f"Prebuilt stages: {stages} from run " + f"Prebuilt stages: `[{stage_list}]` from run " f"[{run_id}](https://github.com/{repo}/actions/runs/{run_id})" ) @@ -176,7 +175,7 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: test_rocm = jobs.test_rocm lines.append( - f"Test level: **{test_rocm.test_type}** ({test_rocm.test_type_reason})." + f"Test level: **{test_rocm.test_type}** ({test_rocm.test_type_reason})" ) # Component test labels @@ -187,7 +186,7 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: test_labels.append(outputs.windows_test_labels) if test_labels: labels_str = ", ".join(f"`{t}`" for t in test_labels) - lines.append(f"Component tests: {labels_str}.") + lines.append(f"Component tests: {labels_str}") lines.append("") # Per-family test runner table From 027ed0c01f17a10174a8c954a4192a0dd6e9c124 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 16:02:09 -0700 Subject: [PATCH 24/56] Fix summary output with default/empty workflow_dispatch inputs --- .../github_actions/configure_multi_arch_ci_summary.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index c6a619eba80..9b5e2fb4ff0 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -47,6 +47,11 @@ def format_summary( ) lines.append("") + # Nothing to build (e.g. workflow_dispatch with no families selected) + if outputs.builds.linux is None and outputs.builds.windows is None: + lines.append("No GPU families selected — nothing to build or test.") + return "\n".join(lines) + # Non-default callout callouts = _non_default_callouts(ci_inputs, outputs) if callouts: @@ -161,7 +166,7 @@ def _append_build_rocm( ("Windows", outputs.builds.windows), ]: if config is None: - lines.append(f"| {platform} | — (no `{build_variant}` config) | — |") + lines.append(f"| {platform} | — | — |") else: families = ", ".join( f"`{f}`" for f in config.dist_amdgpu_families.split(";") From 05cdf8470a1903988a5967f5a4723fc4cfad8c92 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 17 Mar 2026 16:18:18 -0700 Subject: [PATCH 25/56] Improve logging: phase headers, always print all fields, skip context Add === phase headers === to configure() so logs read as a narrative. Print all CIInputs fields unconditionally (empty values are informative). Add context to check_skip_ci ("Checking N files against path filters"). Handle empty-families case in summary ("No GPU families selected"). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../github_actions/configure_multi_arch_ci.py | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 10a82cc559a..ffe6aec6298 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -109,18 +109,13 @@ def log(self) -> None: print(f" event: {self.event_name}") print(f" branch: {self.branch_name}") print(f" variant: {self.build_variant}") - if self.pr_labels: - print(f" pr_labels: {self.pr_labels}") - if self.linux_amdgpu_families: - print(f" linux_amdgpu_families: {self.linux_amdgpu_families}") - if self.windows_amdgpu_families: - print(f" windows_amdgpu_families: {self.windows_amdgpu_families}") - if self.linux_test_labels: - print(f" linux_test_labels: {self.linux_test_labels}") - if self.windows_test_labels: - print(f" windows_test_labels: {self.windows_test_labels}") - if self.prebuilt_stages: - print(f" prebuilt_stages: {self.prebuilt_stages}") + print(f" pr_labels: {self.pr_labels}") + print(f" linux_amdgpu_families: {self.linux_amdgpu_families}") + print(f" windows_amdgpu_families: {self.windows_amdgpu_families}") + print(f" linux_test_labels: {self.linux_test_labels!r}") + print(f" windows_test_labels: {self.windows_test_labels!r}") + print(f" prebuilt_stages: {self.prebuilt_stages!r}") + print(f" baseline_run_id: {self.baseline_run_id!r}") @property def is_pull_request(self) -> bool: @@ -440,13 +435,17 @@ def check_skip_ci( for those triggers, and they have no PR labels). """ if "skip-ci" in ci_inputs.pr_labels: + print(" Found 'skip-ci' PR label") return SkipDecision(skip=True, reason="skip-ci label") # changed_files is None for schedule/workflow_dispatch — always proceed. - if git_context.changed_files is not None and not is_ci_run_required( - git_context.changed_files - ): - return SkipDecision(skip=True, reason="no CI-relevant files changed") + if git_context.changed_files is not None: + print( + f" Checking {len(git_context.changed_files)} changed file(s) " + f"against path filters..." + ) + if not is_ci_run_required(git_context.changed_files): + return SkipDecision(skip=True, reason="no CI-relevant files changed") return SkipDecision(skip=False, reason="") @@ -879,23 +878,26 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: CIInputs and GitContext directly and assert on the returned CIOutputs. No git operations or environment access needed. """ + print("=== Inputs ===") ci_inputs.log() git_context.log() - # Step 2: Gate — should we skip CI entirely? + print("\n=== Checking if CI should run ===") skip_decision = check_skip_ci(ci_inputs=ci_inputs, git_context=git_context) if skip_decision.skip: - print(f"Skipping CI: {skip_decision.reason}") + print(f"Result: skipping CI ({skip_decision.reason})") return CIOutputs.skipped(skip_decision.reason) + print("Result: CI will run") - # Steps 3 and 4 are independent: job decisions (which job groups run) - # and target selection (which GPU families) are orthogonal concerns. + print("\n=== Deciding job configuration ===") jobs = decide_jobs(ci_inputs=ci_inputs, git_context=git_context) jobs.log() + + print("\n=== Selecting GPU target families ===") targets = select_targets(ci_inputs) targets.log() - # Step 5: Build configs per platform + print("\n=== Building per-platform configs ===") builds = expand_build_configs( targets=targets, build_variant=ci_inputs.build_variant, From cabdc8e85022f602c78a5ed8a35f696aee22413e Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 18 Mar 2026 15:04:06 -0700 Subject: [PATCH 26/56] Adapt to ci label changes --- .../github_actions/configure_multi_arch_ci.py | 20 +++++++++---------- .../tests/configure_multi_arch_ci_test.py | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index ffe6aec6298..3ed986b31f9 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -233,7 +233,7 @@ class SkipDecision: """Whether to skip CI entirely.""" skip: bool - reason: str # e.g. "skip-ci label", "only .md files changed", "" + reason: str # e.g. "ci:skip label", "only .md files changed", "" @dataclass(frozen=True) @@ -427,16 +427,16 @@ def check_skip_ci( """Determine whether CI should be skipped entirely. Returns SkipDecision(skip=True) for: - - 'skip-ci' PR label + - 'ci:skip' PR label - Only skippable files changed (docs, .md, etc.) - No files changed schedule and workflow_dispatch always proceed (changed_files is None for those triggers, and they have no PR labels). """ - if "skip-ci" in ci_inputs.pr_labels: - print(" Found 'skip-ci' PR label") - return SkipDecision(skip=True, reason="skip-ci label") + if "ci:skip" in ci_inputs.pr_labels: + print(" Found 'ci:skip' PR label") + return SkipDecision(skip=True, reason="ci:skip label") # changed_files is None for schedule/workflow_dispatch — always proceed. if git_context.changed_files is not None: @@ -619,7 +619,7 @@ def select_targets(ci_inputs: CIInputs) -> TargetSelection: - pull_request: Smallest default set (presubmit families). Designed for fast feedback on proposed changes. PR labels can opt in to additional - families (gfx* labels) or the full set (run-all-archs-ci). + families (gfx* labels) or the full set (ci:run-all-archs). - push: Broader coverage (presubmit + postsubmit families). Runs on code that has landed, so we want more thorough validation than PRs without paying the full nightly cost. @@ -647,7 +647,7 @@ def select_targets(ci_inputs: CIInputs) -> TargetSelection: windows_names = list(ci_inputs.windows_amdgpu_families) elif ci_inputs.is_pull_request: # Smallest default set for fast PR feedback. PR labels can extend - # the set below (gfx* for individual families, run-all-archs-ci + # the set below (gfx* for individual families, ci:run-all-archs # for everything). defaults = list(get_all_families_for_trigger_types(["presubmit"]).keys()) linux_names = list(defaults) @@ -672,11 +672,11 @@ def select_targets(ci_inputs: CIInputs) -> TargetSelection: # PR labels can extend the family set (both platforms) if ci_inputs.is_pull_request: for label in ci_inputs.pr_labels: - if label == "run-all-archs-ci": + if label == "ci:run-all-archs": # Override to all families. linux_names = list(all_families.keys()) windows_names = list(all_families.keys()) - print(" Label 'run-all-archs-ci' -> all families") + print(" Label 'ci:run-all-archs' -> all families") break if label.startswith("gfx"): target = label.split("-")[0] @@ -922,7 +922,7 @@ def main(): ci_inputs = CIInputs.from_environ() # Build git context for push/PR triggers (need changed files for - # skip-ci and test_type decisions). Schedule/workflow_dispatch don't + # ci:skip and test_type decisions). Schedule/workflow_dispatch don't # need git data. if ci_inputs.is_pull_request or ci_inputs.is_push: git_context = GitContext.from_repo(base_ref=ci_inputs.base_ref) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index b427dc14107..51fec3e51fe 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -173,12 +173,12 @@ def _inputs(self, **kwargs): return cm.CIInputs(**defaults) def test_skip_ci_label(self): - """PR with skip-ci label skips CI regardless of changed files.""" - inputs = self._inputs(pr_labels=["skip-ci", "gfx950"]) + """PR with ci:skip label skips CI regardless of changed files.""" + inputs = self._inputs(pr_labels=["ci:skip", "gfx950"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) result = cm.check_skip_ci(inputs, git) self.assertTrue(result.skip) - self.assertIn("skip-ci", result.reason) + self.assertIn("ci:skip", result.reason) @patch("configure_multi_arch_ci.is_ci_run_required", return_value=False) def test_path_filter_says_skip(self, mock_filter): @@ -429,13 +429,13 @@ def test_pull_request_gfx_label_adds_family(self): self.assertIn("gfx906", result_with.linux_families) def test_pull_request_run_all_archs_label(self): - """PR with run-all-archs-ci label selects all families.""" + """PR with ci:run-all-archs label selects all families.""" inputs = cm.CIInputs( event_name="pull_request", branch_name="feature", base_ref="HEAD^", build_variant="release", - pr_labels=["run-all-archs-ci"], + pr_labels=["ci:run-all-archs"], ) result = cm.select_targets(inputs) # Should include nightly-only families @@ -779,7 +779,7 @@ def test_skipped_outputs(self): @patch("configure_multi_arch_ci.check_skip_ci") def test_pipeline_skips_when_gate_says_skip(self, mock_skip): """If check_skip_ci returns skip=True, pipeline short-circuits.""" - mock_skip.return_value = cm.SkipDecision(skip=True, reason="skip-ci label") + mock_skip.return_value = cm.SkipDecision(skip=True, reason="ci:skip label") inputs = cm.CIInputs( event_name="workflow_dispatch", branch_name="main", From e82104acaea350f52dd536eb55598eeaa9dd0f2b Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 18 Mar 2026 15:17:27 -0700 Subject: [PATCH 27/56] Consolidate build config into single JSON input, flatten per_family_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 7+ individual BuildConfig inputs on multi_arch_ci_linux.yml and multi_arch_ci_windows.yml with a single build_config JSON string. Downstream workflows unpack with fromJSON(inputs.build_config).field. Rename matrix_per_family_json (double-encoded JSON string) to per_family_info (native list). Eliminates the fragile fromJSON(fromJSON(...)) pattern — matrix strategies now use fromJSON(inputs.build_config).per_family_info directly. Fix github_actions_utils → github_actions_api import after rebase. Remove stale parity test (matrix data diverged on main). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/multi_arch_ci.yml | 17 +---- .github/workflows/multi_arch_ci_linux.yml | 62 ++++++++----------- .github/workflows/multi_arch_ci_windows.yml | 45 ++++++-------- .../github_actions/configure_multi_arch_ci.py | 8 +-- .../configure_multi_arch_ci_summary.py | 2 +- .../tests/configure_multi_arch_ci_test.py | 60 +++--------------- 6 files changed, 57 insertions(+), 137 deletions(-) diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 1909d329890..c7cb6aa8135 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -75,19 +75,12 @@ jobs: uses: ./.github/workflows/multi_arch_ci_linux.yml secrets: inherit with: - matrix_per_family_json: ${{ fromJSON(needs.setup.outputs.linux_build_config).matrix_per_family_json }} - dist_amdgpu_families: ${{ fromJSON(needs.setup.outputs.linux_build_config).dist_amdgpu_families }} - artifact_group: ${{ fromJSON(needs.setup.outputs.linux_build_config).artifact_group }} - build_variant_label: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_label }} - build_variant_cmake_preset: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_cmake_preset }} - build_variant_suffix: ${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_suffix }} + build_config: ${{ needs.setup.outputs.linux_build_config }} test_labels: ${{ needs.setup.outputs.linux_test_labels }} - expect_failure: ${{ fromJSON(needs.setup.outputs.linux_build_config).expect_failure == true }} prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} - build_pytorch: ${{ matrix.variant.build_pytorch == true }} permissions: contents: read id-token: write @@ -103,14 +96,8 @@ jobs: uses: ./.github/workflows/multi_arch_ci_windows.yml secrets: inherit with: - matrix_per_family_json: ${{ fromJSON(needs.setup.outputs.windows_build_config).matrix_per_family_json }} - dist_amdgpu_families: ${{ fromJSON(needs.setup.outputs.windows_build_config).dist_amdgpu_families }} - artifact_group: ${{ fromJSON(needs.setup.outputs.windows_build_config).artifact_group }} - build_variant_label: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_label }} - build_variant_cmake_preset: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_cmake_preset }} - build_variant_suffix: ${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_suffix }} + build_config: ${{ needs.setup.outputs.windows_build_config }} test_labels: ${{ needs.setup.outputs.windows_test_labels }} - expect_failure: ${{ fromJSON(needs.setup.outputs.windows_build_config).expect_failure == true }} prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} diff --git a/.github/workflows/multi_arch_ci_linux.yml b/.github/workflows/multi_arch_ci_linux.yml index d3d0f481ab6..75f5d1c7745 100644 --- a/.github/workflows/multi_arch_ci_linux.yml +++ b/.github/workflows/multi_arch_ci_linux.yml @@ -6,24 +6,15 @@ name: Multi-Arch CI - Linux on: workflow_call: inputs: - artifact_group: - type: string - matrix_per_family_json: - type: string - description: "JSON array of {amdgpu_family, test-runs-on} objects for per-arch stages" - dist_amdgpu_families: - type: string - description: "Semicolon-separated list of all GPU families for dist targets" - build_variant_label: - type: string - build_variant_cmake_preset: - type: string - build_variant_suffix: + build_config: type: string + description: >- + JSON object with build configuration for this platform. Fields: + artifact_group, per_family_info, dist_amdgpu_families, + build_variant_label, build_variant_cmake_preset, + build_variant_suffix, expect_failure, build_pytorch. test_labels: type: string - expect_failure: - type: boolean prebuilt_stages: type: string default: "" @@ -36,9 +27,6 @@ on: type: string test_type: type: string - build_pytorch: - type: boolean - default: false permissions: contents: read @@ -82,7 +70,7 @@ jobs: python build_tools/artifact_manager.py copy \ --source-run-id=${{ inputs.baseline_run_id }} \ --stage="${{ inputs.prebuilt_stages }}" \ - --amdgpu-families="${{ inputs.dist_amdgpu_families }}" + --amdgpu-families="${{ fromJSON(inputs.build_config).dist_amdgpu_families }}" build_multi_arch_stages: name: Build Multi-Arch Stages @@ -91,13 +79,13 @@ jobs: uses: ./.github/workflows/multi_arch_build_portable_linux.yml secrets: inherit with: - matrix_per_family_json: ${{ inputs.matrix_per_family_json }} - dist_amdgpu_families: ${{ inputs.dist_amdgpu_families }} - artifact_group: ${{ inputs.artifact_group }} - build_variant_label: ${{ inputs.build_variant_label }} - build_variant_cmake_preset: ${{ inputs.build_variant_cmake_preset }} - build_variant_suffix: ${{ inputs.build_variant_suffix }} - expect_failure: ${{ inputs.expect_failure }} + matrix_per_family_json: ${{ toJSON(fromJSON(inputs.build_config).per_family_info) }} + dist_amdgpu_families: ${{ fromJSON(inputs.build_config).dist_amdgpu_families }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} + build_variant_label: ${{ fromJSON(inputs.build_config).build_variant_label }} + build_variant_cmake_preset: ${{ fromJSON(inputs.build_config).build_variant_cmake_preset }} + build_variant_suffix: ${{ fromJSON(inputs.build_config).build_variant_suffix }} + expect_failure: ${{ fromJSON(inputs.build_config).expect_failure }} prebuilt_stages: ${{ inputs.prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} @@ -109,21 +97,21 @@ jobs: needs: [build_multi_arch_stages] name: Validate Artifact Structure # If we are expecting a build failure, do not run tests to save machine capacity. - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} uses: ./.github/workflows/test_artifacts_structure.yml with: - artifact_group: ${{ inputs.artifact_group }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} platform: linux test_artifacts_per_family: needs: [copy_prebuilt_stages, build_multi_arch_stages] name: Test ${{ matrix.family_info.amdgpu_family }} # If we are expecting a build failure, do not run tests to save machine capacity. - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} strategy: fail-fast: false matrix: - family_info: ${{ fromJSON(inputs.matrix_per_family_json) }} + family_info: ${{ fromJSON(inputs.build_config).per_family_info }} # TODO(TheRock#3288): Re-enable gfx950-dcgpu runners once we get more capacity exclude: - family_info: @@ -142,11 +130,11 @@ jobs: build_python_packages: needs: [build_multi_arch_stages] name: Build Python Packages - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} uses: ./.github/workflows/build_portable_linux_python_packages.yml with: - artifact_group: ${{ inputs.artifact_group }} - amdgpu_families: ${{ inputs.dist_amdgpu_families }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} + amdgpu_families: ${{ fromJSON(inputs.build_config).dist_amdgpu_families }} multiarch_index: true package_version: ${{ inputs.rocm_package_version }} permissions: @@ -156,11 +144,11 @@ jobs: test_python_packages_per_family: needs: [build_python_packages] name: Test Python ${{ matrix.family_info.amdgpu_family }} - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} strategy: fail-fast: false matrix: - family_info: ${{ fromJSON(inputs.matrix_per_family_json) }} + family_info: ${{ fromJSON(inputs.build_config).per_family_info }} # TODO(TheRock#3288): Re-enable gfx950-dcgpu runners once we get more capacity exclude: - family_info: @@ -180,11 +168,11 @@ jobs: build_pytorch_wheels_per_family: needs: [build_python_packages] name: Build PyTorch | ${{ matrix.family_info.amdgpu_family }} - if: ${{ !failure() && !cancelled() && inputs.build_pytorch == true }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).build_pytorch == true }} strategy: fail-fast: false matrix: - family_info: ${{ fromJSON(inputs.matrix_per_family_json) }} + family_info: ${{ fromJSON(inputs.build_config).per_family_info }} # TODO(TheRock#3288): Re-enable gfx950-dcgpu runners once we get more capacity exclude: - family_info: diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index b506ca918c1..da269626e6e 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -6,24 +6,15 @@ name: Multi-Arch CI - Windows on: workflow_call: inputs: - artifact_group: - type: string - matrix_per_family_json: - type: string - description: "JSON array of {amdgpu_family, test-runs-on} objects for per-arch stages" - dist_amdgpu_families: - type: string - description: "Semicolon-separated list of all GPU families for dist targets" - build_variant_label: - type: string - build_variant_cmake_preset: - type: string - build_variant_suffix: + build_config: type: string + description: >- + JSON object with build configuration for this platform. Fields: + artifact_group, per_family_info, dist_amdgpu_families, + build_variant_label, build_variant_cmake_preset, + build_variant_suffix, expect_failure, build_pytorch. test_labels: type: string - expect_failure: - type: boolean prebuilt_stages: type: string default: "" @@ -87,7 +78,7 @@ jobs: python build_tools/artifact_manager.py copy \ --source-run-id=${{ inputs.baseline_run_id }} \ --stage="${{ inputs.prebuilt_stages }}" \ - --amdgpu-families="${{ inputs.dist_amdgpu_families }}" + --amdgpu-families="${{ fromJSON(inputs.build_config).dist_amdgpu_families }}" build_multi_arch_stages: name: Build Multi-Arch Stages @@ -96,13 +87,13 @@ jobs: uses: ./.github/workflows/multi_arch_build_windows.yml secrets: inherit with: - matrix_per_family_json: ${{ inputs.matrix_per_family_json }} - dist_amdgpu_families: ${{ inputs.dist_amdgpu_families }} - artifact_group: ${{ inputs.artifact_group }} - build_variant_label: ${{ inputs.build_variant_label }} - build_variant_cmake_preset: ${{ inputs.build_variant_cmake_preset }} - build_variant_suffix: ${{ inputs.build_variant_suffix }} - expect_failure: ${{ inputs.expect_failure }} + matrix_per_family_json: ${{ toJSON(fromJSON(inputs.build_config).per_family_info) }} + dist_amdgpu_families: ${{ fromJSON(inputs.build_config).dist_amdgpu_families }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} + build_variant_label: ${{ fromJSON(inputs.build_config).build_variant_label }} + build_variant_cmake_preset: ${{ fromJSON(inputs.build_config).build_variant_cmake_preset }} + build_variant_suffix: ${{ fromJSON(inputs.build_config).build_variant_suffix }} + expect_failure: ${{ fromJSON(inputs.build_config).expect_failure }} prebuilt_stages: ${{ inputs.prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} @@ -114,21 +105,21 @@ jobs: needs: [build_multi_arch_stages] name: Validate Artifact Structure # If we are expecting a build failure, do not run tests to save machine capacity. - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} uses: ./.github/workflows/test_artifacts_structure.yml with: - artifact_group: ${{ inputs.artifact_group }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} platform: windows test_artifacts_per_family: needs: [copy_prebuilt_stages, build_multi_arch_stages] name: Test ${{ matrix.family_info.amdgpu_family }} # If we are expecting a build failure, do not run tests to save machine capacity. - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} strategy: fail-fast: false matrix: - family_info: ${{ fromJSON(inputs.matrix_per_family_json) }} + family_info: ${{ fromJSON(inputs.build_config).per_family_info }} uses: ./.github/workflows/test_artifacts.yml with: # Use architecture-specific artifact group for fetching per-arch artifacts diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 3ed986b31f9..737d4dcb0a4 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -56,7 +56,7 @@ get_git_submodule_paths, is_ci_run_required, ) -from github_actions_utils import gha_append_step_summary, gha_set_output +from github_actions_api import gha_append_step_summary, gha_set_output # --------------------------------------------------------------------------- # Input parsing helpers @@ -352,7 +352,7 @@ class BuildConfig: for downstream per-architecture job expansion and variant metadata. """ - matrix_per_family_json: str # JSON array of per-family info + per_family_info: list[dict] # Per-family metadata for test/artifact jobs dist_amdgpu_families: str # Semicolon-separated artifact_group: str build_variant_label: str @@ -364,7 +364,7 @@ class BuildConfig: def to_dict(self) -> dict: """Convert to dict for JSON serialization.""" return { - "matrix_per_family_json": self.matrix_per_family_json, + "per_family_info": self.per_family_info, "dist_amdgpu_families": self.dist_amdgpu_families, "artifact_group": self.artifact_group, "build_variant_label": self.build_variant_label, @@ -761,7 +761,7 @@ def _expand_build_config_for_platform( suffix = variant_config.get("build_variant_suffix", "") return BuildConfig( - matrix_per_family_json=json.dumps(per_family_info), + per_family_info=per_family_info, dist_amdgpu_families=";".join(family_names), artifact_group=f"multi-arch-{suffix or 'release'}", build_variant_label=variant_config["build_variant_label"], diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 9b5e2fb4ff0..7ce956ffe1d 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -203,7 +203,7 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: ]: if config is None: continue - per_family = json.loads(config.matrix_per_family_json) + per_family = config.per_family_info for entry in per_family: family = f"`{entry['amdgpu_family']}`" runner = f"`{entry['test-runs-on']}`" if entry["test-runs-on"] else "—" diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 51fec3e51fe..73efa01c306 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -551,7 +551,7 @@ class TestExpandBuildConfigs(unittest.TestCase): def test_build_config_to_dict_round_trips(self): """BuildConfig.to_dict() produces all expected keys.""" config = cm.BuildConfig( - matrix_per_family_json="[]", + per_family_info=[], dist_amdgpu_families="", artifact_group="multi-arch-release", build_variant_label="release", @@ -562,7 +562,7 @@ def test_build_config_to_dict_round_trips(self): ) d = config.to_dict() expected_keys = { - "matrix_per_family_json", + "per_family_info", "dist_amdgpu_families", "artifact_group", "build_variant_label", @@ -599,7 +599,7 @@ def test_release_produces_configs_for_both_platforms(self): } for config in [result.linux, result.windows]: self.assertIsNotNone(config) - per_family = json.loads(config.matrix_per_family_json) + per_family = config.per_family_info self.assertGreater(len(per_family), 0) for entry in per_family: self.assertEqual( @@ -614,7 +614,7 @@ def test_build_config_structure(self): BuildConfig carries two representations of the family list for different workflow consumers: - matrix_per_family_json — JSON array with per-family metadata for + per_family_info — JSON array with per-family metadata for test and per-arch artifact jobs (fromJSON matrix expansion): [ @@ -641,9 +641,9 @@ def test_build_config_structure(self): result = cm.expand_build_configs(targets=targets, build_variant="release") # All target families that support the variant appear in output. - linux_per_family = json.loads(result.linux.matrix_per_family_json) + linux_per_family = result.linux.per_family_info self.assertEqual(len(linux_per_family), 2) - windows_per_family = json.loads(result.windows.matrix_per_family_json) + windows_per_family = result.windows.per_family_info self.assertEqual(len(windows_per_family), 1) # The two family representations carry the same set of families. @@ -668,57 +668,11 @@ def test_variant_filters_by_platform_and_family_support(self): result = cm.expand_build_configs(targets=targets, build_variant="asan") # Only gfx94x on linux survives. self.assertIsNotNone(result.linux) - linux_per_family = json.loads(result.linux.matrix_per_family_json) + linux_per_family = result.linux.per_family_info self.assertEqual(len(linux_per_family), 1) # Windows has no asan variant config at all. self.assertIsNone(result.windows) - # -- Parity test (useful during transition, may be removed later) -- - - def test_output_matches_generate_multi_arch_matrix(self): - """expand_build_configs output matches configure_ci.generate_multi_arch_matrix.""" - from amdgpu_family_matrix import ( - all_build_variants, - get_all_families_for_trigger_types, - ) - from configure_ci import generate_multi_arch_matrix - - all_families_list = ["gfx94x", "gfx110x", "gfx1151", "gfx120x"] - variant = "release" - lookup_matrix = get_all_families_for_trigger_types( - ["presubmit", "postsubmit", "nightly"] - ) - - # Filter families per platform (as select_targets would). - linux_families = [ - f for f in all_families_list if "linux" in lookup_matrix.get(f, {}) - ] - windows_families = [ - f for f in all_families_list if "windows" in lookup_matrix.get(f, {}) - ] - targets = cm.TargetSelection( - linux_families=linux_families, - windows_families=windows_families, - ) - result = cm.expand_build_configs(targets=targets, build_variant=variant) - - for platform, families, new_config in [ - ("linux", linux_families, result.linux), - ("windows", windows_families, result.windows), - ]: - old_result = generate_multi_arch_matrix( - target_names=families, - lookup_matrix=lookup_matrix, - platform=platform, - platform_build_variants=all_build_variants[platform], - base_args={"build_variant": variant}, - ) - self.assertIsNotNone(new_config, f"expected config for {platform}") - self.assertEqual(len(old_result), 1, f"expected 1 entry for {platform}") - self.assertEqual( - old_result[0], new_config.to_dict(), f"mismatch on {platform}" - ) - # --------------------------------------------------------------------------- # Step 6: Format Outputs From 44a20c2b8c6b3b2d268256d175b968944acab7a3 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 18 Mar 2026 15:29:45 -0700 Subject: [PATCH 28/56] Add BuildConfig contract tests for workflow YAML field references Verify that workflow YAML references to fromJSON(inputs.build_config) only use fields that exist on the Python BuildConfig dataclass. Linux checks exact match (all fields used), Windows checks no unknown fields (build_pytorch not yet used there). Also move imports to top of test file and update ci:skip/ci:run-all-archs label names. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/configure_multi_arch_ci_test.py | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 73efa01c306..413fed7c6f1 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -10,14 +10,18 @@ import json import os +import re import sys import tempfile import unittest +from dataclasses import fields from pathlib import Path from unittest.mock import patch sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) import configure_multi_arch_ci as cm +from configure_multi_arch_ci_summary import format_summary +from workflow_utils import WORKFLOWS_DIR # --------------------------------------------------------------------------- @@ -693,15 +697,11 @@ def _inputs(self, **kwargs): return cm.CIInputs(**defaults) def test_skipped_summary_does_not_raise(self): - from configure_multi_arch_ci_summary import format_summary - outputs = cm.CIOutputs.skipped("only .md files changed") git = cm.GitContext(changed_files=["docs/README.md"]) format_summary(self._inputs(), git, outputs) def test_normal_summary_does_not_raise(self): - from configure_multi_arch_ci_summary import format_summary - jobs = cm.JobDecisions( build_rocm=cm.BuildRocmDecision(action="run", reason="default"), test_rocm=cm.TestRocmDecision( @@ -781,5 +781,77 @@ def test_pipeline_calls_all_steps( mock_expand.assert_called_once() +# --------------------------------------------------------------------------- +# Contract: BuildConfig fields match workflow YAML references +# --------------------------------------------------------------------------- + + +class TestBuildConfigWorkflowContract(unittest.TestCase): + """Verify that workflow YAML references to fromJSON(inputs.build_config).FIELD + only use fields that exist in BuildConfig.to_dict(). + + If a workflow references a field that was renamed or removed in Python, + this test fails — catching the mismatch before CI does a runtime fromJSON + and gets null. Fields in Python but not referenced in YAML are fine + (not every workflow uses every field). + """ + + @staticmethod + def _extract_build_config_fields(workflow_path): + """Extract field names referenced as fromJSON(inputs.build_config).X.""" + # We need the raw text, not parsed YAML, to find expression references. + text = workflow_path.read_text() + # Match fromJSON(inputs.build_config).FIELD_NAME + pattern = r"fromJSON\(inputs\.build_config\)\.(\w+)" + return set(re.findall(pattern, text)) + + def _assert_yaml_fields_subset_of_python(self, workflow_path): + yaml_fields = self._extract_build_config_fields(workflow_path) + python_fields = {f.name for f in fields(cm.BuildConfig)} + unknown = yaml_fields - python_fields + self.assertEqual( + unknown, + set(), + f"{workflow_path.name} references BuildConfig fields that don't " + f"exist in Python: {unknown}. " + f"Available fields: {sorted(python_fields)}", + ) + + def test_linux_workflow_uses_all_fields(self): + """Linux workflow should reference every BuildConfig field.""" + workflow_path = WORKFLOWS_DIR / "multi_arch_ci_linux.yml" + yaml_fields = self._extract_build_config_fields(workflow_path) + python_fields = {f.name for f in fields(cm.BuildConfig)} + self.assertEqual( + yaml_fields, + python_fields, + f"BuildConfig fields mismatch with {workflow_path.name}.\n" + f" In YAML but not Python: {yaml_fields - python_fields}\n" + f" In Python but not YAML: {python_fields - yaml_fields}", + ) + + @unittest.skip( + "Windows doesn't build pytorch on multi-arch CI yet — build_pytorch field unused" + ) + def test_windows_workflow_uses_all_fields(self): + """Windows workflow should reference every BuildConfig field.""" + workflow_path = WORKFLOWS_DIR / "multi_arch_ci_windows.yml" + yaml_fields = self._extract_build_config_fields(workflow_path) + python_fields = {f.name for f in fields(cm.BuildConfig)} + self.assertEqual( + yaml_fields, + python_fields, + f"BuildConfig fields mismatch with {workflow_path.name}.\n" + f" In YAML but not Python: {yaml_fields - python_fields}\n" + f" In Python but not YAML: {python_fields - yaml_fields}", + ) + + def test_windows_workflow_no_unknown_fields(self): + """Windows workflow should not reference fields that don't exist.""" + self._assert_yaml_fields_subset_of_python( + WORKFLOWS_DIR / "multi_arch_ci_windows.yml" + ) + + if __name__ == "__main__": unittest.main() From 75846bd5cf52839da17efdd372479920f3716a72 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 18 Mar 2026 15:43:16 -0700 Subject: [PATCH 29/56] Fold prebuilt_stages and baseline_run_id into BuildConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass prebuilt stage info through expand_build_configs to _expand_build_config_for_platform, setting fields at construction time rather than mutating frozen dataclasses after the fact. Removes 2 separate outputs from setup_multi_arch.yml (9 → 7). Workflow YAML reads them from build_config via fromJSON. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/multi_arch_ci.yml | 4 ---- .github/workflows/multi_arch_ci_linux.yml | 19 ++++++----------- .github/workflows/multi_arch_ci_windows.yml | 19 ++++++----------- .github/workflows/setup_multi_arch.yml | 8 ------- .../github_actions/configure_multi_arch_ci.py | 21 +++++++++++++------ .../tests/configure_multi_arch_ci_test.py | 12 ++--------- 6 files changed, 29 insertions(+), 54 deletions(-) diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index c7cb6aa8135..8ab6dbdf1c3 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -77,8 +77,6 @@ jobs: with: build_config: ${{ needs.setup.outputs.linux_build_config }} test_labels: ${{ needs.setup.outputs.linux_test_labels }} - prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} - baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} permissions: @@ -98,8 +96,6 @@ jobs: with: build_config: ${{ needs.setup.outputs.windows_build_config }} test_labels: ${{ needs.setup.outputs.windows_test_labels }} - prebuilt_stages: ${{ needs.setup.outputs.prebuilt_stages }} - baseline_run_id: ${{ needs.setup.outputs.baseline_run_id }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} build_pytorch: ${{ matrix.variant.build_pytorch == true }} diff --git a/.github/workflows/multi_arch_ci_linux.yml b/.github/workflows/multi_arch_ci_linux.yml index 75f5d1c7745..bccd3f8af45 100644 --- a/.github/workflows/multi_arch_ci_linux.yml +++ b/.github/workflows/multi_arch_ci_linux.yml @@ -12,17 +12,10 @@ on: JSON object with build configuration for this platform. Fields: artifact_group, per_family_info, dist_amdgpu_families, build_variant_label, build_variant_cmake_preset, - build_variant_suffix, expect_failure, build_pytorch. + build_variant_suffix, expect_failure, build_pytorch, + prebuilt_stages, baseline_run_id. test_labels: type: string - prebuilt_stages: - type: string - default: "" - description: "Comma-separated build stages to skip; artifacts are copied from baseline_run_id instead" - baseline_run_id: - type: string - default: "" - description: "Workflow run ID to copy prebuilt stage artifacts from" rocm_package_version: type: string test_type: @@ -34,7 +27,7 @@ permissions: jobs: copy_prebuilt_stages: name: Copy Prebuilt Stages - if: ${{ inputs.prebuilt_stages != '' && inputs.baseline_run_id != '' }} + if: ${{ fromJSON(inputs.build_config).prebuilt_stages != '' && fromJSON(inputs.build_config).baseline_run_id != '' }} runs-on: azure-linux-scale-rocm permissions: contents: read @@ -68,8 +61,8 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | python build_tools/artifact_manager.py copy \ - --source-run-id=${{ inputs.baseline_run_id }} \ - --stage="${{ inputs.prebuilt_stages }}" \ + --source-run-id=${{ fromJSON(inputs.build_config).baseline_run_id }} \ + --stage="${{ fromJSON(inputs.build_config).prebuilt_stages }}" \ --amdgpu-families="${{ fromJSON(inputs.build_config).dist_amdgpu_families }}" build_multi_arch_stages: @@ -86,7 +79,7 @@ jobs: build_variant_cmake_preset: ${{ fromJSON(inputs.build_config).build_variant_cmake_preset }} build_variant_suffix: ${{ fromJSON(inputs.build_config).build_variant_suffix }} expect_failure: ${{ fromJSON(inputs.build_config).expect_failure }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} + prebuilt_stages: ${{ fromJSON(inputs.build_config).prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} permissions: diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index da269626e6e..6cddb7bf07b 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -12,17 +12,10 @@ on: JSON object with build configuration for this platform. Fields: artifact_group, per_family_info, dist_amdgpu_families, build_variant_label, build_variant_cmake_preset, - build_variant_suffix, expect_failure, build_pytorch. + build_variant_suffix, expect_failure, build_pytorch, + prebuilt_stages, baseline_run_id. test_labels: type: string - prebuilt_stages: - type: string - default: "" - description: "Comma-separated build stages to skip; artifacts are copied from baseline_run_id instead" - baseline_run_id: - type: string - default: "" - description: "Workflow run ID to copy prebuilt stage artifacts from" rocm_package_version: type: string test_type: @@ -37,7 +30,7 @@ permissions: jobs: copy_prebuilt_stages: name: Copy Prebuilt Stages - if: ${{ inputs.prebuilt_stages != '' && inputs.baseline_run_id != '' }} + if: ${{ fromJSON(inputs.build_config).prebuilt_stages != '' && fromJSON(inputs.build_config).baseline_run_id != '' }} # TODO: Consider running on a Linux runner with --platform=windows to # avoid Windows runner setup overhead (setup-python ~51s). runs-on: azure-windows-scale-rocm @@ -76,8 +69,8 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | python build_tools/artifact_manager.py copy \ - --source-run-id=${{ inputs.baseline_run_id }} \ - --stage="${{ inputs.prebuilt_stages }}" \ + --source-run-id=${{ fromJSON(inputs.build_config).baseline_run_id }} \ + --stage="${{ fromJSON(inputs.build_config).prebuilt_stages }}" \ --amdgpu-families="${{ fromJSON(inputs.build_config).dist_amdgpu_families }}" build_multi_arch_stages: @@ -94,7 +87,7 @@ jobs: build_variant_cmake_preset: ${{ fromJSON(inputs.build_config).build_variant_cmake_preset }} build_variant_suffix: ${{ fromJSON(inputs.build_config).build_variant_suffix }} expect_failure: ${{ fromJSON(inputs.build_config).expect_failure }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} + prebuilt_stages: ${{ fromJSON(inputs.build_config).prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} permissions: diff --git a/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml index 292dab71001..1d674cd5581 100644 --- a/.github/workflows/setup_multi_arch.yml +++ b/.github/workflows/setup_multi_arch.yml @@ -34,12 +34,6 @@ on: windows_test_labels: description: ROCm projects to run Windows tests on. Optional filter. value: ${{ jobs.setup.outputs.windows_test_labels }} - prebuilt_stages: - description: "Comma-separated build stages to use prebuilt artifacts for." - value: ${{ jobs.setup.outputs.prebuilt_stages }} - baseline_run_id: - description: "Workflow run ID to copy prebuilt stage artifacts from." - value: ${{ jobs.setup.outputs.baseline_run_id }} rocm_package_version: description: ROCm package version (primarily for Python packages). value: ${{ jobs.setup.outputs.rocm_package_version }} @@ -59,8 +53,6 @@ jobs: test_type: ${{ steps.configure.outputs.test_type }} linux_test_labels: ${{ steps.configure.outputs.linux_test_labels }} windows_test_labels: ${{ steps.configure.outputs.windows_test_labels }} - prebuilt_stages: ${{ steps.configure.outputs.prebuilt_stages }} - baseline_run_id: ${{ steps.configure.outputs.baseline_run_id }} rocm_package_version: ${{ steps.rocm_package_version.outputs.rocm_package_version }} steps: - name: Checking out repository diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 737d4dcb0a4..1ad0f86eee8 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -360,6 +360,9 @@ class BuildConfig: build_variant_cmake_preset: str expect_failure: bool build_pytorch: bool + # Prebuilt stage configuration — set by configure() from JobDecisions. + prebuilt_stages: str = "" + baseline_run_id: str = "" def to_dict(self) -> dict: """Convert to dict for JSON serialization.""" @@ -372,6 +375,8 @@ def to_dict(self) -> dict: "build_variant_cmake_preset": self.build_variant_cmake_preset, "expect_failure": self.expect_failure, "build_pytorch": self.build_pytorch, + "prebuilt_stages": self.prebuilt_stages, + "baseline_run_id": self.baseline_run_id, } @@ -713,6 +718,8 @@ def _expand_build_config_for_platform( build_variant: str, all_families: dict[str, dict], variant_config: dict, + prebuilt_stages: str = "", + baseline_run_id: str = "", ) -> BuildConfig | None: """Build a BuildConfig for one platform, or None if no families match. @@ -769,12 +776,16 @@ def _expand_build_config_for_platform( build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], expect_failure=expect_failure, build_pytorch=not expect_failure and not expect_pytorch_failure, + prebuilt_stages=prebuilt_stages, + baseline_run_id=baseline_run_id, ) def expand_build_configs( targets: TargetSelection, build_variant: str, + prebuilt_stages: str = "", + baseline_run_id: str = "", ) -> BuildConfigs: """Build a BuildConfig for each platform that supports the variant. @@ -805,6 +816,8 @@ def expand_build_configs( build_variant=build_variant, all_families=all_families, variant_config=variant_config, + prebuilt_stages=prebuilt_stages, + baseline_run_id=baseline_run_id, ) if platform == "linux": linux_config = config @@ -844,12 +857,6 @@ def write_outputs( "test_type": test_type, "linux_test_labels": outputs.linux_test_labels, "windows_test_labels": outputs.windows_test_labels, - "prebuilt_stages": ",".join( - outputs.jobs.build_rocm.prebuilt_stages if outputs.jobs else [] - ), - "baseline_run_id": ( - outputs.jobs.build_rocm.baseline_run_id if outputs.jobs else "" - ), } gha_set_output(output_vars) @@ -901,6 +908,8 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: builds = expand_build_configs( targets=targets, build_variant=ci_inputs.build_variant, + prebuilt_stages=",".join(jobs.build_rocm.prebuilt_stages), + baseline_run_id=jobs.build_rocm.baseline_run_id, ) builds.log() diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 413fed7c6f1..c3bfd6ea33a 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -565,16 +565,8 @@ def test_build_config_to_dict_round_trips(self): build_pytorch=True, ) d = config.to_dict() - expected_keys = { - "per_family_info", - "dist_amdgpu_families", - "artifact_group", - "build_variant_label", - "build_variant_suffix", - "build_variant_cmake_preset", - "expect_failure", - "build_pytorch", - } + # to_dict keys should match dataclass fields. + expected_keys = {f.name for f in fields(cm.BuildConfig)} self.assertEqual(set(d.keys()), expected_keys) def test_empty_targets_both_none(self): From ec266fa541a9c2a295e61d1b42ca1484573e0432 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Wed, 18 Mar 2026 16:26:30 -0700 Subject: [PATCH 30/56] Remove multi-arch code from configure_ci.py, add label gate to new script Multi-arch CI now uses configure_multi_arch_ci.py exclusively. Remove generate_multi_arch_matrix, the multi_arch parameter from matrix_generator, and the ci:run-multi-arch label check from configure_ci.py. Add ci:run-multi-arch label gate to check_skip_ci in the new script: pull_request triggers require the label to opt in, avoiding doubled CI load during the transition. push/schedule/workflow_dispatch are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- build_tools/github_actions/configure_ci.py | 156 +------- .../github_actions/configure_multi_arch_ci.py | 10 + .../github_actions/tests/configure_ci_test.py | 377 ------------------ .../tests/configure_multi_arch_ci_test.py | 21 +- 4 files changed, 30 insertions(+), 534 deletions(-) diff --git a/build_tools/github_actions/configure_ci.py b/build_tools/github_actions/configure_ci.py index fa8ad855ae1..d16d577e64b 100755 --- a/build_tools/github_actions/configure_ci.py +++ b/build_tools/github_actions/configure_ci.py @@ -139,116 +139,6 @@ def filter_known_names( return filtered_names -def generate_multi_arch_matrix( - target_names: List[str], - lookup_matrix: dict, - platform: str, - platform_build_variants: dict, - base_args: dict, -) -> List[dict]: - """Generate matrix grouped by build_variant with structured per-family data. - - In multi-arch mode, instead of creating one entry per (family × build_variant), - we create one entry per build_variant containing all families that support it. - This allows multi_arch_build_portable_linux.yml to run generic stages once - and matrix over families only for per-arch stages. - - Args: - target_names: List of target family names (e.g., ["gfx94X", "gfx1201"]) - lookup_matrix: Family info matrix from amdgpu_family_matrix.py - platform: Platform name ("linux" or "windows") - platform_build_variants: Dict of build variant configs for this platform - base_args: Base arguments including 'build_variant' to filter by - - Returns: - List of matrix entries, each containing: - - matrix_per_family_json: JSON array of {amdgpu_family, test-runs-on} objects - for per-architecture job matrix expansion - - dist_amdgpu_families: Semicolon-separated family names for THEROCK_DIST_AMDGPU_TARGETS - - build_variant_label: Human-readable label (e.g., "Release", "ASAN", "TSAN") - - build_variant_suffix: Suffix for artifact naming (e.g., "", "asan", "tsan"). Empty string - for release builds, short identifier for other variants. - - build_variant_cmake_preset: CMake preset name (e.g., "release", "asan", "tsan") - - expect_failure: If True, job failure is non-blocking (continue-on-error) - - artifact_group: Unique identifier for artifact grouping, formatted as - "multi-arch-{suffix}" where suffix defaults to "release" if empty - """ - # Collect per-family info for each build_variant - variant_to_family_info: dict[str, List[dict]] = {} - variant_info: dict[str, dict] = {} - - for target_name in target_names: - platform_set = lookup_matrix.get(target_name) - if not platform_set or platform not in platform_set: - continue - platform_info = platform_set.get(platform) - family_name = platform_info["family"] - test_runs_on = platform_info.get("test-runs-on", "") - - for build_variant_name in platform_info.get("build_variants", []): - if build_variant_name != base_args.get("build_variant"): - continue - - if build_variant_name not in variant_to_family_info: - variant_to_family_info[build_variant_name] = [] - variant_info[build_variant_name] = platform_build_variants.get( - build_variant_name - ) - - # Check for duplicates by family name - existing_families = [ - f["amdgpu_family"] for f in variant_to_family_info[build_variant_name] - ] - if family_name not in existing_families: - # fetch-gfx-targets: individual GPU arch(s) on the test runner, - # used for fetching split (per-target) artifacts. - fetch_gfx_targets = platform_info.get("fetch-gfx-targets", []) - variant_to_family_info[build_variant_name].append( - { - "amdgpu_family": family_name, - "amdgpu_targets": ",".join(fetch_gfx_targets), - "test-runs-on": test_runs_on, - "sanity_check_only_for_family": platform_info.get( - "sanity_check_only_for_family", False - ), - # Per-family pytorch flag. False for families with known - # build failures. Used to gate per-family pytorch wheel - # builds in multi_arch_ci_linux.yml. - # NOTE: This is distinct from a future combined (multi-arch) - # pytorch build that would build once against the full index. - "build_pytorch": not platform_info.get( - "expect_pytorch_failure", False - ), - } - ) - - # Create one matrix entry per build_variant - matrix_output = [] - for variant_name, family_info_list in variant_to_family_info.items(): - info = variant_info[variant_name] - if not info: - continue - - # Extract family names for dist_amdgpu_families - family_names = [f["amdgpu_family"] for f in family_info_list] - - expect_failure = info.get("expect_failure", False) - expect_pytorch_failure = info.get("expect_pytorch_failure", False) - matrix_row = { - "matrix_per_family_json": json.dumps(family_info_list), - "dist_amdgpu_families": ";".join(family_names), - "artifact_group": f"multi-arch-{info.get('build_variant_suffix') or 'release'}", - "build_variant_label": info["build_variant_label"], - "build_variant_suffix": info["build_variant_suffix"], - "build_variant_cmake_preset": info["build_variant_cmake_preset"], - "expect_failure": expect_failure, - "build_pytorch": not expect_failure and not expect_pytorch_failure, - } - matrix_output.append(matrix_row) - - return matrix_output - - def determine_long_lived_branch(branch_name: str) -> bool: # For long-lived branches (main, releases) we want to run both presubmit and postsubmit jobs on push, # instead of just presubmit jobs (as for other branches) @@ -272,7 +162,6 @@ def matrix_generator( base_args={}, families={}, platform="linux", - multi_arch=False, ): """ Generates a matrix of "family" and "test-runs-on" parameters based on the workflow inputs. @@ -463,19 +352,6 @@ def matrix_generator( platform_build_variants, dict ), f"Expected build variant {platform} in {all_build_variants}" - # In multi-arch mode, group all families into one entry per build_variant - if multi_arch: - matrix_output = generate_multi_arch_matrix( - unique_target_names, - lookup_matrix, - platform, - platform_build_variants, - base_args, - ) - print(f"Generated multi-arch build matrix: {str(matrix_output)}") - print(f"Generated test list: {str(unique_test_names)}") - return matrix_output, unique_test_names - # Expand selected target names back to a matrix (cross-product of families × variants). matrix_output = [] for target_name in unique_target_names: @@ -587,7 +463,6 @@ def main(base_args, linux_families, windows_families): branch_name = base_args.get("branch_name", "") base_ref = base_args.get("base_ref") build_variant = base_args.get("build_variant", "") - multi_arch = base_args.get("multi_arch", False) linux_use_prebuilt_artifacts = base_args.get("linux_use_prebuilt_artifacts") windows_use_prebuilt_artifacts = base_args.get("windows_use_prebuilt_artifacts") @@ -596,7 +471,6 @@ def main(base_args, linux_families, windows_families): print(f" github_event_name: {github_event_name}") print(f" branch_name: {branch_name}") print(f" base_ref: {base_ref}") - print(f" multi_arch: {multi_arch}") print(f" build_variant: {build_variant}") print(f" is_push: {is_push}") print(f" is_workflow_dispatch: {is_workflow_dispatch}") @@ -617,9 +491,7 @@ def main(base_args, linux_families, windows_families): ) print("") - print( - f"Generating build matrix for Linux (multi_arch={multi_arch}): {str(linux_families)}" - ) + print(f"Generating build matrix for Linux: {str(linux_families)}") linux_variants_output, linux_test_output = matrix_generator( is_pull_request, is_workflow_dispatch, @@ -628,13 +500,10 @@ def main(base_args, linux_families, windows_families): base_args, linux_families, platform="linux", - multi_arch=multi_arch, ) print("") - print( - f"Generating build matrix for Windows (multi_arch={multi_arch}): {str(windows_families)}" - ) + print(f"Generating build matrix for Windows: {str(windows_families)}") windows_variants_output, windows_test_output = matrix_generator( is_pull_request, is_workflow_dispatch, @@ -643,7 +512,6 @@ def main(base_args, linux_families, windows_families): base_args, windows_families, platform="windows", - multi_arch=multi_arch, ) print("") @@ -677,21 +545,6 @@ def main(base_args, linux_families, windows_families): # * workflow_dispatch or workflow_call with inputs controlling enabled jobs? enable_build_jobs = is_ci_run_required(modified_paths) - # TODO(#3399): move multi-arch CI configuration to its own script - # Multi-arch CI on PRs requires explicit opt-in via label. - # This avoids doubling CI load during the transition from ci.yml - # to multi_arch_ci.yml. See https://github.com/ROCm/TheRock/issues/3337 - if ( - multi_arch - and is_pull_request - and "ci:run-multi-arch" not in (pr_labels or []) - ): - print( - "Skipping multi-arch CI: 'ci:run-multi-arch' label not found. " - "Add the label to opt in." - ) - enable_build_jobs = False - # If the modified path contains any git submodules, we want to run a full test suite. # Otherwise, we just run quick tests submodule_paths = get_git_submodule_paths(repo_root=THEROCK_DIR) @@ -751,10 +604,6 @@ def format_variants(variants): if flags: label += f" ({', '.join(flags)})" result.append(label) - elif "matrix_per_family_json" in item: - # Multi-arch mode: show the families from the JSON - families = json.loads(item["matrix_per_family_json"]) - result.append([f["amdgpu_family"] for f in families]) return result gha_append_step_summary( @@ -823,6 +672,5 @@ def format_variants(variants): "ADDITIONAL_LABEL_OPTIONS", "" ) base_args["build_variant"] = os.getenv("BUILD_VARIANT", "release") - base_args["multi_arch"] = os.environ.get("MULTI_ARCH", "false") == "true" main(base_args, linux_families, windows_families) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 1ad0f86eee8..465a14ef374 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -433,6 +433,7 @@ def check_skip_ci( Returns SkipDecision(skip=True) for: - 'ci:skip' PR label + - pull_request without 'ci:run-multi-arch' label (opt-in during transition) - Only skippable files changed (docs, .md, etc.) - No files changed @@ -443,6 +444,15 @@ def check_skip_ci( print(" Found 'ci:skip' PR label") return SkipDecision(skip=True, reason="ci:skip label") + # Multi-arch CI on PRs requires explicit opt-in via label to avoid + # doubling CI load during the transition. See #3337. + if ci_inputs.is_pull_request and "ci:run-multi-arch" not in ci_inputs.pr_labels: + print(" PR without 'ci:run-multi-arch' label — skipping multi-arch CI") + return SkipDecision( + skip=True, + reason="ci:run-multi-arch label not found (add to opt in)", + ) + # changed_files is None for schedule/workflow_dispatch — always proceed. if git_context.changed_files is not None: print( diff --git a/build_tools/github_actions/tests/configure_ci_test.py b/build_tools/github_actions/tests/configure_ci_test.py index 840c753f4f3..c3c1edf3f67 100644 --- a/build_tools/github_actions/tests/configure_ci_test.py +++ b/build_tools/github_actions/tests/configure_ci_test.py @@ -26,34 +26,6 @@ def assert_target_output_is_valid(self, target_output, allow_xfail): any(entry.get("expect_failure") for entry in target_output) ) - def assert_multi_arch_output_is_valid(self, target_output, allow_xfail): - """Validate multi-arch matrix output format.""" - import json - - self.assertTrue( - all("matrix_per_family_json" in entry for entry in target_output) - ) - self.assertTrue(all("dist_amdgpu_families" in entry for entry in target_output)) - self.assertTrue(all("build_variant_label" in entry for entry in target_output)) - # Multi-arch output should NOT have 'family' field at top level - self.assertFalse(any("family" in entry for entry in target_output)) - - # Validate structure of matrix_per_family_json - for entry in target_output: - family_info_list = json.loads(entry["matrix_per_family_json"]) - self.assertTrue(all("amdgpu_family" in f for f in family_info_list)) - self.assertTrue(all("amdgpu_targets" in f for f in family_info_list)) - self.assertTrue(all("test-runs-on" in f for f in family_info_list)) - self.assertTrue( - all("sanity_check_only_for_family" in f for f in family_info_list) - ) - self.assertTrue(all("build_pytorch" in f for f in family_info_list)) - - if not allow_xfail: - self.assertFalse( - any(entry.get("expect_failure") for entry in target_output) - ) - ########################################################################### # Tests for matrix_generator and helper functions @@ -537,355 +509,6 @@ def test_determine_long_lived_branch(self): ]: self.assertFalse(configure_ci.determine_long_lived_branch(branch)) - ########################################################################### - # Tests for multi_arch mode - - def test_multi_arch_linux_workflow_dispatch_matrix_generator(self): - """Test multi_arch mode groups all families into one entry with test-runs-on.""" - import json - - build_families = {"amdgpu_families": "gfx94X, gfx110X"} - linux_target_output, linux_test_labels = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=True, - is_push=False, - is_schedule=False, - base_args={ - "workflow_dispatch_linux_test_labels": "", - "workflow_dispatch_windows_test_labels": "", - "build_variant": "release", - }, - families=build_families, - platform="linux", - multi_arch=True, - ) - # Multi-arch should produce one entry per build_variant, not per family - self.assertEqual(len(linux_target_output), 1) - self.assert_multi_arch_output_is_valid( - target_output=linux_target_output, allow_xfail=True - ) - - # Check that both families are in the output with structured format - entry = linux_target_output[0] - family_info_list = json.loads(entry["matrix_per_family_json"]) - family_names = [f["amdgpu_family"] for f in family_info_list] - self.assertIn("gfx94X-dcgpu", family_names) - self.assertIn("gfx110X-all", family_names) - - # Verify test-runs-on is populated for each family - for family_info in family_info_list: - self.assertIn("test-runs-on", family_info) - - # Check dist_amdgpu_families is semicolon-separated - dist_families = entry["dist_amdgpu_families"].split(";") - self.assertIn("gfx94X-dcgpu", dist_families) - self.assertIn("gfx110X-all", dist_families) - - self.assertEqual(linux_test_labels, []) - - def test_multi_arch_single_family_linux_workflow_dispatch(self): - """Test multi_arch mode with single family produces one entry.""" - import json - - build_families = {"amdgpu_families": "gfx94X"} - linux_target_output, linux_test_labels = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=True, - is_push=False, - is_schedule=False, - base_args={ - "workflow_dispatch_linux_test_labels": "", - "workflow_dispatch_windows_test_labels": "", - "build_variant": "release", - }, - families=build_families, - platform="linux", - multi_arch=True, - ) - self.assertEqual(len(linux_target_output), 1) - self.assert_multi_arch_output_is_valid( - target_output=linux_target_output, allow_xfail=True - ) - - entry = linux_target_output[0] - family_info_list = json.loads(entry["matrix_per_family_json"]) - self.assertEqual(len(family_info_list), 1) - self.assertEqual(family_info_list[0]["amdgpu_family"], "gfx94X-dcgpu") - - def test_multi_arch_empty_families_linux_workflow_dispatch(self): - """Test multi_arch mode with empty families produces empty output.""" - build_families = {"amdgpu_families": ""} - linux_target_output, linux_test_labels = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=True, - is_push=False, - is_schedule=False, - base_args={"build_variant": "release"}, - families=build_families, - platform="linux", - multi_arch=True, - ) - self.assertEqual(linux_target_output, []) - self.assertEqual(linux_test_labels, []) - - def test_multi_arch_postsubmit_matrix_generator(self): - """Test multi_arch mode with postsubmit (main branch push).""" - import json - - base_args = {"branch_name": "main", "build_variant": "release"} - linux_target_output, linux_test_labels = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=False, - is_push=True, - is_schedule=False, - base_args=base_args, - families={}, - platform="linux", - multi_arch=True, - ) - # Should produce one entry with all postsubmit families grouped - self.assertEqual(len(linux_target_output), 1) - self.assert_multi_arch_output_is_valid( - target_output=linux_target_output, allow_xfail=True - ) - - entry = linux_target_output[0] - family_info_list = json.loads(entry["matrix_per_family_json"]) - # Postsubmit should have multiple families - self.assertGreaterEqual(len(family_info_list), 1) - # Each entry should have amdgpu_family and test-runs-on - for family_info in family_info_list: - self.assertIn("amdgpu_family", family_info) - self.assertIn("test-runs-on", family_info) - - def test_multi_arch_sanity_check_field_propagation_logic(self): - """Unit test: Verify sanity_check_only_for_family and build_pytorch fields - are correctly propagated into matrix_per_family_json entries. - - Uses synthetic data to test the code logic in isolation. - This test should never need updates unless the code behavior changes. - """ - # Synthetic minimal test matrix - # Use naming convention matching real matrix (e.g., gfx94x, gfx110x - no underscores) - synthetic_matrix = { - "testfamily1": { - "linux": { - "family": "testfamily1-stable", - "test-runs-on": "linux-stable-runner", - "build_variants": ["release"], - # Neither field present - sanity_check defaults False, build_pytorch defaults True - } - }, - "testfamily2": { - "linux": { - "family": "testfamily2-experimental", - "test-runs-on": "linux-experimental-runner", - "build_variants": ["release"], - "sanity_check_only_for_family": True, - "expect_pytorch_failure": True, - } - }, - "testfamily3": { - "linux": { - "family": "testfamily3-explicit-false", - "test-runs-on": "linux-another-runner", - "build_variants": ["release"], - "sanity_check_only_for_family": False, # Explicit False - } - }, - } - - with patch( - "configure_ci.get_all_families_for_trigger_types", - return_value=synthetic_matrix, - ): - build_families = { - "amdgpu_families": "testfamily1, testfamily2, testfamily3" - } - linux_target_output, linux_test_labels = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=True, - is_push=False, - is_schedule=False, - base_args={ - "workflow_dispatch_linux_test_labels": "", - "workflow_dispatch_windows_test_labels": "", - "build_variant": "release", - }, - families=build_families, - platform="linux", - multi_arch=True, - ) - - # Validate multi-arch structure - self.assertEqual(len(linux_target_output), 1) - self.assert_multi_arch_output_is_valid( - target_output=linux_target_output, allow_xfail=True - ) - - # Parse and validate field propagation - entry = linux_target_output[0] - family_info_list = json.loads(entry["matrix_per_family_json"]) - self.assertEqual(len(family_info_list), 3) - - family_dict = {f["amdgpu_family"]: f for f in family_info_list} - - # Verify sanity_check_only_for_family is correctly propagated - self.assertIn("testfamily1-stable", family_dict) - self.assertFalse( - family_dict["testfamily1-stable"]["sanity_check_only_for_family"], - "Missing field should default to False", - ) - - self.assertIn("testfamily2-experimental", family_dict) - self.assertTrue( - family_dict["testfamily2-experimental"]["sanity_check_only_for_family"], - "Explicit True should be preserved", - ) - - self.assertIn("testfamily3-explicit-false", family_dict) - self.assertFalse( - family_dict["testfamily3-explicit-false"][ - "sanity_check_only_for_family" - ], - "Explicit False should be preserved", - ) - - # Verify build_pytorch is correctly propagated per family - self.assertTrue( - family_dict["testfamily1-stable"]["build_pytorch"], - "Missing expect_pytorch_failure should default build_pytorch to True", - ) - self.assertFalse( - family_dict["testfamily2-experimental"]["build_pytorch"], - "expect_pytorch_failure=True should set build_pytorch=False", - ) - self.assertTrue( - family_dict["testfamily3-explicit-false"]["build_pytorch"], - "Missing expect_pytorch_failure should default build_pytorch to True", - ) - - # Verify all entries have both fields as booleans - for family_info in family_info_list: - self.assertIn("sanity_check_only_for_family", family_info) - self.assertIsInstance(family_info["sanity_check_only_for_family"], bool) - self.assertIn("build_pytorch", family_info) - self.assertIsInstance(family_info["build_pytorch"], bool) - - def test_multi_arch_production_sanity_check_configuration(self): - """Integration test: Verify production matrix sanity_check configuration. - - This documents our expected production configuration and catches unintentional changes. - - When this test fails: - 1. Check if the architecture matured (expected) → update expected_families - 2. Check if someone accidentally changed the matrix (bug) → revert the change - - Update this test when architectures are promoted/demoted intentionally. - """ - # Get actual production matrix - matrix = configure_ci.get_all_families_for_trigger_types(["presubmit"]) - - # Document expected production configuration as of 2025-02 - # Update these when architectures mature or new experimental archs are added - expected_families = { - # Stable architectures - should NOT have sanity_check flag - "stable": ["gfx94x"], - # Experimental architectures - SHOULD have sanity_check flag - "experimental": ["gfx110x", "gfx1151"], - } - - # Verify stable architectures - for family in expected_families["stable"]: - if family not in matrix: - self.fail( - f"Stable family '{family}' not in presubmit matrix. " - f"If removed intentionally, update expected_families in this test." - ) - linux_info = matrix[family].get("linux", {}) - sanity_check = linux_info.get("sanity_check_only_for_family", False) - self.assertFalse( - sanity_check, - f"Stable family '{family}' should not have sanity_check_only_for_family=True", - ) - - # Verify experimental architectures - for family in expected_families["experimental"]: - if family not in matrix: - # Allow experimental families to be removed without breaking CI - print( - f"WARNING: Experimental family '{family}' not in matrix (may have been promoted/removed)" - ) - continue - linux_info = matrix[family].get("linux", {}) - sanity_check = linux_info.get("sanity_check_only_for_family", False) - self.assertTrue( - sanity_check, - f"Experimental family '{family}' should have sanity_check_only_for_family=True. " - f"If promoted to stable, move to 'stable' list in expected_families.", - ) - - # Now test end-to-end: pick one stable + one experimental and verify propagation - if not expected_families["stable"] or not expected_families["experimental"]: - self.skipTest("Need at least one stable and one experimental family") - - stable_family = expected_families["stable"][0] - experimental_family = expected_families["experimental"][0] - - # Skip if experimental family was removed - if experimental_family not in matrix: - self.skipTest(f"Experimental family {experimental_family} not available") - - build_families = {"amdgpu_families": f"{stable_family}, {experimental_family}"} - linux_target_output, _ = configure_ci.matrix_generator( - is_pull_request=False, - is_workflow_dispatch=True, - is_push=False, - is_schedule=False, - base_args={ - "workflow_dispatch_linux_test_labels": "", - "workflow_dispatch_windows_test_labels": "", - "build_variant": "release", - }, - families=build_families, - platform="linux", - multi_arch=True, - ) - - self.assertEqual(len(linux_target_output), 1) - self.assert_multi_arch_output_is_valid( - target_output=linux_target_output, allow_xfail=True - ) - - # Verify the production values are correctly propagated - entry = linux_target_output[0] - family_info_list = json.loads(entry["matrix_per_family_json"]) - - stable_arch_name = matrix[stable_family]["linux"]["family"] - experimental_arch_name = matrix[experimental_family]["linux"]["family"] - - family_dict = {f["amdgpu_family"]: f for f in family_info_list} - - self.assertIn(stable_arch_name, family_dict) - self.assertFalse( - family_dict[stable_arch_name]["sanity_check_only_for_family"], - f"Stable family {stable_arch_name} should have sanity_check=False", - ) - self.assertTrue( - family_dict[stable_arch_name]["build_pytorch"], - f"Stable family {stable_arch_name} should have build_pytorch=True", - ) - - self.assertIn(experimental_arch_name, family_dict) - self.assertTrue( - family_dict[experimental_arch_name]["sanity_check_only_for_family"], - f"Experimental family {experimental_arch_name} should have sanity_check=True", - ) - self.assertTrue( - family_dict[experimental_arch_name]["build_pytorch"], - f"Experimental family {experimental_arch_name} should have build_pytorch=True", - ) - # TODO(#3433): Remove sandbox logic once ASAN tests are passing and environment is no longer required def test_sandbox_test_runner_with_asan(self): base_args = {"build_variant": "asan"} diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index c3bfd6ea33a..cea460a3b59 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -178,16 +178,31 @@ def _inputs(self, **kwargs): def test_skip_ci_label(self): """PR with ci:skip label skips CI regardless of changed files.""" - inputs = self._inputs(pr_labels=["ci:skip", "gfx950"]) + inputs = self._inputs(pr_labels=["ci:skip", "ci:run-multi-arch"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) result = cm.check_skip_ci(inputs, git) self.assertTrue(result.skip) self.assertIn("ci:skip", result.reason) + def test_pr_without_multi_arch_label_skips(self): + """PR without ci:run-multi-arch label skips multi-arch CI.""" + inputs = self._inputs(pr_labels=[]) + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.check_skip_ci(inputs, git) + self.assertTrue(result.skip) + self.assertIn("ci:run-multi-arch", result.reason) + + def test_pr_with_multi_arch_label_proceeds(self): + """PR with ci:run-multi-arch label proceeds to path filtering.""" + inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + result = cm.check_skip_ci(inputs, git) + self.assertFalse(result.skip) + @patch("configure_multi_arch_ci.is_ci_run_required", return_value=False) def test_path_filter_says_skip(self, mock_filter): """When is_ci_run_required returns False, skip CI.""" - inputs = self._inputs() + inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) git = cm.GitContext(changed_files=["docs/README.md"]) result = cm.check_skip_ci(inputs, git) self.assertTrue(result.skip) @@ -196,7 +211,7 @@ def test_path_filter_says_skip(self, mock_filter): @patch("configure_multi_arch_ci.is_ci_run_required", return_value=True) def test_path_filter_says_required(self, mock_filter): """When is_ci_run_required returns True, don't skip.""" - inputs = self._inputs() + inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) result = cm.check_skip_ci(inputs, git) self.assertFalse(result.skip) From 53e1d97b742f4b175a316145bcefd5a4988dbe87 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 13:31:07 -0700 Subject: [PATCH 31/56] Comment cleanup 1. --- .../github_actions/configure_multi_arch_ci.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 465a14ef374..811a93ace3f 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -135,18 +135,13 @@ def is_workflow_dispatch(self) -> bool: @staticmethod def from_environ() -> "CIInputs": - """Parse from GitHub Actions environment. - - Reads GITHUB_EVENT_PATH for the event payload and a few standard - env vars. This is the only function in the pipeline that touches - external state. - """ + """Parse from GitHub Actions environment.""" event_name = os.environ.get("GITHUB_EVENT_NAME", "") branch_name = os.environ.get("GITHUB_REF_NAME", "") if not branch_name: raise RuntimeError("GITHUB_REF_NAME is not set.") - # Read the full event payload + # Read the full event webhook payload. event_path = os.environ.get("GITHUB_EVENT_PATH", "") if event_path and Path(event_path).exists(): with open(event_path) as f: @@ -154,7 +149,7 @@ def from_environ() -> "CIInputs": else: event = {} - # Extract fields based on event type + # Extract fields based on event type. inputs = event.get("inputs") or {} pr_labels: list[str] = [] base_ref = "HEAD^1" @@ -162,12 +157,12 @@ def from_environ() -> "CIInputs": if event_name == "pull_request": pr_obj = event.get("pull_request", {}) pr_labels = [label["name"].lower() for label in pr_obj.get("labels", [])] - # The merge commit's first parent is the PR base + # The merge commit's first parent is the PR base. base_ref = "HEAD^" elif event_name == "push": base_ref = event.get("before", "HEAD^1") - # BUILD_VARIANT comes from workflow_call inputs, not the event payload + # BUILD_VARIANT comes from workflow_call inputs, not the event payload. build_variant = os.environ.get("BUILD_VARIANT", "release") return CIInputs( @@ -213,7 +208,11 @@ def from_repo(base_ref: str) -> "GitContext": @staticmethod def empty() -> "GitContext": - """No git data (schedule/workflow_dispatch).""" + """Empty context with no git data. + + This should typically be used for schedule/workflow_dispatch events + where we don't want to diff against a prior commit. + """ return GitContext() def log(self) -> None: From 9b6eeb8f61240246e11566c89c7a136d716d27ce Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 13:36:46 -0700 Subject: [PATCH 32/56] Simplify skip CI gate: remove SkipDecision, return bool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the SkipDecision dataclass with a plain bool return from the renamed should_skip_ci() function. Skip reasons are logged directly before returning True, removing an indirection layer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 38 +++++++------------ .../tests/configure_multi_arch_ci_test.py | 38 ++++++++----------- 2 files changed, 28 insertions(+), 48 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 811a93ace3f..31d3ef8857e 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -227,14 +227,6 @@ def log(self) -> None: print(f" ... and {len(self.changed_files) - 20} more") -@dataclass(frozen=True) -class SkipDecision: - """Whether to skip CI entirely.""" - - skip: bool - reason: str # e.g. "ci:skip label", "only .md files changed", "" - - @dataclass(frozen=True) class TargetSelection: """Which GPU families to build/test, per platform.""" @@ -414,7 +406,7 @@ class CIOutputs: windows_test_labels: str = "" @staticmethod - def skipped(reason: str) -> "CIOutputs": + def skipped() -> "CIOutputs": """Produce empty outputs when CI is skipped.""" return CIOutputs(is_ci_enabled=False) @@ -424,13 +416,13 @@ def skipped(reason: str) -> "CIOutputs": # --------------------------------------------------------------------------- -def check_skip_ci( +def should_skip_ci( ci_inputs: CIInputs, git_context: GitContext, -) -> SkipDecision: +) -> bool: """Determine whether CI should be skipped entirely. - Returns SkipDecision(skip=True) for: + Returns True for: - 'ci:skip' PR label - pull_request without 'ci:run-multi-arch' label (opt-in during transition) - Only skippable files changed (docs, .md, etc.) @@ -440,17 +432,14 @@ def check_skip_ci( for those triggers, and they have no PR labels). """ if "ci:skip" in ci_inputs.pr_labels: - print(" Found 'ci:skip' PR label") - return SkipDecision(skip=True, reason="ci:skip label") + print(" Skipping: 'ci:skip' PR label") + return True # Multi-arch CI on PRs requires explicit opt-in via label to avoid # doubling CI load during the transition. See #3337. if ci_inputs.is_pull_request and "ci:run-multi-arch" not in ci_inputs.pr_labels: - print(" PR without 'ci:run-multi-arch' label — skipping multi-arch CI") - return SkipDecision( - skip=True, - reason="ci:run-multi-arch label not found (add to opt in)", - ) + print(" Skipping: PR without 'ci:run-multi-arch' label") + return True # changed_files is None for schedule/workflow_dispatch — always proceed. if git_context.changed_files is not None: @@ -459,9 +448,10 @@ def check_skip_ci( f"against path filters..." ) if not is_ci_run_required(git_context.changed_files): - return SkipDecision(skip=True, reason="no CI-relevant files changed") + print(" Skipping: no CI-relevant files changed") + return True - return SkipDecision(skip=False, reason="") + return False # --------------------------------------------------------------------------- @@ -899,10 +889,8 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: git_context.log() print("\n=== Checking if CI should run ===") - skip_decision = check_skip_ci(ci_inputs=ci_inputs, git_context=git_context) - if skip_decision.skip: - print(f"Result: skipping CI ({skip_decision.reason})") - return CIOutputs.skipped(skip_decision.reason) + if should_skip_ci(ci_inputs=ci_inputs, git_context=git_context): + return CIOutputs.skipped() print("Result: CI will run") print("\n=== Deciding job configuration ===") diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index cea460a3b59..5c3599b769b 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -158,11 +158,11 @@ def test_push_reads_before_sha(self): # --------------------------------------------------------------------------- -class TestCheckSkipCI(unittest.TestCase): +class TestShouldSkipCI(unittest.TestCase): """Test the skip CI gate. Path filtering logic is tested in configure_ci_path_filters_test.py. - These tests mock is_ci_run_required and verify check_skip_ci's own + These tests mock is_ci_run_required and verify should_skip_ci's own logic: label handling, None changed_files passthrough, and delegation. """ @@ -180,32 +180,26 @@ def test_skip_ci_label(self): """PR with ci:skip label skips CI regardless of changed files.""" inputs = self._inputs(pr_labels=["ci:skip", "ci:run-multi-arch"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) - result = cm.check_skip_ci(inputs, git) - self.assertTrue(result.skip) - self.assertIn("ci:skip", result.reason) + self.assertTrue(cm.should_skip_ci(inputs, git)) def test_pr_without_multi_arch_label_skips(self): """PR without ci:run-multi-arch label skips multi-arch CI.""" inputs = self._inputs(pr_labels=[]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) - result = cm.check_skip_ci(inputs, git) - self.assertTrue(result.skip) - self.assertIn("ci:run-multi-arch", result.reason) + self.assertTrue(cm.should_skip_ci(inputs, git)) def test_pr_with_multi_arch_label_proceeds(self): """PR with ci:run-multi-arch label proceeds to path filtering.""" inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) - result = cm.check_skip_ci(inputs, git) - self.assertFalse(result.skip) + self.assertFalse(cm.should_skip_ci(inputs, git)) @patch("configure_multi_arch_ci.is_ci_run_required", return_value=False) def test_path_filter_says_skip(self, mock_filter): """When is_ci_run_required returns False, skip CI.""" inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) git = cm.GitContext(changed_files=["docs/README.md"]) - result = cm.check_skip_ci(inputs, git) - self.assertTrue(result.skip) + self.assertTrue(cm.should_skip_ci(inputs, git)) mock_filter.assert_called_once_with(["docs/README.md"]) @patch("configure_multi_arch_ci.is_ci_run_required", return_value=True) @@ -213,16 +207,14 @@ def test_path_filter_says_required(self, mock_filter): """When is_ci_run_required returns True, don't skip.""" inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) - result = cm.check_skip_ci(inputs, git) - self.assertFalse(result.skip) + self.assertFalse(cm.should_skip_ci(inputs, git)) @patch("configure_multi_arch_ci.is_ci_run_required") def test_none_changed_files_skips_path_filter(self, mock_filter): """schedule/workflow_dispatch pass None → path filter not called.""" inputs = self._inputs(event_name="schedule") git = cm.GitContext() - result = cm.check_skip_ci(inputs, git) - self.assertFalse(result.skip) + self.assertFalse(cm.should_skip_ci(inputs, git)) mock_filter.assert_not_called() @@ -704,7 +696,7 @@ def _inputs(self, **kwargs): return cm.CIInputs(**defaults) def test_skipped_summary_does_not_raise(self): - outputs = cm.CIOutputs.skipped("only .md files changed") + outputs = cm.CIOutputs.skipped() git = cm.GitContext(changed_files=["docs/README.md"]) format_summary(self._inputs(), git, outputs) @@ -732,15 +724,15 @@ class TestConfigurePipeline(unittest.TestCase): def test_skipped_outputs(self): """CIOutputs.skipped produces empty, disabled outputs.""" - outputs = cm.CIOutputs.skipped("test reason") + outputs = cm.CIOutputs.skipped() self.assertFalse(outputs.is_ci_enabled) self.assertIsNone(outputs.builds.linux) self.assertIsNone(outputs.builds.windows) - @patch("configure_multi_arch_ci.check_skip_ci") + @patch("configure_multi_arch_ci.should_skip_ci") def test_pipeline_skips_when_gate_says_skip(self, mock_skip): - """If check_skip_ci returns skip=True, pipeline short-circuits.""" - mock_skip.return_value = cm.SkipDecision(skip=True, reason="ci:skip label") + """If should_skip_ci returns True, pipeline short-circuits.""" + mock_skip.return_value = True inputs = cm.CIInputs( event_name="workflow_dispatch", branch_name="main", @@ -751,7 +743,7 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): self.assertFalse(outputs.is_ci_enabled) self.assertIsNone(outputs.builds.linux) - @patch("configure_multi_arch_ci.check_skip_ci") + @patch("configure_multi_arch_ci.should_skip_ci") @patch("configure_multi_arch_ci.select_targets") @patch("configure_multi_arch_ci.decide_jobs") @patch("configure_multi_arch_ci.expand_build_configs") @@ -759,7 +751,7 @@ def test_pipeline_calls_all_steps( self, mock_expand, mock_jobs, mock_targets, mock_skip ): """When not skipped, all pipeline steps are called.""" - mock_skip.return_value = cm.SkipDecision(skip=False, reason="") + mock_skip.return_value = False mock_targets.return_value = cm.TargetSelection( linux_families=["gfx94x"], windows_families=[], From be196efcbc7c86bb4bf4884008ca5955f6cb5a29 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 13:51:53 -0700 Subject: [PATCH 33/56] Comment/logging cleanup 2 --- .../github_actions/configure_multi_arch_ci.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 31d3ef8857e..f5588b0dbc7 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -6,7 +6,7 @@ This script is a pipeline of data transformations: - 1. Parse Inputs — read GitHub event context → CIInputs + 1. Parse Inputs — read GitHub event context → CIInputs, GitContext 2. Check Skip CI — gate: should we skip CI entirely? 3. Decide Jobs — changed files + topology → per-job-group decisions 4. Select Targets — trigger type + labels → per-platform GPU families @@ -21,6 +21,8 @@ build-rocm → test-rocm → build-rocm-python → build-pytorch → test-pytorch → build-jax → test-jax (future) + → build-native-linux → test-native-linux (future) + → build-native-windows → test-native-windows (future) Step 4 determines which job groups to run, skip, or satisfy with prebuilt artifacts. Within build-rocm, per-stage rebuild/prebuilt granularity is @@ -427,21 +429,21 @@ def should_skip_ci( - pull_request without 'ci:run-multi-arch' label (opt-in during transition) - Only skippable files changed (docs, .md, etc.) - No files changed - - schedule and workflow_dispatch always proceed (changed_files is None - for those triggers, and they have no PR labels). """ if "ci:skip" in ci_inputs.pr_labels: print(" Skipping: 'ci:skip' PR label") return True # Multi-arch CI on PRs requires explicit opt-in via label to avoid - # doubling CI load during the transition. See #3337. + # doubling CI load during the transition. + # See https://github.com/ROCm/TheRock/issues/3337. if ci_inputs.is_pull_request and "ci:run-multi-arch" not in ci_inputs.pr_labels: print(" Skipping: PR without 'ci:run-multi-arch' label") return True - # changed_files is None for schedule/workflow_dispatch — always proceed. + # If we have a list of changed files (push/pull_request events), check if + # CI should run for that set of changed files. For example: if only .md + # files are changed, skip CI. if git_context.changed_files is not None: print( f" Checking {len(git_context.changed_files)} changed file(s) " @@ -450,6 +452,8 @@ def should_skip_ci( if not is_ci_run_required(git_context.changed_files): print(" Skipping: no CI-relevant files changed") return True + else: + print(" CI-relevant files changed, running CI") return False @@ -927,12 +931,13 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: def main(): ci_inputs = CIInputs.from_environ() - # Build git context for push/PR triggers (need changed files for - # ci:skip and test_type decisions). Schedule/workflow_dispatch don't - # need git data. if ci_inputs.is_pull_request or ci_inputs.is_push: + # 'pull_request' and 'push' events can use the list of changed files + # compared to the "prior commit" to affect job selections/options. git_context = GitContext.from_repo(base_ref=ci_inputs.base_ref) else: + # 'workflow_dispatch' and 'schedule' events don't have as natural + # a "prior commit" to compare against. git_context = GitContext.empty() outputs = configure(ci_inputs, git_context) From ea5761a7da86d7967a0d987e6de6132cd5990d57 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 15:19:55 -0700 Subject: [PATCH 34/56] Replace action strings with JobAction enum, remove reason fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce JobAction enum (RUN, PREBUILT, SKIP) replacing string literals on JobGroupDecision and StageDecision. Remove unused reason fields from both — reasons are logged at decision time. StageDecision is replaced by JobAction directly in the stage_decisions dict. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 102 +++++++++--------- .../tests/configure_multi_arch_ci_test.py | 45 ++++---- 2 files changed, 71 insertions(+), 76 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index f5588b0dbc7..93f5d635c29 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -46,11 +46,12 @@ test_type : "quick", "standard", "comprehensive", or "full" """ +import enum import json import os from dataclasses import dataclass, field from pathlib import Path -from typing import Literal + from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types from configure_ci_path_filters import ( @@ -255,27 +256,28 @@ def log(self) -> None: # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class JobGroupDecision: - """Decision for one node in the CI job graph.""" +class JobAction(enum.Enum): + """Action for a node in the CI job graph or a build stage.""" - action: Literal["run", "prebuilt", "skip"] - reason: str + RUN = "run" + PREBUILT = "prebuilt" + SKIP = "skip" @dataclass(frozen=True) -class StageDecision: - """Decision for a single build stage within build-rocm.""" +class JobGroupDecision: + """Decision for one node in the CI job graph. - action: Literal["rebuild", "prebuilt"] - reason: str + Nodes may inherit from this base class to add additional options.""" + + action: JobAction @dataclass(frozen=True) class BuildRocmDecision(JobGroupDecision): """Build-rocm job group with per-stage granularity.""" - stage_decisions: dict[str, StageDecision] = field(default_factory=dict) + stage_decisions: dict[str, JobAction] = field(default_factory=dict) # Run ID to fetch prebuilt stage artifacts from. Currently passed through # from workflow_dispatch input; TODO(#3399): derive automatically from # the current commit's parent workflow run. @@ -284,13 +286,17 @@ class BuildRocmDecision(JobGroupDecision): @property def prebuilt_stages(self) -> list[str]: return [ - name for name, d in self.stage_decisions.items() if d.action == "prebuilt" + name + for name, action in self.stage_decisions.items() + if action == JobAction.PREBUILT ] @property def rebuild_stages(self) -> list[str]: return [ - name for name, d in self.stage_decisions.items() if d.action == "rebuild" + name + for name, action in self.stage_decisions.items() + if action == JobAction.RUN ] @@ -330,11 +336,11 @@ def log(self) -> None: f" test_type: {self.test_rocm.test_type} " f"({self.test_rocm.test_type_reason})" ) - print(f" build_rocm: {self.build_rocm.action}") - print(f" test_rocm: {self.test_rocm.action}") - print(f" build_rocm_python: {self.build_rocm_python.action}") - print(f" build_pytorch: {self.build_pytorch.action}") - print(f" test_pytorch: {self.test_pytorch.action}") + print(f" build_rocm: {self.build_rocm.action.value}") + print(f" test_rocm: {self.test_rocm.action.value}") + print(f" build_rocm_python: {self.build_rocm_python.action.value}") + print(f" build_pytorch: {self.build_pytorch.action.value}") + print(f" test_pytorch: {self.test_pytorch.action.value}") @dataclass(frozen=True) @@ -545,47 +551,43 @@ def decide_jobs( ci_inputs: CIInputs, git_context: GitContext, ) -> JobDecisions: - """Determine which job groups to run, skip, or satisfy with prebuilt files. + """Determine which job groups to run, skip, or satisfy with prebuilt files.""" - All job groups currently run unconditionally. test_type is determined - based on trigger type, labels, and changed files. + # Build ROCm. + # TODO(#3399): Use changed files and build_topology.py to: + # 1. set per-stage prebuilt decisions + # 2. skip job groups that aren't reachable from the changed files + # Parse explicit prebuilt stages from workflow_dispatch input. + stage_decisions: dict[str, JobAction] = {} + if ci_inputs.prebuilt_stages: + for stage in _parse_comma_list(ci_inputs.prebuilt_stages): + stage_decisions[stage] = JobAction.PREBUILT + build_rocm = BuildRocmDecision( + action=JobAction.RUN, + stage_decisions=stage_decisions, + baseline_run_id=ci_inputs.baseline_run_id, + ) - TODO(#3399): Use changed files and BUILD_TOPOLOGY.toml to set per-stage - prebuilt decisions in BuildRocmDecision.stage_decisions, and skip job - groups that aren't reachable from the changed files. - """ + # Test ROCm. test_type, test_type_reason = _determine_test_type( ci_inputs=ci_inputs, git_context=git_context, ) + test_rocm = TestRocmDecision( + action=JobAction.RUN, + test_type=test_type, + test_type_reason=test_type_reason, + ) - # Parse explicit prebuilt stages from workflow_dispatch input. - # TODO(#3399): Also derive prebuilt stages automatically from changed - # files and BUILD_TOPOLOGY.toml for pull_request triggers. - stage_decisions: dict[str, StageDecision] = {} - if ci_inputs.prebuilt_stages: - for stage in _parse_comma_list(ci_inputs.prebuilt_stages): - stage_decisions[stage] = StageDecision( - action="prebuilt", - reason="explicit workflow_dispatch input", - ) + # Other jobs run unconditionally with no configuration. + # TODO: job pruning: skip pytorch if only JAX has been edited, etc. return JobDecisions( - build_rocm=BuildRocmDecision( - action="run", - reason="default", - stage_decisions=stage_decisions, - baseline_run_id=ci_inputs.baseline_run_id, - ), - test_rocm=TestRocmDecision( - action="run", - reason="default", - test_type=test_type, - test_type_reason=test_type_reason, - ), - build_rocm_python=JobGroupDecision(action="run", reason="default"), - build_pytorch=JobGroupDecision(action="run", reason="default"), - test_pytorch=JobGroupDecision(action="run", reason="default"), + build_rocm=build_rocm, + test_rocm=test_rocm, + build_rocm_python=JobGroupDecision(action=JobAction.RUN), + build_pytorch=JobGroupDecision(action=JobAction.RUN), + test_pytorch=JobGroupDecision(action=JobAction.RUN), ) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 5c3599b769b..ccbe9bfab4a 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -240,11 +240,11 @@ def test_all_job_groups_run(self): """All job groups are set to run (subgraph selection is Phase 4).""" result = cm.decide_jobs(self._inputs(), git_context=cm.GitContext()) self.assertIsInstance(result, cm.JobDecisions) - self.assertEqual(result.build_rocm.action, "run") - self.assertEqual(result.test_rocm.action, "run") - self.assertEqual(result.build_rocm_python.action, "run") - self.assertEqual(result.build_pytorch.action, "run") - self.assertEqual(result.test_pytorch.action, "run") + self.assertEqual(result.build_rocm.action, cm.JobAction.RUN) + self.assertEqual(result.test_rocm.action, cm.JobAction.RUN) + self.assertEqual(result.build_rocm_python.action, cm.JobAction.RUN) + self.assertEqual(result.build_pytorch.action, cm.JobAction.RUN) + self.assertEqual(result.test_pytorch.action, cm.JobAction.RUN) def test_default_test_type_is_quick(self): """Default test_type for PR/push with no special conditions.""" @@ -341,16 +341,11 @@ def test_no_prebuilt_stages_by_default(self): def test_build_rocm_stage_partitioning(self): """BuildRocmDecision correctly partitions stages into prebuilt/rebuild.""" decision = cm.BuildRocmDecision( - action="run", - reason="source changes", + action=cm.JobAction.RUN, stage_decisions={ - "foundation": cm.StageDecision(action="prebuilt", reason="no changes"), - "compiler-runtime": cm.StageDecision( - action="prebuilt", reason="no changes" - ), - "math-libs": cm.StageDecision( - action="rebuild", reason="rocm-libraries changed" - ), + "foundation": cm.JobAction.PREBUILT, + "compiler-runtime": cm.JobAction.PREBUILT, + "math-libs": cm.JobAction.RUN, }, ) self.assertEqual( @@ -702,13 +697,11 @@ def test_skipped_summary_does_not_raise(self): def test_normal_summary_does_not_raise(self): jobs = cm.JobDecisions( - build_rocm=cm.BuildRocmDecision(action="run", reason="default"), - test_rocm=cm.TestRocmDecision( - action="run", reason="default", test_type="full" - ), - build_rocm_python=cm.JobGroupDecision(action="run", reason="default"), - build_pytorch=cm.JobGroupDecision(action="run", reason="default"), - test_pytorch=cm.JobGroupDecision(action="run", reason="default"), + build_rocm=cm.BuildRocmDecision(action=cm.JobAction.RUN), + test_rocm=cm.TestRocmDecision(action=cm.JobAction.RUN, test_type="full"), + build_rocm_python=cm.JobGroupDecision(action=cm.JobAction.RUN), + build_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), + test_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), ) outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) format_summary(self._inputs(), cm.GitContext(), outputs) @@ -757,11 +750,11 @@ def test_pipeline_calls_all_steps( windows_families=[], ) mock_jobs.return_value = cm.JobDecisions( - build_rocm=cm.BuildRocmDecision(action="run", reason="default"), - test_rocm=cm.TestRocmDecision(action="run", reason="default"), - build_rocm_python=cm.JobGroupDecision(action="run", reason="default"), - build_pytorch=cm.JobGroupDecision(action="run", reason="default"), - test_pytorch=cm.JobGroupDecision(action="run", reason="default"), + build_rocm=cm.BuildRocmDecision(action=cm.JobAction.RUN), + test_rocm=cm.TestRocmDecision(action=cm.JobAction.RUN), + build_rocm_python=cm.JobGroupDecision(action=cm.JobAction.RUN), + build_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), + test_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), ) mock_expand.return_value = cm.BuildConfigs() From fff9e8f2e6c486cd0e2513b973f86aceb8d05b8d Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 15:26:38 -0700 Subject: [PATCH 35/56] Keep prebuilt_stages as list[str] internally, serialize only at output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIInputs.prebuilt_stages stays str (raw user input). After parsing in decide_jobs, everything uses list[str] through BuildRocmDecision, expand_build_configs, and BuildConfig. Comma-join only happens in BuildConfig.to_dict() at the serialization boundary. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 93f5d635c29..eda0bd7796b 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -360,7 +360,7 @@ class BuildConfig: expect_failure: bool build_pytorch: bool # Prebuilt stage configuration — set by configure() from JobDecisions. - prebuilt_stages: str = "" + prebuilt_stages: list[str] = field(default_factory=list) baseline_run_id: str = "" def to_dict(self) -> dict: @@ -374,7 +374,7 @@ def to_dict(self) -> dict: "build_variant_cmake_preset": self.build_variant_cmake_preset, "expect_failure": self.expect_failure, "build_pytorch": self.build_pytorch, - "prebuilt_stages": self.prebuilt_stages, + "prebuilt_stages": ",".join(self.prebuilt_stages), "baseline_run_id": self.baseline_run_id, } @@ -723,7 +723,7 @@ def _expand_build_config_for_platform( build_variant: str, all_families: dict[str, dict], variant_config: dict, - prebuilt_stages: str = "", + prebuilt_stages: list[str] | None = None, baseline_run_id: str = "", ) -> BuildConfig | None: """Build a BuildConfig for one platform, or None if no families match. @@ -781,7 +781,7 @@ def _expand_build_config_for_platform( build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], expect_failure=expect_failure, build_pytorch=not expect_failure and not expect_pytorch_failure, - prebuilt_stages=prebuilt_stages, + prebuilt_stages=prebuilt_stages or [], baseline_run_id=baseline_run_id, ) @@ -789,7 +789,7 @@ def _expand_build_config_for_platform( def expand_build_configs( targets: TargetSelection, build_variant: str, - prebuilt_stages: str = "", + prebuilt_stages: list[str] | None = None, baseline_run_id: str = "", ) -> BuildConfigs: """Build a BuildConfig for each platform that supports the variant. @@ -911,7 +911,7 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: builds = expand_build_configs( targets=targets, build_variant=ci_inputs.build_variant, - prebuilt_stages=",".join(jobs.build_rocm.prebuilt_stages), + prebuilt_stages=jobs.build_rocm.prebuilt_stages, baseline_run_id=jobs.build_rocm.baseline_run_id, ) builds.log() From 843b188c728ffd998ccf7b84278f00f3d794b604 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 15:29:31 -0700 Subject: [PATCH 36/56] Trim summary formatting --- .../configure_multi_arch_ci_summary.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 7ce956ffe1d..d082b5f0b20 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -5,21 +5,12 @@ design rationale and example outputs. """ -import json - from configure_multi_arch_ci import ( CIInputs, CIOutputs, GitContext, ) -_DAG = """\ -``` -# Build graph -build-rocm ──┬── test-rocm - └── build-rocm-python ── build-pytorch -```""" - _PATH_FILTERS_URL = ( "https://github.com/ROCm/TheRock/blob/main/" "build_tools/github_actions/configure_ci_path_filters.py" @@ -61,10 +52,6 @@ def format_summary( lines.append(f"> - {callout}") lines.append("") - # Fixed DAG - lines.append(_DAG) - lines.append("") - # build-rocm lines.append("### build-rocm") lines.append("") From 73afa6656609517b3c17c51281f3365330e5b30f Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 15:34:32 -0700 Subject: [PATCH 37/56] Improve step summary: ci: labels, per-platform test labels, split component tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include all ci: PR labels in non-default callouts - Show workflow_dispatch test labels per platform in callouts - Split "Component tests" line per platform (Linux/Windows) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../configure_multi_arch_ci_summary.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index d082b5f0b20..ac50549b6a8 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -106,10 +106,23 @@ def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: ) elif label.startswith("test_filter:"): callouts.append( - f"Label `{label}`: overrode test level " f"(default would be `quick`)" + f"Label `{label}`: overrode test level (default would be `quick`)" ) elif label.startswith("test:"): callouts.append(f"Label `{label}`: requested component tests") + elif label.startswith("ci:"): + callouts.append(f"Label `{label}`") + + # Explicit test labels (workflow_dispatch) + if ci_inputs.is_workflow_dispatch: + if ci_inputs.linux_test_labels: + callouts.append( + f"Explicit Linux test labels: `{ci_inputs.linux_test_labels}`" + ) + if ci_inputs.windows_test_labels: + callouts.append( + f"Explicit Windows test labels: `{ci_inputs.windows_test_labels}`" + ) # Prebuilt stages if jobs and jobs.build_rocm.prebuilt_stages: @@ -170,15 +183,11 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: f"Test level: **{test_rocm.test_type}** ({test_rocm.test_type_reason})" ) - # Component test labels - test_labels = [] + # Component test labels (per platform) if outputs.linux_test_labels: - test_labels.append(outputs.linux_test_labels) + lines.append(f"Component tests (Linux): `{outputs.linux_test_labels}`") if outputs.windows_test_labels: - test_labels.append(outputs.windows_test_labels) - if test_labels: - labels_str = ", ".join(f"`{t}`" for t in test_labels) - lines.append(f"Component tests: {labels_str}") + lines.append(f"Component tests (Windows): `{outputs.windows_test_labels}`") lines.append("") # Per-family test runner table From 1a1855e3f5f723e85ad4fa8a0584dffc18764862 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 15:53:27 -0700 Subject: [PATCH 38/56] Fix various issues spotted during self-review --- .github/workflows/multi_arch_ci.yml | 1 - .github/workflows/multi_arch_ci_windows.yml | 13 +++--- .../configure_multi_arch_ci_summary.py | 6 +-- .../tests/configure_multi_arch_ci_test.py | 45 ------------------- 4 files changed, 7 insertions(+), 58 deletions(-) diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 8ab6dbdf1c3..e97d2007d60 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -98,7 +98,6 @@ jobs: test_labels: ${{ needs.setup.outputs.windows_test_labels }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} test_type: ${{ needs.setup.outputs.test_type }} - build_pytorch: ${{ matrix.variant.build_pytorch == true }} permissions: contents: read id-token: write diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index 6cddb7bf07b..535ba11161e 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -20,9 +20,6 @@ on: type: string test_type: type: string - build_pytorch: - type: boolean - default: false permissions: contents: read @@ -127,11 +124,11 @@ jobs: build_python_packages: needs: [build_multi_arch_stages] name: Build Python Packages - if: ${{ !failure() && !cancelled() && inputs.expect_failure == false }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).expect_failure == false }} uses: ./.github/workflows/build_windows_python_packages.yml with: - artifact_group: ${{ inputs.artifact_group }} - amdgpu_families: ${{ inputs.dist_amdgpu_families }} + artifact_group: ${{ fromJSON(inputs.build_config).artifact_group }} + amdgpu_families: ${{ fromJSON(inputs.build_config).dist_amdgpu_families }} multiarch_index: true package_version: ${{ inputs.rocm_package_version }} permissions: @@ -145,11 +142,11 @@ jobs: build_pytorch_wheels_per_family: needs: [build_python_packages] name: Build PyTorch | ${{ matrix.family_info.amdgpu_family }} - if: ${{ !failure() && !cancelled() && inputs.build_pytorch == true }} + if: ${{ !failure() && !cancelled() && fromJSON(inputs.build_config).build_pytorch == true }} strategy: fail-fast: false matrix: - family_info: ${{ fromJSON(inputs.matrix_per_family_json) }} + family_info: ${{ fromJSON(inputs.build_config).per_family_info }} uses: ./.github/workflows/build_windows_pytorch_wheels_ci.yml with: artifact_group: ${{ matrix.family_info.amdgpu_family }} diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index ac50549b6a8..5af340ed311 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -55,7 +55,7 @@ def format_summary( # build-rocm lines.append("### build-rocm") lines.append("") - _append_build_rocm(lines, outputs, ci_inputs.build_variant) + _append_build_rocm(lines, outputs) # test-rocm lines.append("### test-rocm") @@ -137,9 +137,7 @@ def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: return callouts -def _append_build_rocm( - lines: list[str], outputs: CIOutputs, build_variant: str -) -> None: +def _append_build_rocm(lines: list[str], outputs: CIOutputs) -> None: jobs = outputs.jobs # Prebuilt info diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index ccbe9bfab4a..919e390541a 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -736,42 +736,6 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): self.assertFalse(outputs.is_ci_enabled) self.assertIsNone(outputs.builds.linux) - @patch("configure_multi_arch_ci.should_skip_ci") - @patch("configure_multi_arch_ci.select_targets") - @patch("configure_multi_arch_ci.decide_jobs") - @patch("configure_multi_arch_ci.expand_build_configs") - def test_pipeline_calls_all_steps( - self, mock_expand, mock_jobs, mock_targets, mock_skip - ): - """When not skipped, all pipeline steps are called.""" - mock_skip.return_value = False - mock_targets.return_value = cm.TargetSelection( - linux_families=["gfx94x"], - windows_families=[], - ) - mock_jobs.return_value = cm.JobDecisions( - build_rocm=cm.BuildRocmDecision(action=cm.JobAction.RUN), - test_rocm=cm.TestRocmDecision(action=cm.JobAction.RUN), - build_rocm_python=cm.JobGroupDecision(action=cm.JobAction.RUN), - build_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), - test_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), - ) - mock_expand.return_value = cm.BuildConfigs() - - inputs = cm.CIInputs( - event_name="workflow_dispatch", - branch_name="main", - base_ref="HEAD^1", - build_variant="release", - ) - outputs = cm.configure(inputs, cm.GitContext()) - - self.assertTrue(outputs.is_ci_enabled) - self.assertIsNotNone(outputs.jobs) - mock_targets.assert_called_once() - mock_jobs.assert_called_once() - mock_expand.assert_called_once() - # --------------------------------------------------------------------------- # Contract: BuildConfig fields match workflow YAML references @@ -822,9 +786,6 @@ def test_linux_workflow_uses_all_fields(self): f" In Python but not YAML: {python_fields - yaml_fields}", ) - @unittest.skip( - "Windows doesn't build pytorch on multi-arch CI yet — build_pytorch field unused" - ) def test_windows_workflow_uses_all_fields(self): """Windows workflow should reference every BuildConfig field.""" workflow_path = WORKFLOWS_DIR / "multi_arch_ci_windows.yml" @@ -838,12 +799,6 @@ def test_windows_workflow_uses_all_fields(self): f" In Python but not YAML: {python_fields - yaml_fields}", ) - def test_windows_workflow_no_unknown_fields(self): - """Windows workflow should not reference fields that don't exist.""" - self._assert_yaml_fields_subset_of_python( - WORKFLOWS_DIR / "multi_arch_ci_windows.yml" - ) - if __name__ == "__main__": unittest.main() From 2edeb1de88cac5cf41c491265c2093cae14901c3 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 16:01:50 -0700 Subject: [PATCH 39/56] Add TODO for run_functional_tests --- build_tools/github_actions/configure_multi_arch_ci.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index eda0bd7796b..01c41dab274 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -313,6 +313,9 @@ class TestRocmDecision(JobGroupDecision): test_type: str = "quick" test_type_reason: str = "default" + # TODO: Consolidate test_type, test labels, and run_functional_tests + # (from the single-arch pipeline) into a per-platform test config object + # (e.g. linux_test_config JSON) instead of separate top-level outputs. @dataclass(frozen=True) From 808e5593491809b956ba446972aac7207c857118 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 16:07:35 -0700 Subject: [PATCH 40/56] Remove redundant plumbing --- .github/workflows/multi_arch_ci.yml | 4 ++-- .github/workflows/setup_multi_arch.yml | 8 ------- .../github_actions/configure_multi_arch_ci.py | 8 ++----- .../tests/configure_multi_arch_ci_test.py | 23 +++++++++++++++++++ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index e97d2007d60..61d612b67e7 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -69,7 +69,7 @@ jobs: needs: setup if: >- ${{ - needs.setup.outputs.linux_build_enabled == 'true' && + needs.setup.outputs.linux_build_config != '' && needs.setup.outputs.enable_build_jobs == 'true' }} uses: ./.github/workflows/multi_arch_ci_linux.yml @@ -88,7 +88,7 @@ jobs: needs: setup if: >- ${{ - needs.setup.outputs.windows_build_enabled == 'true' && + needs.setup.outputs.windows_build_config != '' && needs.setup.outputs.enable_build_jobs == 'true' }} uses: ./.github/workflows/multi_arch_ci_windows.yml diff --git a/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml index 1d674cd5581..8d1101e6937 100644 --- a/.github/workflows/setup_multi_arch.yml +++ b/.github/workflows/setup_multi_arch.yml @@ -16,15 +16,9 @@ on: linux_build_config: description: JSON object with Linux build configuration, or empty string if skipped. value: ${{ jobs.setup.outputs.linux_build_config }} - linux_build_enabled: - description: Whether Linux builds are enabled. - value: ${{ jobs.setup.outputs.linux_build_enabled }} windows_build_config: description: JSON object with Windows build configuration, or empty string if skipped. value: ${{ jobs.setup.outputs.windows_build_config }} - windows_build_enabled: - description: Whether Windows builds are enabled. - value: ${{ jobs.setup.outputs.windows_build_enabled }} test_type: description: "The test type to run (quick, standard, comprehensive, full)." value: ${{ jobs.setup.outputs.test_type }} @@ -47,9 +41,7 @@ jobs: outputs: enable_build_jobs: ${{ steps.configure.outputs.enable_build_jobs }} linux_build_config: ${{ steps.configure.outputs.linux_build_config }} - linux_build_enabled: ${{ steps.configure.outputs.linux_build_enabled }} windows_build_config: ${{ steps.configure.outputs.windows_build_config }} - windows_build_enabled: ${{ steps.configure.outputs.windows_build_enabled }} test_type: ${{ steps.configure.outputs.test_type }} linux_test_labels: ${{ steps.configure.outputs.linux_test_labels }} windows_test_labels: ${{ steps.configure.outputs.windows_test_labels }} diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 01c41dab274..7abcc349cc4 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -40,8 +40,6 @@ Outputs (written to GITHUB_OUTPUT): linux_build_config : JSON object with build config, or "" if skipped windows_build_config : JSON object with build config, or "" if skipped - linux_build_enabled : "true" or "false" - windows_build_enabled : "true" or "false" enable_build_jobs : "true" or "false" test_type : "quick", "standard", "comprehensive", or "full" """ @@ -856,12 +854,10 @@ def write_outputs( linux = outputs.builds.linux windows = outputs.builds.windows output_vars = { - "linux_build_config": json.dumps(linux.to_dict()) if linux else "", - "windows_build_config": json.dumps(windows.to_dict()) if windows else "", - "linux_build_enabled": json.dumps(linux is not None), - "windows_build_enabled": json.dumps(windows is not None), # Workflow YAML references this as 'enable_build_jobs' "enable_build_jobs": json.dumps(outputs.is_ci_enabled), + "linux_build_config": json.dumps(linux.to_dict()) if linux else "", + "windows_build_config": json.dumps(windows.to_dict()) if windows else "", "test_type": test_type, "linux_test_labels": outputs.linux_test_labels, "windows_test_labels": outputs.windows_test_labels, diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 919e390541a..c4d84cc1f77 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -578,6 +578,29 @@ def test_empty_targets_both_none(self): self.assertIsNone(result.linux) self.assertIsNone(result.windows) + def test_build_config_serialization_empty_vs_present(self): + """Workflow YAML gates on build_config != '', so None must serialize + to '' and present configs must serialize to valid JSON.""" + config = cm.BuildConfig( + per_family_info=[{"amdgpu_family": "gfx110x"}], + dist_amdgpu_families="gfx110x", + artifact_group="multi-arch-release", + build_variant_label="release", + build_variant_suffix="", + build_variant_cmake_preset="release", + expect_failure=False, + build_pytorch=True, + ) + # Present config → valid JSON + serialized = json.dumps(config.to_dict()) + self.assertTrue(serialized) + round_tripped = json.loads(serialized) + self.assertEqual(round_tripped["dist_amdgpu_families"], "gfx110x") + + # None config → empty string (matches workflow `!= ''` gate) + none_serialized = json.dumps(None.to_dict()) if None else "" + self.assertEqual(none_serialized, "") + def test_release_produces_configs_for_both_platforms(self): """Release variant with families on both platforms produces both configs with correctly structured per-family info.""" From e3df8585cb16f995b67a85c872b1a74b6432d00e Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 16:16:35 -0700 Subject: [PATCH 41/56] Simplify skip summary, remove unused git_context from summary path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _format_skipped now derives skip reason from ci_inputs (ci:skip label, missing ci:run-multi-arch, or path filter) instead of hardcoding one reason. Directs user to logs for details. - Remove changed_files listing, _PATH_FILTERS_URL, and GitContext from the summary module entirely. - Remove git_context parameter from write_outputs (no longer needed). - Remove workspace-specific docstring reference. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 4 +-- .../configure_multi_arch_ci_summary.py | 34 ++++++------------- .../tests/configure_multi_arch_ci_test.py | 5 ++- 3 files changed, 14 insertions(+), 29 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 7abcc349cc4..93eb01480c8 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -843,7 +843,6 @@ def expand_build_configs( def write_outputs( ci_inputs: CIInputs, - git_context: GitContext, outputs: CIOutputs, ) -> None: """Write results to GITHUB_OUTPUT and GITHUB_STEP_SUMMARY. @@ -871,7 +870,6 @@ def write_outputs( gha_append_step_summary( format_summary( ci_inputs=ci_inputs, - git_context=git_context, outputs=outputs, ) ) @@ -942,7 +940,7 @@ def main(): git_context = GitContext.empty() outputs = configure(ci_inputs, git_context) - write_outputs(ci_inputs=ci_inputs, git_context=git_context, outputs=outputs) + write_outputs(ci_inputs=ci_inputs, outputs=outputs) if __name__ == "__main__": diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 5af340ed311..0431f3ffbf8 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -1,32 +1,23 @@ """Formats the GITHUB_STEP_SUMMARY markdown for configure_multi_arch_ci.py. Produces human-readable markdown explaining what CI will do and why. -See reviews/summary_format_v5.md in the claude-rocm-workspace for the -design rationale and example outputs. """ from configure_multi_arch_ci import ( CIInputs, CIOutputs, - GitContext, -) - -_PATH_FILTERS_URL = ( - "https://github.com/ROCm/TheRock/blob/main/" - "build_tools/github_actions/configure_ci_path_filters.py" ) def format_summary( ci_inputs: CIInputs, - git_context: GitContext, outputs: CIOutputs, ) -> str: """Generate the full step summary markdown.""" lines = ["## Multi-Arch CI Configuration", ""] if not outputs.is_ci_enabled: - return _format_skipped(lines, git_context) + return _format_skipped(lines, ci_inputs) if not outputs.jobs: return "\n".join(lines) @@ -65,19 +56,16 @@ def format_summary( return "\n".join(lines) -def _format_skipped(lines: list[str], git_context: GitContext) -> str: - lines.append( - f"CI was **skipped**: no CI-relevant files changed " - f"(see [configure_ci_path_filters.py]({_PATH_FILTERS_URL}) " - f"for skip patterns)." - ) - if git_context.changed_files: - lines.append("") - lines.append("Changed files:") - lines.append("```") - for path in git_context.changed_files: - lines.append(path) - lines.append("```") +def _format_skipped(lines: list[str], ci_inputs: CIInputs) -> str: + # Determine skip reason (same priority order as should_skip_ci). + if "ci:skip" in ci_inputs.pr_labels: + reason = "`ci:skip` PR label" + elif ci_inputs.is_pull_request and "ci:run-multi-arch" not in ci_inputs.pr_labels: + reason = "PR does not have `ci:run-multi-arch` label" + else: + reason = "no CI-relevant files changed" + + lines.append(f"CI was **skipped**: {reason}. See logs for details.") return "\n".join(lines) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index c4d84cc1f77..2549b3093d3 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -715,8 +715,7 @@ def _inputs(self, **kwargs): def test_skipped_summary_does_not_raise(self): outputs = cm.CIOutputs.skipped() - git = cm.GitContext(changed_files=["docs/README.md"]) - format_summary(self._inputs(), git, outputs) + format_summary(self._inputs(), outputs) def test_normal_summary_does_not_raise(self): jobs = cm.JobDecisions( @@ -727,7 +726,7 @@ def test_normal_summary_does_not_raise(self): test_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), ) outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) - format_summary(self._inputs(), cm.GitContext(), outputs) + format_summary(self._inputs(), outputs) # --------------------------------------------------------------------------- From ecf1eb132918a40476b12d915a5c2176f42685ae Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 23 Mar 2026 16:30:38 -0700 Subject: [PATCH 42/56] Trim duplicated DAG comment --- build_tools/github_actions/configure_multi_arch_ci.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 93eb01480c8..70dcdbe1d80 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -245,10 +245,6 @@ def log(self) -> None: # --------------------------------------------------------------------------- # Job decisions — the CI pipeline as a DAG of job groups # -# build-rocm → test-rocm -# → build-rocm-python → build-pytorch → test-pytorch -# → build-jax → test-jax (future) -# # Each node gets a JobGroupDecision (run/prebuilt/skip). Subclasses add # group-specific details (per-stage granularity, test type, etc.). # --------------------------------------------------------------------------- From 5c9fa5563b8c6d051805bfd2270d718f2c6ab1f4 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 24 Mar 2026 13:36:11 -0700 Subject: [PATCH 43/56] Update ci_behavior_manipulation.md for multi-arch CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure to cover both pipelines: shared trigger table, labels under Pull request, workflow dispatch for both. Add multi-arch CI references and update prebuilt stages TODO. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/development/ci_behavior_manipulation.md | 74 ++++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/development/ci_behavior_manipulation.md b/docs/development/ci_behavior_manipulation.md index ed7c8fa7c53..b849f95a514 100644 --- a/docs/development/ci_behavior_manipulation.md +++ b/docs/development/ci_behavior_manipulation.md @@ -1,45 +1,61 @@ # CI Behavior Manipulation -TheRock CI is controlled by [`configure_ci.py`](../../build_tools/github_actions/configure_ci.py), where it controls push, pull request, workflow dispatch and schedule CI behavior. +TheRock has two CI pipelines: -## CI (non-multi-arch) +- **CI** ([`ci.yml`](https://github.com/ROCm/TheRock/actions/workflows/ci.yml)) — single-arch builds, configured by [`configure_ci.py`](../../build_tools/github_actions/configure_ci.py) +- **Multi-Arch CI** ([`multi_arch_ci.yml`](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml)) — multi-arch builds, configured by [`configure_multi_arch_ci.py`](../../build_tools/github_actions/configure_multi_arch_ci.py) - +## Trigger behavior -### Push behavior +The CI pipelines test a growing set of GPU targets depending on trigger type/frequency: -For `push`, TheRock CI only runs builds and tests when pushed to the `main` branch. From [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py), TheRock CI collects the AMD GPU families from `amdgpu_family_info_matrix_presubmit` and `amdgpu_family_info_matrix_postsubmit` dictionaries, then runs builds and tests. +| Trigger type | Included family groups | Notes | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `pull_request` |
  • `amdgpu_family_info_matrix_presubmit`
| Common targets with the most test runners | +| `push` |
  • `amdgpu_family_info_matrix_presubmit`
  • `amdgpu_family_info_matrix_postsubmit`
| High priority targets with limited test runners | +| `schedule` |
  • `amdgpu_family_info_matrix_presubmit`
  • `amdgpu_family_info_matrix_postsubmit`
  • `amdgpu_family_info_matrix_nightly`
| All targets, even those that fail to build | -### Pull request behavior +### Pull request -For `pull_request`, TheRock CI collects the `amdgpu_family_info_matrix_presubmit` dictionary from [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py) and runs build/tests. +CI runs on pull requests if modified files pass the filters in +[`configure_ci_path_filters.py`](../../build_tools/github_actions/configure_ci_path_filters.py). -However, if additional options are wanted, you can add a label to manipulate the behavior. The labels we provide are: +The following labels may be added to a pull request to modify CI behavior: -- `ci:skip`: Skip all builds and tests -- `ci:run-all-archs`: Build and test all possible architectures -- `ci:run-multi-arch`: Opt in to running [Multi-Arch CI](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml) on this PR. Without this label, multi-arch CI is skipped on PRs to avoid doubling CI load during the transition. See [issue #3337](https://github.com/ROCm/TheRock/issues/3337). -- `gfx...`: Add a build and test (if a test machine is available) for the specified gfx family (e.g. `gfx120X`, `gfx950`) -- `test:...`: Run full tests only for the specified label and other labeled projects (e.g. `test:rocthrust`, `test:hipblaslt`) -- `test_runner:...`: Run tests on only custom test machines (e.g. `test_runner:oem`) -- `test_filter:...`: Run tests based on the specified filter (e.g. `test_filter:comprehensive`). See [test_filtering.md](./test_filtering.md) for allowed test filters. +| Label or group | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `ci:skip` | Skip all builds and tests | +| `ci:run-all-archs` | Build and test all possible architectures | +| `ci:run-multi-arch` | Opt in to running Multi-Arch CI on this PR | +| `gfx...` | Opt-in to building and testing the specified gfx family (e.g. `gfx120X`, `gfx950`) | +| `test:...` | Run full tests only for the specified projects (e.g. `test:rocthrust`, `test:hipblaslt`) | +| `test_runner:...` | Run tests on only custom test machines (e.g. `test_runner:oem`). Single-arch CI only. | +| `test_filter:...` | Set the test filter explicitly (e.g. `test_filter:comprehensive`). See [test_filtering.md](./test_filtering.md) for allowed test filters. | -### Workflow dispatch behavior +### Push -For `workflow_dispatch`, you are able to trigger CI in [GitHub's ci.yml workflow page](https://github.com/ROCm/TheRock/actions/workflows/ci.yml). To trigger a workflow dispatch, click "Run workflow" and fill in the fields accordingly: +CI runs on pushes to `main` if modified files pass the filters in +[`configure_ci_path_filters.py`](../../build_tools/github_actions/configure_ci_path_filters.py). - +### Schedule -### Schedule behavior +The +[`CI Nightly`](https://github.com/ROCm/TheRock/blob/main/.github/workflows/ci_nightly.yml) +workflow runs once a day. It selects _all_ families (even those which may fail +to build) and runs comprehensive tests. -For `schedule` runs, the `CI Nightly` runs everyday at 2AM UTC. This collects all families from [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py), running all builds and tests. +### Workflow dispatch + +The CI and Multi-Arch CI pipelines can be triggered manually from their GitHub +Actions workflow pages: + +- [CI workflow dispatch](https://github.com/ROCm/TheRock/actions/workflows/ci.yml) — click "Run workflow" and fill in the fields: + + + +- [Multi-Arch CI workflow dispatch](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml) — inputs allow per-platform family selection, test label filtering, and prebuilt stage configuration. ## Prebuilt stages (Multi-Arch CI) @@ -82,9 +98,9 @@ be computed based on dependencies and a special "all" option may be available. + another. When configure_multi_arch_ci.py generates the stage list + automatically, switch to a JSON array and use `fromJSON()` + `contains()` + for exact matching. --> For now, these are the common configurations used for testing: From d99128b8a396e28ef6b5fa1385b60bf941f6a630 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 24 Mar 2026 13:46:05 -0700 Subject: [PATCH 44/56] Delete amdgpu_family_matrix_test.py (move to another branch) --- .../tests/amdgpu_family_matrix_test.py | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 build_tools/github_actions/tests/amdgpu_family_matrix_test.py diff --git a/build_tools/github_actions/tests/amdgpu_family_matrix_test.py b/build_tools/github_actions/tests/amdgpu_family_matrix_test.py deleted file mode 100644 index 412ab41f2af..00000000000 --- a/build_tools/github_actions/tests/amdgpu_family_matrix_test.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -# Copyright Advanced Micro Devices, Inc. -# SPDX-License-Identifier: MIT - -"""Tests for data invariants in amdgpu_family_matrix.py.""" - -import os -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, os.fspath(Path(__file__).parent.parent)) - -from amdgpu_family_matrix import get_all_families_for_trigger_types - -ALL_FAMILIES = get_all_families_for_trigger_types( - ["presubmit", "postsubmit", "nightly"] -) - - -class TestFamilyMatrixInvariants(unittest.TestCase): - """Validate structural invariants on the family matrix data.""" - - def test_no_duplicate_family_names_per_platform(self): - """Each (platform, family) pair must be unique across target names. - - Two target names mapping to the same amdgpu_family on the same - platform would cause silent data loss in matrix expansion. - """ - for platform in ("linux", "windows"): - seen: dict[str, str] = {} # family → target_name - for target_name, entry in ALL_FAMILIES.items(): - if platform not in entry: - continue - family = entry[platform]["family"] - if family in seen: - self.fail( - f"Duplicate family {family!r} on {platform}: " - f"target {target_name!r} and {seen[family]!r}" - ) - seen[family] = target_name - - def test_required_fields_present(self): - """Every platform entry must have the required fields.""" - required = {"family", "fetch-gfx-targets", "test-runs-on", "build_variants"} - for target_name, entry in ALL_FAMILIES.items(): - for platform in ("linux", "windows"): - if platform not in entry: - continue - platform_info = entry[platform] - missing = required - platform_info.keys() - if missing: - self.fail( - f"{target_name}/{platform} missing required fields: {missing}" - ) - - def test_build_variants_non_empty(self): - """Every platform entry must list at least one build variant.""" - for target_name, entry in ALL_FAMILIES.items(): - for platform in ("linux", "windows"): - if platform not in entry: - continue - variants = entry[platform].get("build_variants", []) - if not variants: - self.fail(f"{target_name}/{platform} has empty build_variants") - - -if __name__ == "__main__": - unittest.main() From 9a50df380ac301be1a890f2648a77d401734e1f7 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 24 Mar 2026 13:46:16 -0700 Subject: [PATCH 45/56] Revert "Update ci_behavior_manipulation.md for multi-arch CI" (move to another branch) This reverts commit 5c9fa5563b8c6d051805bfd2270d718f2c6ab1f4. --- docs/development/ci_behavior_manipulation.md | 74 ++++++++------------ 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/docs/development/ci_behavior_manipulation.md b/docs/development/ci_behavior_manipulation.md index b849f95a514..ed7c8fa7c53 100644 --- a/docs/development/ci_behavior_manipulation.md +++ b/docs/development/ci_behavior_manipulation.md @@ -1,61 +1,45 @@ # CI Behavior Manipulation -TheRock has two CI pipelines: +TheRock CI is controlled by [`configure_ci.py`](../../build_tools/github_actions/configure_ci.py), where it controls push, pull request, workflow dispatch and schedule CI behavior. -- **CI** ([`ci.yml`](https://github.com/ROCm/TheRock/actions/workflows/ci.yml)) — single-arch builds, configured by [`configure_ci.py`](../../build_tools/github_actions/configure_ci.py) -- **Multi-Arch CI** ([`multi_arch_ci.yml`](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml)) — multi-arch builds, configured by [`configure_multi_arch_ci.py`](../../build_tools/github_actions/configure_multi_arch_ci.py) +## CI (non-multi-arch) -Both read GPU family definitions from [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py). + -The CI pipelines test a growing set of GPU targets depending on trigger type/frequency: +### Push behavior -| Trigger type | Included family groups | Notes | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| `pull_request` |
  • `amdgpu_family_info_matrix_presubmit`
| Common targets with the most test runners | -| `push` |
  • `amdgpu_family_info_matrix_presubmit`
  • `amdgpu_family_info_matrix_postsubmit`
| High priority targets with limited test runners | -| `schedule` |
  • `amdgpu_family_info_matrix_presubmit`
  • `amdgpu_family_info_matrix_postsubmit`
  • `amdgpu_family_info_matrix_nightly`
| All targets, even those that fail to build | +For `push`, TheRock CI only runs builds and tests when pushed to the `main` branch. From [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py), TheRock CI collects the AMD GPU families from `amdgpu_family_info_matrix_presubmit` and `amdgpu_family_info_matrix_postsubmit` dictionaries, then runs builds and tests. -### Pull request +### Pull request behavior -CI runs on pull requests if modified files pass the filters in -[`configure_ci_path_filters.py`](../../build_tools/github_actions/configure_ci_path_filters.py). +For `pull_request`, TheRock CI collects the `amdgpu_family_info_matrix_presubmit` dictionary from [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py) and runs build/tests. -The following labels may be added to a pull request to modify CI behavior: +However, if additional options are wanted, you can add a label to manipulate the behavior. The labels we provide are: -| Label or group | Description | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `ci:skip` | Skip all builds and tests | -| `ci:run-all-archs` | Build and test all possible architectures | -| `ci:run-multi-arch` | Opt in to running Multi-Arch CI on this PR | -| `gfx...` | Opt-in to building and testing the specified gfx family (e.g. `gfx120X`, `gfx950`) | -| `test:...` | Run full tests only for the specified projects (e.g. `test:rocthrust`, `test:hipblaslt`) | -| `test_runner:...` | Run tests on only custom test machines (e.g. `test_runner:oem`). Single-arch CI only. | -| `test_filter:...` | Set the test filter explicitly (e.g. `test_filter:comprehensive`). See [test_filtering.md](./test_filtering.md) for allowed test filters. | +- `ci:skip`: Skip all builds and tests +- `ci:run-all-archs`: Build and test all possible architectures +- `ci:run-multi-arch`: Opt in to running [Multi-Arch CI](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml) on this PR. Without this label, multi-arch CI is skipped on PRs to avoid doubling CI load during the transition. See [issue #3337](https://github.com/ROCm/TheRock/issues/3337). +- `gfx...`: Add a build and test (if a test machine is available) for the specified gfx family (e.g. `gfx120X`, `gfx950`) +- `test:...`: Run full tests only for the specified label and other labeled projects (e.g. `test:rocthrust`, `test:hipblaslt`) +- `test_runner:...`: Run tests on only custom test machines (e.g. `test_runner:oem`) +- `test_filter:...`: Run tests based on the specified filter (e.g. `test_filter:comprehensive`). See [test_filtering.md](./test_filtering.md) for allowed test filters. -### Push +### Workflow dispatch behavior -CI runs on pushes to `main` if modified files pass the filters in -[`configure_ci_path_filters.py`](../../build_tools/github_actions/configure_ci_path_filters.py). +For `workflow_dispatch`, you are able to trigger CI in [GitHub's ci.yml workflow page](https://github.com/ROCm/TheRock/actions/workflows/ci.yml). To trigger a workflow dispatch, click "Run workflow" and fill in the fields accordingly: -### Schedule + -The -[`CI Nightly`](https://github.com/ROCm/TheRock/blob/main/.github/workflows/ci_nightly.yml) -workflow runs once a day. It selects _all_ families (even those which may fail -to build) and runs comprehensive tests. +### Schedule behavior -### Workflow dispatch - -The CI and Multi-Arch CI pipelines can be triggered manually from their GitHub -Actions workflow pages: - -- [CI workflow dispatch](https://github.com/ROCm/TheRock/actions/workflows/ci.yml) — click "Run workflow" and fill in the fields: - - - -- [Multi-Arch CI workflow dispatch](https://github.com/ROCm/TheRock/actions/workflows/multi_arch_ci.yml) — inputs allow per-platform family selection, test label filtering, and prebuilt stage configuration. +For `schedule` runs, the `CI Nightly` runs everyday at 2AM UTC. This collects all families from [`amdgpu_family_matrix.py`](../../build_tools/github_actions/amdgpu_family_matrix.py), running all builds and tests. ## Prebuilt stages (Multi-Arch CI) @@ -98,9 +82,9 @@ be computed based on dependencies and a special "all" option may be available. + another. When configure_ci.py generates the stage list automatically, + switch to a JSON array and use `fromJSON()` + `contains()` for exact + matching. --> For now, these are the common configurations used for testing: From 373c8c80bd0b5c6214ffde28a7422d2a378c18fa Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 24 Mar 2026 15:30:02 -0700 Subject: [PATCH 46/56] Address review feedback: hardcode repo slug, rename test, add assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace _repo_slug() env var lookup with hardcoded _REPO_SLUG constant (prebuilt artifacts always come from ROCm/TheRock). TODO(#3399) to pass repo slug through when baseline_run_id carries a repo qualifier. - Rename test_build_config_to_dict_round_trips to _has_all_fields. - Add header assertions to summary tests with notes on why we don't assert more (change-detector avoidance). - Remove stale multi-arch comment in configure_ci.py. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- build_tools/github_actions/configure_ci.py | 2 +- .../configure_multi_arch_ci_summary.py | 16 +++++++-------- .../tests/configure_multi_arch_ci_test.py | 20 +++++++++++++------ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/build_tools/github_actions/configure_ci.py b/build_tools/github_actions/configure_ci.py index d16d577e64b..6f52921d611 100755 --- a/build_tools/github_actions/configure_ci.py +++ b/build_tools/github_actions/configure_ci.py @@ -589,7 +589,7 @@ def main(base_args, linux_families, windows_families): print(f"test_type decision: '{test_type}' (reason: {test_type_reason})") - # Format variants for summary - handle both regular and multi-arch modes + # Format variants for summary def format_variants(variants): result = [] for item in variants: diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 0431f3ffbf8..a747f23033e 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -8,6 +8,11 @@ CIOutputs, ) +# Hardcoded for now — prebuilt artifacts are always fetched from ROCm/TheRock +# workflow runs. TODO(#3399): when baseline_run_id carries a repo qualifier, +# pass the repo slug through from CIInputs instead of hardcoding. +_REPO_SLUG = "ROCm/TheRock" + def format_summary( ci_inputs: CIInputs, @@ -116,7 +121,7 @@ def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: if jobs and jobs.build_rocm.prebuilt_stages: stage_list = ", ".join(jobs.build_rocm.prebuilt_stages) run_id = jobs.build_rocm.baseline_run_id - repo = _repo_slug() + repo = _REPO_SLUG callouts.append( f"Prebuilt stages: `[{stage_list}]` from run " f"[{run_id}](https://github.com/{repo}/actions/runs/{run_id})" @@ -133,7 +138,7 @@ def _append_build_rocm(lines: list[str], outputs: CIOutputs) -> None: if prebuilt: stage_list = ", ".join(prebuilt) run_id = jobs.build_rocm.baseline_run_id - repo = _repo_slug() + repo = _REPO_SLUG lines.append( f"Using prebuilt artifacts for stages: `[{stage_list}]` " f"from run [{run_id}]" @@ -195,10 +200,3 @@ def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: scope = test_rocm.test_type lines.append(f"| {platform} | {family} | {runner} | {scope} |") lines.append("") - - -def _repo_slug() -> str: - """Return OWNER/REPO from GITHUB_REPOSITORY, or a placeholder.""" - import os - - return os.environ.get("GITHUB_REPOSITORY", "ROCm/TheRock") diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 2549b3093d3..31048dca98a 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -554,7 +554,7 @@ class TestExpandBuildConfigs(unittest.TestCase): expect_failure in the matrix data should not require test updates here. """ - def test_build_config_to_dict_round_trips(self): + def test_build_config_to_dict_has_all_fields(self): """BuildConfig.to_dict() produces all expected keys.""" config = cm.BuildConfig( per_family_info=[], @@ -713,11 +713,16 @@ def _inputs(self, **kwargs): defaults.update(kwargs) return cm.CIInputs(**defaults) - def test_skipped_summary_does_not_raise(self): + def test_skipped_summary(self): outputs = cm.CIOutputs.skipped() - format_summary(self._inputs(), outputs) - - def test_normal_summary_does_not_raise(self): + result = format_summary(self._inputs(), outputs) + # Just check the header. The output is markdown and asserting + # on more exact formatting would create a change detector test. + self.assertTrue(result.startswith("## Multi-Arch CI Configuration")) + + def test_normal_summary(self): + """Only checks header — output is markdown, not a contract. + Asserting on exact wording would create a change-detector test.""" jobs = cm.JobDecisions( build_rocm=cm.BuildRocmDecision(action=cm.JobAction.RUN), test_rocm=cm.TestRocmDecision(action=cm.JobAction.RUN, test_type="full"), @@ -726,7 +731,10 @@ def test_normal_summary_does_not_raise(self): test_pytorch=cm.JobGroupDecision(action=cm.JobAction.RUN), ) outputs = cm.CIOutputs(is_ci_enabled=True, jobs=jobs) - format_summary(self._inputs(), outputs) + result = format_summary(self._inputs(), outputs) + # Just check the header. The output is markdown for humans and asserting + # on more exact formatting would create a change detector test. + self.assertTrue(result.startswith("## Multi-Arch CI Configuration")) # --------------------------------------------------------------------------- From efb6c3313ec492befd365c0a5fdcb4a0e4fb1dea Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 13:07:40 -0700 Subject: [PATCH 47/56] Fix tests after merging --- .../tests/configure_multi_arch_ci_test.py | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 31048dca98a..ddd643d8877 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -50,6 +50,7 @@ def _run_from_environ( try: env = { + "GITHUB_RUN_ID": "12345", "GITHUB_EVENT_NAME": event_name, "GITHUB_EVENT_PATH": event_path, "GITHUB_REF_NAME": branch_name, @@ -72,6 +73,7 @@ class TestCIInputs(unittest.TestCase): def test_event_type_properties(self): """Event type properties are mutually exclusive.""" inputs = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -85,6 +87,7 @@ def test_event_type_properties(self): def test_defaults(self): """Fields with defaults can be omitted.""" inputs = cm.CIInputs( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -168,6 +171,7 @@ class TestShouldSkipCI(unittest.TestCase): def _inputs(self, **kwargs): defaults = dict( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -178,26 +182,20 @@ def _inputs(self, **kwargs): def test_skip_ci_label(self): """PR with ci:skip label skips CI regardless of changed files.""" - inputs = self._inputs(pr_labels=["ci:skip", "ci:run-multi-arch"]) + inputs = self._inputs(pr_labels=["ci:skip"]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) self.assertTrue(cm.should_skip_ci(inputs, git)) - def test_pr_without_multi_arch_label_skips(self): - """PR without ci:run-multi-arch label skips multi-arch CI.""" + def test_pr_without_skip_label_proceeds(self): + """PR without ci:skip label proceeds to path filtering.""" inputs = self._inputs(pr_labels=[]) git = cm.GitContext(changed_files=["CMakeLists.txt"]) - self.assertTrue(cm.should_skip_ci(inputs, git)) - - def test_pr_with_multi_arch_label_proceeds(self): - """PR with ci:run-multi-arch label proceeds to path filtering.""" - inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) - git = cm.GitContext(changed_files=["CMakeLists.txt"]) self.assertFalse(cm.should_skip_ci(inputs, git)) @patch("configure_multi_arch_ci.is_ci_run_required", return_value=False) def test_path_filter_says_skip(self, mock_filter): """When is_ci_run_required returns False, skip CI.""" - inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) + inputs = self._inputs() git = cm.GitContext(changed_files=["docs/README.md"]) self.assertTrue(cm.should_skip_ci(inputs, git)) mock_filter.assert_called_once_with(["docs/README.md"]) @@ -205,7 +203,7 @@ def test_path_filter_says_skip(self, mock_filter): @patch("configure_multi_arch_ci.is_ci_run_required", return_value=True) def test_path_filter_says_required(self, mock_filter): """When is_ci_run_required returns True, don't skip.""" - inputs = self._inputs(pr_labels=["ci:run-multi-arch"]) + inputs = self._inputs() git = cm.GitContext(changed_files=["CMakeLists.txt"]) self.assertFalse(cm.should_skip_ci(inputs, git)) @@ -228,6 +226,7 @@ class TestDecideJobs(unittest.TestCase): def _inputs(self, **kwargs): defaults = dict( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -372,6 +371,7 @@ class TestSelectTargets(unittest.TestCase): def test_push_includes_postsubmit_families(self): """Push trigger selects presubmit+postsubmit families.""" inputs = cm.CIInputs( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -384,6 +384,7 @@ def test_push_includes_postsubmit_families(self): def test_schedule_returns_all_families(self): """Schedule trigger selects all families (presubmit+postsubmit+nightly).""" inputs = cm.CIInputs( + run_id="12345", event_name="schedule", branch_name="main", base_ref="HEAD^1", @@ -392,6 +393,7 @@ def test_schedule_returns_all_families(self): result = cm.select_targets(inputs) # Schedule should have more families than push (nightly families added) push_inputs = cm.CIInputs( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -403,6 +405,7 @@ def test_schedule_returns_all_families(self): def test_pull_request_defaults_to_presubmit_only(self): """PR without labels gets presubmit families only, not postsubmit.""" inputs = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -416,12 +419,14 @@ def test_pull_request_defaults_to_presubmit_only(self): def test_pull_request_gfx_label_adds_family(self): """PR with a gfx label adds that family to the defaults.""" inputs_without = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", build_variant="release", ) inputs_with = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -437,6 +442,7 @@ def test_pull_request_gfx_label_adds_family(self): def test_pull_request_run_all_archs_label(self): """PR with ci:run-all-archs label selects all families.""" inputs = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -450,6 +456,7 @@ def test_pull_request_run_all_archs_label(self): def test_pull_request_unknown_gfx_label_raises(self): """PR with an unknown gfx label fails fast.""" inputs = cm.CIInputs( + run_id="12345", event_name="pull_request", branch_name="feature", base_ref="HEAD^", @@ -462,6 +469,7 @@ def test_pull_request_unknown_gfx_label_raises(self): def test_workflow_dispatch_per_platform(self): """workflow_dispatch selects families per platform.""" inputs = cm.CIInputs( + run_id="12345", event_name="workflow_dispatch", branch_name="main", base_ref="HEAD^1", @@ -479,6 +487,7 @@ def test_workflow_dispatch_per_platform(self): def test_workflow_dispatch_empty_input(self): """workflow_dispatch with empty lists returns empty families.""" inputs = cm.CIInputs( + run_id="12345", event_name="workflow_dispatch", branch_name="main", base_ref="HEAD^1", @@ -491,6 +500,7 @@ def test_workflow_dispatch_empty_input(self): def test_workflow_dispatch_unknown_family_raises(self): """workflow_dispatch with unknown family fails fast.""" inputs = cm.CIInputs( + run_id="12345", event_name="workflow_dispatch", branch_name="main", base_ref="HEAD^1", @@ -506,6 +516,7 @@ def test_workflow_dispatch_unknown_family_raises(self): def test_workflow_dispatch_wrong_platform_raises(self): """Requesting a family for a platform it doesn't support should fail.""" inputs = cm.CIInputs( + run_id="12345", event_name="workflow_dispatch", branch_name="main", base_ref="HEAD^1", @@ -519,6 +530,7 @@ def test_workflow_dispatch_wrong_platform_raises(self): def test_unsupported_event_type_raises(self): """Unknown event type raises ValueError.""" inputs = cm.CIInputs( + run_id="12345", event_name="repository_dispatch", branch_name="main", base_ref="HEAD^1", @@ -530,6 +542,7 @@ def test_unsupported_event_type_raises(self): def test_platform_filtering(self): """Families without a platform entry are excluded from that platform.""" inputs = cm.CIInputs( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -605,6 +618,7 @@ def test_release_produces_configs_for_both_platforms(self): """Release variant with families on both platforms produces both configs with correctly structured per-family info.""" inputs = cm.CIInputs( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -705,6 +719,7 @@ class TestFormatSummary(unittest.TestCase): def _inputs(self, **kwargs): defaults = dict( + run_id="12345", event_name="push", branch_name="main", base_ref="HEAD^1", @@ -757,6 +772,7 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): """If should_skip_ci returns True, pipeline short-circuits.""" mock_skip.return_value = True inputs = cm.CIInputs( + run_id="12345", event_name="workflow_dispatch", branch_name="main", base_ref="HEAD^1", From d481320d816db441e01145c508d7118d0f0a1fe6 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 13:52:38 -0700 Subject: [PATCH 48/56] Add build variant label to job names --- .github/workflows/multi_arch_ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 61d612b67e7..d7d49d489c5 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -65,7 +65,7 @@ jobs: build_variant: "release" linux_build_and_test: - name: Linux + name: Linux::${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_label }} needs: setup if: >- ${{ @@ -84,7 +84,7 @@ jobs: id-token: write windows_build_and_test: - name: Windows + name: Windows::${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_label }} needs: setup if: >- ${{ From 5cc463e1cd3ea204f0a475dfa42bb4f9a7cb5794 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 14:10:50 -0700 Subject: [PATCH 49/56] Address comments in configure_multi_arch_ci_summary.py --- .../configure_multi_arch_ci_summary.py | 71 +++++++++---------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 4802afb73d4..a4f7d79495e 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -30,7 +30,7 @@ def format_summary( lines = ["## Multi-Arch CI Configuration", ""] if not outputs.is_ci_enabled: - return _format_skipped(lines, ci_inputs) + return _format_skipped_ci(lines, ci_inputs) if not outputs.jobs: return "\n".join(lines) @@ -47,21 +47,19 @@ def format_summary( lines.append("No GPU families selected — nothing to build or test.") return "\n".join(lines) - # Non-default callout - callouts = _non_default_callouts(ci_inputs, outputs) - if callouts: + # Highlight noteworthy non-default settings ahead of the standard output. + highlights = _non_default_highlights(ci_inputs) + if highlights: lines.append("> [!NOTE]") lines.append("> **Non-default configuration:**") - for callout in callouts: + for callout in highlights: lines.append(f"> - {callout}") lines.append("") - # build-rocm lines.append("### build-rocm") lines.append("") _append_build_rocm(lines, ci_inputs, outputs) - # test-rocm lines.append("### test-rocm") lines.append("") _append_test_rocm(lines, outputs) @@ -69,7 +67,7 @@ def format_summary( return "\n".join(lines) -def _format_skipped(lines: list[str], ci_inputs: CIInputs) -> str: +def _format_skipped_ci(lines: list[str], ci_inputs: CIInputs) -> str: # Determine skip reason (same priority order as should_skip_ci). if "ci:skip" in ci_inputs.pr_labels: reason = "`ci:skip` PR label" @@ -82,65 +80,59 @@ def _format_skipped(lines: list[str], ci_inputs: CIInputs) -> str: return "\n".join(lines) -def _non_default_callouts(ci_inputs: CIInputs, outputs: CIOutputs) -> list[str]: - callouts: list[str] = [] - jobs = outputs.jobs +def _non_default_highlights(ci_inputs: CIInputs) -> list[str]: + highlights: list[str] = [] # Explicit family selection (workflow_dispatch) if ci_inputs.is_workflow_dispatch: - if ci_inputs.linux_amdgpu_families or ci_inputs.windows_amdgpu_families: - parts = [] - if ci_inputs.linux_amdgpu_families: - fams = ", ".join(ci_inputs.linux_amdgpu_families) - parts.append(f"Linux: `[{fams}]`") - if ci_inputs.windows_amdgpu_families: - fams = ", ".join(ci_inputs.windows_amdgpu_families) - parts.append(f"Windows: `[{fams}]`") - callouts.append(f"Explicit family selection — {', '.join(parts)}") + parts = [] + if ci_inputs.linux_amdgpu_families: + families = ", ".join(ci_inputs.linux_amdgpu_families) + parts.append(f"Linux: `[{families}]`") + if ci_inputs.windows_amdgpu_families: + families = ", ".join(ci_inputs.windows_amdgpu_families) + parts.append(f"Windows: `[{families}]`") + if parts: + highlights.append(f"Explicit family selection — {', '.join(parts)}") # PR labels that affect behavior for label in ci_inputs.pr_labels: if label.startswith("gfx"): - callouts.append( + highlights.append( f"Label `{label}`: added family `{label}` " f"(not in default presubmit set)" ) elif label.startswith("test_filter:"): - callouts.append( + highlights.append( f"Label `{label}`: overrode test level (default would be `quick`)" ) elif label.startswith("test:"): - callouts.append(f"Label `{label}`: requested component tests") + highlights.append(f"Label `{label}`: requested component tests") elif label.startswith("ci:"): - callouts.append(f"Label `{label}`") + highlights.append(f"Label `{label}`") # Explicit test labels (workflow_dispatch) if ci_inputs.is_workflow_dispatch: if ci_inputs.linux_test_labels: - callouts.append( + highlights.append( f"Explicit Linux test labels: `{ci_inputs.linux_test_labels}`" ) if ci_inputs.windows_test_labels: - callouts.append( + highlights.append( f"Explicit Windows test labels: `{ci_inputs.windows_test_labels}`" ) - # Prebuilt stages - if jobs and jobs.build_rocm.prebuilt_stages: - stage_list = ", ".join(jobs.build_rocm.prebuilt_stages) - run_id = jobs.build_rocm.baseline_run_id - repo = _REPO_SLUG - callouts.append( - f"Prebuilt stages: `[{stage_list}]` from run " - f"[{run_id}](https://github.com/{repo}/actions/runs/{run_id})" - ) - - return callouts + return highlights def _append_build_rocm( lines: list[str], ci_inputs: CIInputs, outputs: CIOutputs ) -> None: + # Note: this assumes that the build_rocm job is never skipped. + # We may decide to skip it under certain conditions in the future + # (e.g. only editing pytorch-related files, no ROCm-related files). + # This code will need to adapt then. + jobs = outputs.jobs # Prebuilt info @@ -194,6 +186,11 @@ def _append_build_rocm( def _append_test_rocm(lines: list[str], outputs: CIOutputs) -> None: + # Note: this assumes that the test_rocm job is never skipped. + # We may decide to skip it under certain conditions in the future + # (e.g. only editing pytorch-related files, no ROCm-related files). + # This code will need to adapt then. + jobs = outputs.jobs test_rocm = jobs.test_rocm From 26d721a8f670288aa5ca65c062de5d4a7045f3b2 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 14:44:30 -0700 Subject: [PATCH 50/56] Simplifying and documenting configure_multi_arch_ci.py fields --- .../github_actions/configure_multi_arch_ci.py | 59 ++++++++----------- .../configure_multi_arch_ci_summary.py | 2 +- 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 3a537c1f9e9..fc40ca84063 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -47,7 +47,7 @@ import enum import json import os -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from pathlib import Path @@ -86,11 +86,11 @@ class CIInputs: access needed. """ - run_id: str - event_name: str # push, pull_request, schedule, workflow_dispatch - branch_name: str - base_ref: str # Git ref for diffing (PR base or HEAD^1) - build_variant: str # release, asan, tsan + run_id: str # GITHUB_RUN_ID value + event_name: str # GITHUB_EVENT_NAME value (e.g. "push", "pull_request", "schedule", "workflow_dispatch") + commit_ref: str # GITHUB_REF_NAME value + base_ref: str # Git ref for the workflow run (PR base or HEAD^1, used for diffing) + build_variant: str # Build variant label, e.g. "release", "asan", "tsan" # PR labels (from event payload for pull_request events) pr_labels: list[str] = field(default_factory=list) @@ -108,16 +108,8 @@ class CIInputs: def log(self) -> None: """Log parsed inputs for CI diagnostics.""" print("CIInputs:") - print(f" event: {self.event_name}") - print(f" branch: {self.branch_name}") - print(f" variant: {self.build_variant}") - print(f" pr_labels: {self.pr_labels}") - print(f" linux_amdgpu_families: {self.linux_amdgpu_families}") - print(f" windows_amdgpu_families: {self.windows_amdgpu_families}") - print(f" linux_test_labels: {self.linux_test_labels!r}") - print(f" windows_test_labels: {self.windows_test_labels!r}") - print(f" prebuilt_stages: {self.prebuilt_stages!r}") - print(f" baseline_run_id: {self.baseline_run_id!r}") + for f in fields(self): + print(f" {f.name}: {getattr(self, f.name)!r}") @property def is_pull_request(self) -> bool: @@ -138,25 +130,25 @@ def is_workflow_dispatch(self) -> bool: @staticmethod def from_environ() -> "CIInputs": """Parse from GitHub Actions environment.""" - run_id = os.environ.get("GITHUB_RUN_ID", "") - event_name = os.environ.get("GITHUB_EVENT_NAME", "") - branch_name = os.environ.get("GITHUB_REF_NAME", "") - if not branch_name: - raise RuntimeError("GITHUB_REF_NAME is not set.") - - # Read the full event webhook payload. - event_path = os.environ.get("GITHUB_EVENT_PATH", "") - if event_path and Path(event_path).exists(): - with open(event_path) as f: - event = json.load(f) - else: - event = {} + run_id = os.environ["GITHUB_RUN_ID"] + event_name = os.environ["GITHUB_EVENT_NAME"] + commit_ref = os.environ["GITHUB_REF_NAME"] + + # Read the full event webhook payload (common to all event triggers). + event_path = os.environ["GITHUB_EVENT_PATH"] + with open(event_path) as f: + event = json.load(f) + + # Extract additional fields based on event type. - # Extract fields based on event type. + # "inputs" are set for workflow_dispatch, empty otherwise. inputs = event.get("inputs") or {} + + # BUILD_VARIANT comes from workflow_call inputs, not the event payload. + build_variant = os.environ.get("BUILD_VARIANT", "release") + pr_labels: list[str] = [] base_ref = "HEAD^1" - if event_name == "pull_request": pr_obj = event.get("pull_request", {}) pr_labels = [label["name"].lower() for label in pr_obj.get("labels", [])] @@ -165,13 +157,10 @@ def from_environ() -> "CIInputs": elif event_name == "push": base_ref = event.get("before", "HEAD^1") - # BUILD_VARIANT comes from workflow_call inputs, not the event payload. - build_variant = os.environ.get("BUILD_VARIANT", "release") - return CIInputs( run_id=run_id, event_name=event_name, - branch_name=branch_name, + commit_ref=commit_ref, base_ref=base_ref, build_variant=build_variant, pr_labels=pr_labels, diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index a4f7d79495e..56b65d86742 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -37,7 +37,7 @@ def format_summary( # One-liner: trigger, branch, variant lines.append( - f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.branch_name}` branch, " + f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.commit_ref}`, " f"`{ci_inputs.build_variant}` variant." ) lines.append("") From f1a7488effddb21a6a2510d2b709e14084f7bb82 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 14:46:41 -0700 Subject: [PATCH 51/56] Test fixes --- build_tools/github_actions/configure_ci.py | 3 +- .../github_actions/tests/configure_ci_test.py | 1 - .../tests/configure_multi_arch_ci_test.py | 48 +++++++++---------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/build_tools/github_actions/configure_ci.py b/build_tools/github_actions/configure_ci.py index a84ef5f0b36..d2ef62ac128 100644 --- a/build_tools/github_actions/configure_ci.py +++ b/build_tools/github_actions/configure_ci.py @@ -187,7 +187,7 @@ def matrix_generator( if is_long_lived_branch: active_trigger_types.extend(["presubmit", "postsubmit"]) else: - # Non-long-lived branch pushes (e.g., multi_arch/bringup1) use presubmit defaults + # Non-long-lived branch pushes use presubmit defaults active_trigger_types.append("presubmit") if is_schedule: active_trigger_types.extend(["presubmit", "postsubmit", "nightly"]) @@ -475,7 +475,6 @@ def main(base_args, linux_families, windows_families): print(f" branch_name: {branch_name}") print(f" base_ref: {base_ref}") print(f" build_variant: {build_variant}") - print(f" multi_arch: {multi_arch}") print(f" linux_use_prebuilt_artifacts: {linux_use_prebuilt_artifacts}") print(f" windows_use_prebuilt_artifacts: {windows_use_prebuilt_artifacts}") pr_labels = None diff --git a/build_tools/github_actions/tests/configure_ci_test.py b/build_tools/github_actions/tests/configure_ci_test.py index c3c1edf3f67..f4ddd423452 100644 --- a/build_tools/github_actions/tests/configure_ci_test.py +++ b/build_tools/github_actions/tests/configure_ci_test.py @@ -372,7 +372,6 @@ def test_main_windows_branch_push_matrix_generator(self): def test_linux_branch_push_matrix_generator(self): # Push to non-main branches uses presubmit defaults - # This supports multi_arch_ci.yml which triggers on multi_arch/** branches base_args = {"branch_name": "test_branch", "build_variant": "release"} linux_target_output, linux_test_labels = configure_ci.matrix_generator( is_pull_request=False, diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index ddd643d8877..16e132a63bf 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -33,7 +33,7 @@ def _run_from_environ( event_name: str, event_payload: dict, *, - branch_name: str = "main", + commit_ref: str = "main", build_variant: str = "release", ) -> cm.CIInputs: """Call CIInputs.from_environ() with a synthetic event payload. @@ -53,7 +53,7 @@ def _run_from_environ( "GITHUB_RUN_ID": "12345", "GITHUB_EVENT_NAME": event_name, "GITHUB_EVENT_PATH": event_path, - "GITHUB_REF_NAME": branch_name, + "GITHUB_REF_NAME": commit_ref, "BUILD_VARIANT": build_variant, } with patch.dict(os.environ, env, clear=False): @@ -75,7 +75,7 @@ def test_event_type_properties(self): inputs = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", ) @@ -89,7 +89,7 @@ def test_defaults(self): inputs = cm.CIInputs( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -142,7 +142,7 @@ def test_pull_request_extracts_labels(self): ] } }, - branch_name="feature-branch", + commit_ref="feature-branch", ) self.assertEqual(inputs.pr_labels, ["gfx950", "test:rocprim"]) self.assertEqual(inputs.base_ref, "HEAD^") @@ -173,7 +173,7 @@ def _inputs(self, **kwargs): defaults = dict( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", ) @@ -228,7 +228,7 @@ def _inputs(self, **kwargs): defaults = dict( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", ) @@ -373,7 +373,7 @@ def test_push_includes_postsubmit_families(self): inputs = cm.CIInputs( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -386,7 +386,7 @@ def test_schedule_returns_all_families(self): inputs = cm.CIInputs( run_id="12345", event_name="schedule", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -395,7 +395,7 @@ def test_schedule_returns_all_families(self): push_inputs = cm.CIInputs( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -407,7 +407,7 @@ def test_pull_request_defaults_to_presubmit_only(self): inputs = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", ) @@ -421,14 +421,14 @@ def test_pull_request_gfx_label_adds_family(self): inputs_without = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", ) inputs_with = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", # gfx906 is nightly-only, not in presubmit+postsubmit defaults @@ -444,7 +444,7 @@ def test_pull_request_run_all_archs_label(self): inputs = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", pr_labels=["ci:run-all-archs"], @@ -458,7 +458,7 @@ def test_pull_request_unknown_gfx_label_raises(self): inputs = cm.CIInputs( run_id="12345", event_name="pull_request", - branch_name="feature", + commit_ref="feature", base_ref="HEAD^", build_variant="release", pr_labels=["gfx9999"], @@ -471,7 +471,7 @@ def test_workflow_dispatch_per_platform(self): inputs = cm.CIInputs( run_id="12345", event_name="workflow_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", linux_amdgpu_families=["gfx94x", "gfx110x"], @@ -489,7 +489,7 @@ def test_workflow_dispatch_empty_input(self): inputs = cm.CIInputs( run_id="12345", event_name="workflow_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -502,7 +502,7 @@ def test_workflow_dispatch_unknown_family_raises(self): inputs = cm.CIInputs( run_id="12345", event_name="workflow_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", linux_amdgpu_families=["gfx_bogus"], @@ -518,7 +518,7 @@ def test_workflow_dispatch_wrong_platform_raises(self): inputs = cm.CIInputs( run_id="12345", event_name="workflow_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", # gfx950 has no windows entry — this should be an error, not silently dropped @@ -532,7 +532,7 @@ def test_unsupported_event_type_raises(self): inputs = cm.CIInputs( run_id="12345", event_name="repository_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -544,7 +544,7 @@ def test_platform_filtering(self): inputs = cm.CIInputs( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -620,7 +620,7 @@ def test_release_produces_configs_for_both_platforms(self): inputs = cm.CIInputs( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -721,7 +721,7 @@ def _inputs(self, **kwargs): defaults = dict( run_id="12345", event_name="push", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) @@ -774,7 +774,7 @@ def test_pipeline_skips_when_gate_says_skip(self, mock_skip): inputs = cm.CIInputs( run_id="12345", event_name="workflow_dispatch", - branch_name="main", + commit_ref="main", base_ref="HEAD^1", build_variant="release", ) From 0856078942d9d27c1e62a1c4baf3242265265fe0 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 15:25:24 -0700 Subject: [PATCH 52/56] Address more review comments in configure_multi_arch_ci.py --- .../github_actions/configure_multi_arch_ci.py | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index fc40ca84063..fd561c97034 100644 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -26,7 +26,7 @@ Step 4 determines which job groups to run, skip, or satisfy with prebuilt artifacts. Within build-rocm, per-stage rebuild/prebuilt granularity is -available. Test details (which tests to run, smoke vs full) are decided +available. Test details (which tests to run, quick vs full) are decided per test job group. Inputs: @@ -47,8 +47,7 @@ import enum import json import os -from dataclasses import dataclass, field, fields -from pathlib import Path +from dataclasses import asdict, dataclass, field, fields from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types @@ -150,8 +149,12 @@ def from_environ() -> "CIInputs": pr_labels: list[str] = [] base_ref = "HEAD^1" if event_name == "pull_request": + # Extract label name strings from the event payload's label objects: + # Sample input: [{"name": "ci:skip", "color": "fff", ...}, ...] + # Sample output: ["ci:skip", ...] pr_obj = event.get("pull_request", {}) pr_labels = [label["name"].lower() for label in pr_obj.get("labels", [])] + # The merge commit's first parent is the PR base. base_ref = "HEAD^" elif event_name == "push": @@ -186,7 +189,10 @@ class GitContext: construct GitContext directly without touching git. """ + # List of relative file paths modified relative to a base ref changed_files: list[str] | None = None + + # List of paths of all git submodules in the repo submodule_paths: list[str] | None = None @staticmethod @@ -237,8 +243,33 @@ def log(self) -> None: # --------------------------------------------------------------------------- # Job decisions — the CI pipeline as a DAG of job groups # -# Each node gets a JobGroupDecision (run/prebuilt/skip). Subclasses add -# group-specific details (per-stage granularity, test type, etc.). +# The CI pipeline forms a DAG where each node is a job group: +# +# build-rocm ──> test-rocm +# └─> build-rocm-python ──> build-pytorch ──> test-pytorch +# +# Each node gets a JobAction: RUN, PREBUILT, or SKIP. +# - RUN: Build from source (or run tests). +# - PREBUILT: Fetch artifacts from a prior successful run. Only valid for +# build job groups (build-rocm, build-rocm-python). +# - SKIP: Don't run at all. Used when no downstream job needs this +# node's outputs. +# +# Note: this is aspirational and not fully implemented yet. +# +# Example: a commit that only changes ROCm python packaging code: +# +# [PREBUILT] build-rocm (ROCm itself unchanged, reuse artifacts) +# │ +# ├──> [SKIP] test-rocm (ROCm unchanged, no need to re-test) +# │ +# └──> [RUN] build-rocm-python ──> [RUN] build-pytorch +# (packaging changed) │ +# └──> [RUN] test-pytorch +# +# Subclasses of JobGroupDecision add group-specific details: +# - BuildRocmDecision: per-stage rebuild/prebuilt granularity +# - TestRocmDecision: test type (quick/standard/comprehensive/full) # --------------------------------------------------------------------------- @@ -353,19 +384,9 @@ class BuildConfig: baseline_run_id: str = "" def to_dict(self) -> dict: - """Convert to dict for JSON serialization.""" - return { - "per_family_info": self.per_family_info, - "dist_amdgpu_families": self.dist_amdgpu_families, - "artifact_group": self.artifact_group, - "build_variant_label": self.build_variant_label, - "build_variant_suffix": self.build_variant_suffix, - "build_variant_cmake_preset": self.build_variant_cmake_preset, - "expect_failure": self.expect_failure, - "build_pytorch": self.build_pytorch, - "prebuilt_stages": ",".join(self.prebuilt_stages), - "baseline_run_id": self.baseline_run_id, - } + d = asdict(self) + d["prebuilt_stages"] = ",".join(self.prebuilt_stages) + return d @dataclass(frozen=True) @@ -829,9 +850,9 @@ def write_outputs( This is the only function with side effects (besides from_environ). """ - test_type = outputs.jobs.test_rocm.test_type if outputs.jobs else "quick" linux = outputs.builds.linux windows = outputs.builds.windows + test_type = outputs.jobs.test_rocm.test_type output_vars = { # Workflow YAML references this as 'enable_build_jobs' "enable_build_jobs": json.dumps(outputs.is_ci_enabled), From 84ab30509db510c72c6a6da4b4ec4ba9b4f46188 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 15:45:49 -0700 Subject: [PATCH 53/56] Add executable bit to configure scripts (??? why did it get dropped?) --- build_tools/github_actions/configure_ci.py | 0 build_tools/github_actions/configure_multi_arch_ci.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 build_tools/github_actions/configure_ci.py mode change 100644 => 100755 build_tools/github_actions/configure_multi_arch_ci.py diff --git a/build_tools/github_actions/configure_ci.py b/build_tools/github_actions/configure_ci.py old mode 100644 new mode 100755 diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py old mode 100644 new mode 100755 From 1262471dbbeaf0f0e0b53c7d68e78ff1eaf65e45 Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 16:09:43 -0700 Subject: [PATCH 54/56] Add links to docs for test filtering and CI behavior manipulation --- .../github_actions/configure_multi_arch_ci.py | 31 ++++++------------- .../configure_multi_arch_ci_summary.py | 6 +++- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index fd561c97034..2136aba2391 100755 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -487,28 +487,17 @@ def _determine_test_type( ) -> tuple[str, str]: """Determine test_type and reason based on trigger, labels, and changed files. - Test types from least to most testing: - - - "quick": Fast sanity checks. Default for PRs and push where only - build infra or non-submodule files changed. Keeps CI fast for - routine changes that are unlikely to break GPU-specific behavior. - - "standard": More thorough than quick, but not full nightly coverage. - Only available via explicit test_filter:standard PR label. - - "comprehensive": Full nightly test suite. Used for scheduled runs - to catch regressions across all components without requiring a - submodule change to trigger it. - - "full": Everything, including tests for specific components named - by test:* labels. Triggered when a submodule changes (the actual - GPU libraries changed, so we need thorough validation) or when - test labels explicitly request specific component tests. - - The test_filter: PR label can override any of the above, giving - developers manual control (e.g. test_filter:comprehensive on a PR - to get nightly-level coverage before merge). - - Returns (test_type, reason). Checked in priority order — highest - priority overrides win and return early. + This code implements the policies from docs/development/test_filtering.md + and docs/development/ci_behavior_manipulation.md: + + * Available filter types: ["quick", "standard", "comprehensive", "full"] + * Workflow runs choose a filter type automatically but PRs can override + with labels like `test_filter:comprehensive` + + Returns (test_type, reason). """ + + # Check in priority order - highest priority returns early. # Priority 1: test_filter: PR label is an explicit manual override. # This is the escape hatch: run comprehensive on a PR before merge, # or downgrade to quick if you know the change is safe. diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 56b65d86742..3e230e9da7c 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -27,7 +27,11 @@ def format_summary( outputs: CIOutputs, ) -> str: """Generate the full step summary markdown.""" - lines = ["## Multi-Arch CI Configuration", ""] + lines = [] + lines.append( + "## Multi-Arch CI Configuration (tips: [ci_behavior_manipulation.md](https://github.com/ROCm/TheRock/blob/main/docs/development/ci_behavior_manipulation.md))" + ) + lines.append("") if not outputs.is_ci_enabled: return _format_skipped_ci(lines, ci_inputs) From 5b61a9036fcebbdc6d7e6020752572553e9fefcd Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Mon, 30 Mar 2026 16:36:39 -0700 Subject: [PATCH 55/56] Add test_runner: label support to multi-arch CI configure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass CIInputs to expand_build_configs instead of just build_variant, giving it access to PR labels. When a test_runner: label is present (e.g. test_runner:oem), override the test runner for families that have a matching test-runs-on-kernel entry in the family matrix. Families without kernel support get their test runner cleared. Also adds test_runner: to the step summary highlights section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github_actions/configure_multi_arch_ci.py | 43 ++++++++++++-- .../configure_multi_arch_ci_summary.py | 5 ++ .../tests/configure_multi_arch_ci_test.py | 56 +++++++++++++++++-- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 2136aba2391..88fa503055d 100755 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -498,6 +498,7 @@ def _determine_test_type( """ # Check in priority order - highest priority returns early. + # Priority 1: test_filter: PR label is an explicit manual override. # This is the escape hatch: run comprehensive on a PR before merge, # or downgrade to quick if you know the change is safe. @@ -711,7 +712,7 @@ def select_targets(ci_inputs: CIInputs) -> TargetSelection: def _expand_build_config_for_platform( families: list[str], platform: str, - build_variant: str, + ci_inputs: CIInputs, all_families: dict[str, dict], variant_config: dict, prebuilt_stages: list[str] | None = None, @@ -728,8 +729,17 @@ def _expand_build_config_for_platform( - test-runs-on: runner label for testing (empty = no test runner available) - sanity_check_only_for_family: whether to limit test scope """ - per_family_info: list[dict] = [] + build_variant = ci_inputs.build_variant + # Extract kernel type from test_runner: PR label (e.g. "oem"). + # Selects kernel-specific test runners for families that support them. + test_runner_kernel = "" + for label in ci_inputs.pr_labels: + if label.startswith("test_runner:"): + test_runner_kernel = label.split(":")[1] + break + + per_family_info: list[dict] = [] for family_name in families: # select_targets already validates family names and filters by # platform availability. Family name uniqueness is validated by @@ -744,11 +754,31 @@ def _expand_build_config_for_platform( ) continue + # Determine test runner label. + test_runs_on = platform_info["test-runs-on"] + # When a test_runner: label is set, use the + # kernel-specific runner if available, otherwise disable testing for + # this family (the default runner may not have the right kernel). + if test_runner_kernel: + kernel_runners = platform_info.get("test-runs-on-kernel", {}) + if test_runner_kernel in kernel_runners: + test_runs_on = kernel_runners[test_runner_kernel] + print( + f" {family_name}: using {test_runner_kernel} kernel " + f"runner: {test_runs_on}" + ) + else: + test_runs_on = "" + print( + f" {family_name}: no {test_runner_kernel} kernel " + f"runner available, disabling tests" + ) + per_family_info.append( { "amdgpu_family": platform_info["family"], "amdgpu_targets": ",".join(platform_info["fetch-gfx-targets"]), - "test-runs-on": platform_info["test-runs-on"], + "test-runs-on": test_runs_on, "sanity_check_only_for_family": platform_info.get( "sanity_check_only_for_family", False ), @@ -779,7 +809,7 @@ def _expand_build_config_for_platform( def expand_build_configs( targets: TargetSelection, - build_variant: str, + ci_inputs: CIInputs, prebuilt_stages: list[str] | None = None, baseline_run_id: str = "", ) -> BuildConfigs: @@ -791,6 +821,7 @@ def expand_build_configs( all_families = get_all_families_for_trigger_types( ["presubmit", "postsubmit", "nightly"] ) + build_variant = ci_inputs.build_variant linux_config: BuildConfig | None = None windows_config: BuildConfig | None = None @@ -809,7 +840,7 @@ def expand_build_configs( config = _expand_build_config_for_platform( families=families, platform=platform, - build_variant=build_variant, + ci_inputs=ci_inputs, all_families=all_families, variant_config=variant_config, prebuilt_stages=prebuilt_stages, @@ -897,7 +928,7 @@ def configure(ci_inputs: CIInputs, git_context: GitContext) -> CIOutputs: print("\n=== Building per-platform configs ===") builds = expand_build_configs( targets=targets, - build_variant=ci_inputs.build_variant, + ci_inputs=ci_inputs, prebuilt_stages=jobs.build_rocm.prebuilt_stages, baseline_run_id=jobs.build_rocm.baseline_run_id, ) diff --git a/build_tools/github_actions/configure_multi_arch_ci_summary.py b/build_tools/github_actions/configure_multi_arch_ci_summary.py index 3e230e9da7c..6ebc1b18012 100644 --- a/build_tools/github_actions/configure_multi_arch_ci_summary.py +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -110,6 +110,11 @@ def _non_default_highlights(ci_inputs: CIInputs) -> list[str]: highlights.append( f"Label `{label}`: overrode test level (default would be `quick`)" ) + elif label.startswith("test_runner:"): + kernel = label.split(":")[1] + highlights.append( + f"Label `{label}`: using `{kernel}` kernel-specific test runners" + ) elif label.startswith("test:"): highlights.append(f"Label `{label}`: requested component tests") elif label.startswith("ci:"): diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 16e132a63bf..ae568147ff4 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -560,13 +560,24 @@ def test_platform_filtering(self): class TestExpandBuildConfigs(unittest.TestCase): - """Test expand_build_configs: TargetSelection × build_variant → BuildConfigs. + """Test expand_build_configs: TargetSelection × CIInputs → BuildConfigs. Tests verify structural properties of the output, not specific data values from amdgpu_family_matrix.py. Changing a runner label or flipping expect_failure in the matrix data should not require test updates here. """ + def _inputs(self, **kwargs): + defaults = dict( + run_id="12345", + event_name="push", + commit_ref="main", + base_ref="HEAD^1", + build_variant="release", + ) + defaults.update(kwargs) + return cm.CIInputs(**defaults) + def test_build_config_to_dict_has_all_fields(self): """BuildConfig.to_dict() produces all expected keys.""" config = cm.BuildConfig( @@ -587,7 +598,7 @@ def test_build_config_to_dict_has_all_fields(self): def test_empty_targets_both_none(self): """Empty targets on both platforms → both None.""" targets = cm.TargetSelection() - result = cm.expand_build_configs(targets=targets, build_variant="release") + result = cm.expand_build_configs(targets=targets, ci_inputs=self._inputs()) self.assertIsNone(result.linux) self.assertIsNone(result.windows) @@ -625,7 +636,7 @@ def test_release_produces_configs_for_both_platforms(self): build_variant="release", ) targets = cm.select_targets(inputs) - result = cm.expand_build_configs(targets=targets, build_variant="release") + result = cm.expand_build_configs(targets=targets, ci_inputs=inputs) required_keys = { "amdgpu_family", "amdgpu_targets", @@ -673,7 +684,7 @@ def test_build_config_structure(self): linux_families=["gfx94x", "gfx110x"], windows_families=["gfx110x"], ) - result = cm.expand_build_configs(targets=targets, build_variant="release") + result = cm.expand_build_configs(targets=targets, ci_inputs=self._inputs()) # All target families that support the variant appear in output. linux_per_family = result.linux.per_family_info @@ -700,7 +711,9 @@ def test_variant_filters_by_platform_and_family_support(self): linux_families=["gfx94x", "gfx110x"], windows_families=["gfx110x"], ) - result = cm.expand_build_configs(targets=targets, build_variant="asan") + result = cm.expand_build_configs( + targets=targets, ci_inputs=self._inputs(build_variant="asan") + ) # Only gfx94x on linux survives. self.assertIsNotNone(result.linux) linux_per_family = result.linux.per_family_info @@ -708,6 +721,39 @@ def test_variant_filters_by_platform_and_family_support(self): # Windows has no asan variant config at all. self.assertIsNone(result.windows) + def test_test_runner_kernel_overrides_runner_label(self): + """test_runner:oem label swaps in kernel-specific runner for gfx1151.""" + targets = cm.TargetSelection(linux_families=["gfx1151"]) + result = cm.expand_build_configs( + targets=targets, + ci_inputs=self._inputs(pr_labels=["test_runner:oem"]), + ) + self.assertIsNotNone(result.linux) + entry = result.linux.per_family_info[0] + self.assertEqual(entry["test-runs-on"], "linux-strix-halo-gpu-rocm-oem") + + def test_test_runner_kernel_clears_unsupported_family(self): + """test_runner:oem label clears runner for families without kernel support.""" + # gfx94x has no test-runs-on-kernel entry + targets = cm.TargetSelection(linux_families=["gfx94x"]) + result = cm.expand_build_configs( + targets=targets, + ci_inputs=self._inputs(pr_labels=["test_runner:oem"]), + ) + self.assertIsNotNone(result.linux) + entry = result.linux.per_family_info[0] + self.assertEqual(entry["test-runs-on"], "") + + def test_no_test_runner_label_uses_default(self): + """Without test_runner: label, default runner labels are used.""" + targets = cm.TargetSelection(linux_families=["gfx1151"]) + result = cm.expand_build_configs(targets=targets, ci_inputs=self._inputs()) + self.assertIsNotNone(result.linux) + entry = result.linux.per_family_info[0] + # Default runner, not the oem one + self.assertNotEqual(entry["test-runs-on"], "") + self.assertNotIn("oem", entry["test-runs-on"]) + # --------------------------------------------------------------------------- # Step 6: Format Outputs From 0855f955fdea31f6b09cecdd319932284fee7d9b Mon Sep 17 00:00:00 2001 From: Scott Todd Date: Tue, 31 Mar 2026 10:01:08 -0700 Subject: [PATCH 56/56] Add comment about label suffix trimming. --- build_tools/github_actions/configure_multi_arch_ci.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 88fa503055d..87b096c5e6d 100755 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -681,6 +681,10 @@ def select_targets(ci_inputs: CIInputs) -> TargetSelection: print(" Label 'ci:run-all-archs' -> all families") break if label.startswith("gfx"): + # Trim suffixes from labels since amdgpu_family_matrix.py + # specifies families with no suffix (e.g. `gfx94x`) but + # we have some labels like `gfx94X-dcgpu` or `gfx103X-linux`. + # Note: labels are normalized to lowercase during parsing. target = label.split("-")[0] linux_names.append(target) windows_names.append(target)