diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 29fcc85b405..d7d49d489c5 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -60,72 +60,44 @@ 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::${{ fromJSON(needs.setup.outputs.linux_build_config).build_variant_label }} needs: setup if: >- ${{ - needs.setup.outputs.linux_variants != '[]' && + needs.setup.outputs.linux_build_config != '' && 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 }} + build_config: ${{ needs.setup.outputs.linux_build_config }} 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 }} 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 windows_build_and_test: - name: Windows::${{ matrix.variant.build_variant_label }} + name: Windows::${{ fromJSON(needs.setup.outputs.windows_build_config).build_variant_label }} needs: setup if: >- ${{ - needs.setup.outputs.windows_variants != '[]' && + needs.setup.outputs.windows_build_config != '' && 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 }} + build_config: ${{ needs.setup.outputs.windows_build_config }} 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 }} 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_linux.yml b/.github/workflows/multi_arch_ci_linux.yml index 2f12ab0b031..14ab13002c7 100644 --- a/.github/workflows/multi_arch_ci_linux.yml +++ b/.github/workflows/multi_arch_ci_linux.yml @@ -6,39 +6,20 @@ 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, + prebuilt_stages, baseline_run_id. test_labels: type: string - expect_failure: - type: boolean - 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: type: string - build_pytorch: - type: boolean - default: false permissions: contents: read @@ -46,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 @@ -80,9 +61,9 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | python build_tools/artifact_manager.py copy \ - --source-run-id=${{ inputs.baseline_run_id }} \ - --stage="${{ inputs.prebuilt_stages }}" \ - --amdgpu-families="${{ inputs.dist_amdgpu_families }}" + --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: name: Build Multi-Arch Stages @@ -91,14 +72,14 @@ 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 }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} + 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: ${{ fromJSON(inputs.build_config).prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} permissions: @@ -109,21 +90,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 }} uses: ./.github/workflows/test_artifacts.yml with: # Use architecture-specific artifact group for fetching per-arch artifacts @@ -138,11 +119,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: @@ -152,11 +133,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 }} uses: ./.github/workflows/test_rocm_wheels.yml with: amdgpu_family: ${{ matrix.family_info.amdgpu_family }} @@ -172,11 +153,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_portable_linux_pytorch_wheels_ci.yml with: artifact_group: ${{ matrix.family_info.amdgpu_family }} diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index a27225054fa..d82a1e11828 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -6,39 +6,20 @@ 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, + prebuilt_stages, baseline_run_id. test_labels: type: string - expect_failure: - type: boolean - 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: type: string - build_pytorch: - type: boolean - default: false permissions: contents: read @@ -46,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 != '' }} # 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 @@ -85,9 +66,9 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | python build_tools/artifact_manager.py copy \ - --source-run-id=${{ inputs.baseline_run_id }} \ - --stage="${{ inputs.prebuilt_stages }}" \ - --amdgpu-families="${{ inputs.dist_amdgpu_families }}" + --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: name: Build Multi-Arch Stages @@ -96,14 +77,14 @@ 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 }} - prebuilt_stages: ${{ inputs.prebuilt_stages }} + 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: ${{ fromJSON(inputs.build_config).prebuilt_stages }} rocm_package_version: ${{ inputs.rocm_package_version }} test_type: ${{ inputs.test_type }} permissions: @@ -114,21 +95,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 @@ -143,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: @@ -161,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/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml new file mode 100644 index 00000000000..8d1101e6937 --- /dev/null +++ b/.github/workflows/setup_multi_arch.yml @@ -0,0 +1,66 @@ +# 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 }} + windows_build_config: + description: JSON object with Windows build configuration, or empty string if skipped. + value: ${{ jobs.setup.outputs.windows_build_config }} + 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 }} + 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 }} + windows_build_config: ${{ steps.configure.outputs.windows_build_config }} + 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 }} + 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_ci.py b/build_tools/github_actions/configure_ci.py index a53bb9a2d9c..d2ef62ac128 100755 --- a/build_tools/github_actions/configure_ci.py +++ b/build_tools/github_actions/configure_ci.py @@ -51,7 +51,6 @@ import json import os from pathlib import Path -import subprocess import sys from typing import Iterable, List, Optional import string @@ -139,116 +138,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 +161,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. @@ -299,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"]) @@ -463,19 +351,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: @@ -583,12 +458,10 @@ def main(base_args, linux_families, windows_families): is_workflow_dispatch = github_event_name == "workflow_dispatch" is_pull_request = github_event_name == "pull_request" is_schedule = github_event_name == "schedule" - github_run_id = base_args.get("github_run_id") 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") @@ -599,11 +472,9 @@ def main(base_args, linux_families, windows_families): print(f" is_workflow_dispatch: {is_workflow_dispatch}") print(f" is_pull_request: {is_pull_request}") print(f" is_schedule: {is_schedule}") - print(f" github_run_id: {github_run_id}") 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 @@ -619,9 +490,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, @@ -630,13 +499,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, @@ -645,7 +511,6 @@ def main(base_args, linux_families, windows_families): base_args, windows_families, platform="windows", - multi_arch=multi_arch, ) print("") @@ -684,11 +549,7 @@ def main(base_args, linux_families, windows_families): # This avoids doubling CI load during the transition from ci.yml # to multi_arch_ci.yml. See https://github.com/ROCm/TheRock/issues/3337 # TODO(#3399): move multi-arch CI configuration to its own script - if ( - not multi_arch - and is_pull_request - and "ci:run-non-multi-arch" not in (pr_labels or []) - ): + if is_pull_request and "ci:run-non-multi-arch" not in (pr_labels or []): print( "Skipping non-multi-arch CI: 'ci:run-non-multi-arch' label not found. " "Add the label to opt in." @@ -739,7 +600,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: @@ -754,10 +615,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( @@ -775,34 +632,6 @@ def format_variants(variants): """ ) - # Multi-arch build summary: add links to logs and artifacts index pages. - # These are posted early (before builds complete) so they appear at the top - # of the job summary. The server-side Lambda generates the index pages as - # logs and artifacts flow in. - # TODO(#3399): move multi-arch CI configuration to its own script - if multi_arch and enable_build_jobs: - # Lazy import since multi-arch CI configuration will move soon - sys.path.insert(0, str(THEROCK_DIR / "build_tools")) - from _therock_utils.workflow_outputs import WorkflowOutputRoot - - if github_run_id: - summary_lines = [ - "## Build outputs", - "", - "Platform | 📋 Logs | 📦 Artifacts", - "-- | -- | --", - ] - for platform_name in ["linux", "windows"]: - root = WorkflowOutputRoot.from_workflow_run( - run_id=github_run_id, platform=platform_name - ) - log_url = root.root_log_index().https_url - artifact_url = root.root_index().https_url - summary_lines.append( - f"{platform_name.capitalize()} | {log_url} | {artifact_url}" - ) - gha_append_step_summary("\n".join(summary_lines)) - output = { "linux_variants": json.dumps(linux_variants_output), "linux_test_labels": json.dumps(linux_test_output), @@ -837,7 +666,6 @@ def format_variants(variants): ) sys.exit(1) base_args["github_event_name"] = os.environ.get("GITHUB_EVENT_NAME", "") - base_args["github_run_id"] = os.environ.get("GITHUB_RUN_ID", "") base_args["base_ref"] = os.environ.get("BASE_REF", "HEAD^1") base_args["linux_use_prebuilt_artifacts"] = ( os.environ.get("LINUX_USE_PREBUILT_ARTIFACTS") == "true" @@ -855,6 +683,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 new file mode 100755 index 00000000000..87b096c5e6d --- /dev/null +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -0,0 +1,972 @@ +#!/usr/bin/env python3 +# Copyright Advanced Micro Devices, Inc. +# SPDX-License-Identifier: MIT + +"""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, 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 + 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, +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) + → 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 +available. Test details (which tests to run, quick 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.) + 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_build_config : JSON object with build config, or "" if skipped + windows_build_config : JSON object with build config, or "" if skipped + enable_build_jobs : "true" or "false" + test_type : "quick", "standard", "comprehensive", or "full" +""" + +import enum +import json +import os +from dataclasses import asdict, dataclass, field, fields + + +from amdgpu_family_matrix import all_build_variants, get_all_families_for_trigger_types +from configure_ci_path_filters import ( + get_git_modified_paths, + get_git_submodule_paths, + is_ci_run_required, +) +from github_actions_api 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, lowercased, non-empty names. + + Example: "gfx94X, gfx120X" → ["gfx94x", "gfx120x"] + """ + return [name.strip().lower() for name in raw.split(",") if name.strip()] + + +# --------------------------------------------------------------------------- +# 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. + """ + + 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) + + # 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 = "" + + # Prebuilt configuration (from workflow_dispatch) + prebuilt_stages: str = "" + baseline_run_id: str = "" + + def log(self) -> None: + """Log parsed inputs for CI diagnostics.""" + print("CIInputs:") + for f in fields(self): + print(f" {f.name}: {getattr(self, f.name)!r}") + + @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.""" + 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. + + # "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": + # 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": + base_ref = event.get("before", "HEAD^1") + + return CIInputs( + run_id=run_id, + event_name=event_name, + commit_ref=commit_ref, + base_ref=base_ref, + build_variant=build_variant, + pr_labels=pr_labels, + 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", ""), + baseline_run_id=inputs.get("baseline_run_id", ""), + ) + + +@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. + """ + + # 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 + 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": + """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: + """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 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) + + 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 +# +# 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) +# --------------------------------------------------------------------------- + + +class JobAction(enum.Enum): + """Action for a node in the CI job graph or a build stage.""" + + RUN = "run" + PREBUILT = "prebuilt" + SKIP = "skip" + + +@dataclass(frozen=True) +class JobGroupDecision: + """Decision for one node in the CI job graph. + + 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, 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. + baseline_run_id: str = "" + + @property + def prebuilt_stages(self) -> list[str]: + return [ + name + for name, action in self.stage_decisions.items() + if action == JobAction.PREBUILT + ] + + @property + def rebuild_stages(self) -> list[str]: + return [ + name + for name, action in self.stage_decisions.items() + if action == JobAction.RUN + ] + + +@dataclass(frozen=True) +class TestRocmDecision(JobGroupDecision): + """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 = "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) +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 + + 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.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) +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. + """ + + 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 + build_variant_suffix: str + build_variant_cmake_preset: str + expect_failure: bool + build_pytorch: bool + # Prebuilt stage configuration — set by configure() from JobDecisions. + prebuilt_stages: list[str] = field(default_factory=list) + baseline_run_id: str = "" + + def to_dict(self) -> dict: + d = asdict(self) + d["prebuilt_stages"] = ",".join(self.prebuilt_stages) + return d + + +@dataclass(frozen=True) +class BuildConfigs: + """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: + """All outputs from the CI configuration pipeline.""" + + 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() -> "CIOutputs": + """Produce empty outputs when CI is skipped.""" + return CIOutputs(is_ci_enabled=False) + + +# --------------------------------------------------------------------------- +# Step 2: Check Skip CI +# --------------------------------------------------------------------------- + + +def should_skip_ci( + ci_inputs: CIInputs, + git_context: GitContext, +) -> bool: + """Determine whether CI should be skipped entirely. + + Returns True for: + - 'ci:skip' PR label + - Only skippable files changed (docs, .md, etc.) + - No files changed + """ + if "ci:skip" in ci_inputs.pr_labels: + print(" Skipping: 'ci:skip' PR label") + return True + + # 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) " + f"against path filters..." + ) + 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 + + +# --------------------------------------------------------------------------- +# Step 3: Decide Jobs +# --------------------------------------------------------------------------- + + +_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. + + 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. + 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}" + + # 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: + 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 + ): + matching = set(git_context.submodule_paths) & set(git_context.changed_files) + if matching: + return "full", f"submodule(s) changed: {sorted(matching)}" + + # Default: quick tests for fast CI feedback. + return "quick", "default" + + +def decide_jobs( + ci_inputs: CIInputs, + git_context: GitContext, +) -> JobDecisions: + """Determine which job groups to run, skip, or satisfy with prebuilt 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, + ) + + # 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, + ) + + # Other jobs run unconditionally with no configuration. + # TODO: job pruning: skip pytorch if only JAX has been edited, etc. + + return JobDecisions( + 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), + ) + + +# --------------------------------------------------------------------------- +# 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, + 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 all_families and platform in all_families[name] + ] + + +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: + + - 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 (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. + - 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. + """ + all_families = get_all_families_for_trigger_types( + ["presubmit", "postsubmit", "nightly"] + ) + + # Select family names per platform based on trigger type. + # Ordered from most-specific (workflow_dispatch) to broadest (schedule). + 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(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, ci:run-all-archs + # for everything). + defaults = list(get_all_families_for_trigger_types(["presubmit"]).keys()) + linux_names = list(defaults) + windows_names = list(defaults) + 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. + defaults = list( + get_all_families_for_trigger_types(["presubmit", "postsubmit"]).keys() + ) + linux_names = list(defaults) + windows_names = list(defaults) + 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: {ci_inputs.event_name!r}") + + # PR labels can extend the family set (both platforms) + if ci_inputs.is_pull_request: + for label in ci_inputs.pr_labels: + if label == "ci:run-all-archs": + # Override to all families. + linux_names = list(all_families.keys()) + windows_names = list(all_families.keys()) + 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) + 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. + # 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) + + return TargetSelection( + linux_families=linux_names, + windows_families=windows_names, + ) + + +# --------------------------------------------------------------------------- +# Step 5: Build Configs +# --------------------------------------------------------------------------- + + +def _expand_build_config_for_platform( + families: list[str], + platform: str, + ci_inputs: CIInputs, + all_families: dict[str, dict], + variant_config: dict, + prebuilt_stages: list[str] | None = None, + baseline_run_id: str = "", +) -> 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 on this platform, then bundles them into a BuildConfig. + + 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 + """ + 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 + # amdgpu_family_matrix_test.py. We can index directly here. + platform_info = all_families[family_name][platform] + + # 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 + + # 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": test_runs_on, + "sanity_check_only_for_family": platform_info.get( + "sanity_check_only_for_family", False + ), + } + ) + + if not per_family_info: + 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 BuildConfig( + 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"], + 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, + prebuilt_stages=prebuilt_stages or [], + baseline_run_id=baseline_run_id, + ) + + +def expand_build_configs( + targets: TargetSelection, + ci_inputs: CIInputs, + prebuilt_stages: list[str] | None = None, + baseline_run_id: str = "", +) -> BuildConfigs: + """Build a BuildConfig for each platform that supports the variant. + + 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"] + ) + build_variant = ci_inputs.build_variant + + linux_config: BuildConfig | None = None + windows_config: BuildConfig | None = None + + 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" + ) + continue + config = _expand_build_config_for_platform( + families=families, + platform=platform, + ci_inputs=ci_inputs, + all_families=all_families, + variant_config=variant_config, + prebuilt_stages=prebuilt_stages, + baseline_run_id=baseline_run_id, + ) + if platform == "linux": + linux_config = config + else: + windows_config = config + + return BuildConfigs( + linux=linux_config, + windows=windows_config, + ) + + +# --------------------------------------------------------------------------- +# Step 6: Format and Write Outputs +# --------------------------------------------------------------------------- + + +def write_outputs( + ci_inputs: CIInputs, + outputs: CIOutputs, +) -> None: + """Write results to GITHUB_OUTPUT and GITHUB_STEP_SUMMARY. + + This is the only function with side effects (besides from_environ). + """ + 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), + "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, + } + gha_set_output(output_vars) + + # 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, + outputs=outputs, + ) + ) + + +# --------------------------------------------------------------------------- +# Pipeline orchestration +# --------------------------------------------------------------------------- + + +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 + 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() + + print("\n=== Checking if CI should run ===") + 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 ===") + 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() + + print("\n=== Building per-platform configs ===") + builds = expand_build_configs( + targets=targets, + ci_inputs=ci_inputs, + prebuilt_stages=jobs.build_rocm.prebuilt_stages, + baseline_run_id=jobs.build_rocm.baseline_run_id, + ) + builds.log() + + return 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, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + ci_inputs = CIInputs.from_environ() + + 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) + write_outputs(ci_inputs=ci_inputs, outputs=outputs) + + +if __name__ == "__main__": + 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..6ebc1b18012 --- /dev/null +++ b/build_tools/github_actions/configure_multi_arch_ci_summary.py @@ -0,0 +1,235 @@ +"""Formats the GITHUB_STEP_SUMMARY markdown for configure_multi_arch_ci.py. + +Produces human-readable markdown explaining what CI will do and why. +""" + +from configure_multi_arch_ci import ( + CIInputs, + CIOutputs, +) +from pathlib import Path +import sys + +THIS_SCRIPT_DIR = Path(__file__).resolve().parent +THEROCK_DIR = THIS_SCRIPT_DIR.parent.parent + +sys.path.insert(0, str(THEROCK_DIR / "build_tools")) +from _therock_utils.workflow_outputs import WorkflowOutputRoot + +# 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, + outputs: CIOutputs, +) -> str: + """Generate the full step summary markdown.""" + 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) + + if not outputs.jobs: + return "\n".join(lines) + + # One-liner: trigger, branch, variant + lines.append( + f"Trigger: `{ci_inputs.event_name}` on `{ci_inputs.commit_ref}`, " + f"`{ci_inputs.build_variant}` variant." + ) + 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) + + # 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 highlights: + lines.append(f"> - {callout}") + lines.append("") + + lines.append("### build-rocm") + lines.append("") + _append_build_rocm(lines, ci_inputs, outputs) + + lines.append("### test-rocm") + lines.append("") + _append_test_rocm(lines, outputs) + + return "\n".join(lines) + + +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" + 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) + + +def _non_default_highlights(ci_inputs: CIInputs) -> list[str]: + highlights: list[str] = [] + + # Explicit family selection (workflow_dispatch) + if ci_inputs.is_workflow_dispatch: + 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"): + highlights.append( + f"Label `{label}`: added family `{label}` " + f"(not in default presubmit set)" + ) + elif label.startswith("test_filter:"): + 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:"): + highlights.append(f"Label `{label}`") + + # Explicit test labels (workflow_dispatch) + if ci_inputs.is_workflow_dispatch: + if ci_inputs.linux_test_labels: + highlights.append( + f"Explicit Linux test labels: `{ci_inputs.linux_test_labels}`" + ) + if ci_inputs.windows_test_labels: + highlights.append( + f"Explicit Windows test labels: `{ci_inputs.windows_test_labels}`" + ) + + 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 + 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} | — | — |") + else: + families = ", ".join( + f"`{f}`" for f in config.dist_amdgpu_families.split(";") + ) + lines.append(f"| {platform} | {families} | `{config.artifact_group}` |") + lines.append("") + + # Link to log and artifact index pages + lines.extend( + [ + "## Build outputs", + "", + "Platform | 📋 Logs | 📦 Artifacts", + "-- | -- | --", + ] + ) + for platform_name in ["linux", "windows"]: + output_root = WorkflowOutputRoot.from_workflow_run( + run_id=ci_inputs.run_id, platform=platform_name + ) + log_url = output_root.root_log_index().https_url + artifact_url = output_root.root_index().https_url + lines.append(f"{platform_name.capitalize()} | {log_url} | {artifact_url}") + + +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 + + lines.append( + f"Test level: **{test_rocm.test_type}** ({test_rocm.test_type_reason})" + ) + + # Component test labels (per platform) + if outputs.linux_test_labels: + lines.append(f"Component tests (Linux): `{outputs.linux_test_labels}`") + if outputs.windows_test_labels: + lines.append(f"Component tests (Windows): `{outputs.windows_test_labels}`") + lines.append("") + + # Per-family test runner table + lines.append("| Platform | Family | Runner Label | Scope |") + lines.append("|----------|--------|--------------|-------|") + for platform, config in [ + ("Linux", outputs.builds.linux), + ("Windows", outputs.builds.windows), + ]: + if config is None: + continue + 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 "—" + 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("") diff --git a/build_tools/github_actions/tests/configure_ci_test.py b/build_tools/github_actions/tests/configure_ci_test.py index 840c753f4f3..f4ddd423452 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 @@ -400,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, @@ -537,355 +508,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 new file mode 100644 index 00000000000..ae568147ff4 --- /dev/null +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -0,0 +1,896 @@ +# 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 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 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_from_environ( + event_name: str, + event_payload: dict, + *, + commit_ref: 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_RUN_ID": "12345", + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": event_path, + "GITHUB_REF_NAME": commit_ref, + "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 +# --------------------------------------------------------------------------- + + +class TestCIInputs(unittest.TestCase): + """Test CIInputs dataclass and its properties.""" + + def test_event_type_properties(self): + """Event type properties are mutually exclusive.""" + inputs = cm.CIInputs( + run_id="12345", + event_name="pull_request", + commit_ref="feature", + base_ref="HEAD^", + build_variant="release", + ) + self.assertTrue(inputs.is_pull_request) + self.assertFalse(inputs.is_push) + self.assertFalse(inputs.is_schedule) + self.assertFalse(inputs.is_workflow_dispatch) + + def test_defaults(self): + """Fields with defaults can be omitted.""" + inputs = cm.CIInputs( + run_id="12345", + event_name="push", + commit_ref="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. + + 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}, + ] + } + }, + commit_ref="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") + + +# --------------------------------------------------------------------------- +# Step 2: Check Skip CI +# --------------------------------------------------------------------------- + + +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 should_skip_ci's own + logic: label handling, None changed_files passthrough, and delegation. + """ + + def _inputs(self, **kwargs): + defaults = dict( + run_id="12345", + event_name="pull_request", + commit_ref="feature", + base_ref="HEAD^", + build_variant="release", + ) + defaults.update(kwargs) + return cm.CIInputs(**defaults) + + def test_skip_ci_label(self): + """PR with ci:skip label skips CI regardless of changed files.""" + 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_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.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() + 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"]) + + @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() + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + 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() + self.assertFalse(cm.should_skip_ci(inputs, git)) + mock_filter.assert_not_called() + + +# --------------------------------------------------------------------------- +# Step 3: Decide Jobs +# --------------------------------------------------------------------------- + + +class TestDecideJobs(unittest.TestCase): + """Test job decision logic and test_type determination.""" + + def _inputs(self, **kwargs): + defaults = dict( + run_id="12345", + event_name="pull_request", + commit_ref="feature", + base_ref="HEAD^", + build_variant="release", + ) + 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, 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.""" + 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_raises(self): + """Unrecognized test_filter value raises ValueError.""" + git = cm.GitContext(changed_files=["CMakeLists.txt"]) + with self.assertRaises(ValueError, msg="Unrecognized test_filter"): + cm.decide_jobs( + 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( + action=cm.JobAction.RUN, + stage_decisions={ + "foundation": cm.JobAction.PREBUILT, + "compiler-runtime": cm.JobAction.PREBUILT, + "math-libs": cm.JobAction.RUN, + }, + ) + self.assertEqual( + sorted(decision.prebuilt_stages), + ["compiler-runtime", "foundation"], + ) + 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( + run_id="12345", + event_name="push", + commit_ref="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( + run_id="12345", + event_name="schedule", + commit_ref="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( + run_id="12345", + event_name="push", + commit_ref="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( + run_id="12345", + event_name="pull_request", + commit_ref="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( + run_id="12345", + event_name="pull_request", + commit_ref="feature", + base_ref="HEAD^", + build_variant="release", + ) + inputs_with = cm.CIInputs( + run_id="12345", + event_name="pull_request", + commit_ref="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 ci:run-all-archs label selects all families.""" + inputs = cm.CIInputs( + run_id="12345", + event_name="pull_request", + commit_ref="feature", + base_ref="HEAD^", + build_variant="release", + pr_labels=["ci:run-all-archs"], + ) + 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( + run_id="12345", + event_name="pull_request", + commit_ref="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( + run_id="12345", + event_name="workflow_dispatch", + commit_ref="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( + run_id="12345", + event_name="workflow_dispatch", + commit_ref="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( + run_id="12345", + event_name="workflow_dispatch", + commit_ref="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( + run_id="12345", + event_name="workflow_dispatch", + commit_ref="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( + run_id="12345", + event_name="repository_dispatch", + commit_ref="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( + run_id="12345", + event_name="push", + commit_ref="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: Build Configs +# --------------------------------------------------------------------------- + + +class TestExpandBuildConfigs(unittest.TestCase): + """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( + per_family_info=[], + dist_amdgpu_families="", + artifact_group="multi-arch-release", + build_variant_label="release", + build_variant_suffix="", + build_variant_cmake_preset="", + expect_failure=False, + build_pytorch=True, + ) + d = config.to_dict() + # 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): + """Empty targets on both platforms → both None.""" + targets = cm.TargetSelection() + result = cm.expand_build_configs(targets=targets, ci_inputs=self._inputs()) + 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.""" + inputs = cm.CIInputs( + run_id="12345", + event_name="push", + commit_ref="main", + base_ref="HEAD^1", + build_variant="release", + ) + targets = cm.select_targets(inputs) + result = cm.expand_build_configs(targets=targets, ci_inputs=inputs) + 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 = config.per_family_info + 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: + + per_family_info — 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"], + ) + 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 + self.assertEqual(len(linux_per_family), 2) + 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. + 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", "gfx110x"], + windows_families=["gfx110x"], + ) + 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 + self.assertEqual(len(linux_per_family), 1) + # 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 +# --------------------------------------------------------------------------- + + +class TestFormatSummary(unittest.TestCase): + """Test summary formatting (pure function).""" + + 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_skipped_summary(self): + outputs = cm.CIOutputs.skipped() + 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"), + 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) + 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")) + + +# --------------------------------------------------------------------------- +# 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() + self.assertFalse(outputs.is_ci_enabled) + self.assertIsNone(outputs.builds.linux) + self.assertIsNone(outputs.builds.windows) + + @patch("configure_multi_arch_ci.should_skip_ci") + 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", + commit_ref="main", + base_ref="HEAD^1", + build_variant="release", + ) + outputs = cm.configure(inputs, cm.GitContext()) + self.assertFalse(outputs.is_ci_enabled) + self.assertIsNone(outputs.builds.linux) + + +# --------------------------------------------------------------------------- +# 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}", + ) + + 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}", + ) + + +if __name__ == "__main__": + unittest.main()