From 0bfb1a475c2750c61715c39870d4027e5cb3a2fb Mon Sep 17 00:00:00 2001 From: Dass Date: Mon, 19 Oct 2026 14:37:44 -0400 Subject: [PATCH 1/3] ci: wire native Windows MSI build into CI with build_native_windows opt-in Part 1 of 2 splitting the Milestone 4 MSI wiring (#1987). This PR wires the MSI build into the CI (nightly) path and lands the shared pieces both CI and release need; a follow-up PR adds the release-bucket publish + install-test dispatch on top. CI wiring: - multi_arch_ci_windows.yml: add build_native_msi_packages, gated behind build_native_windows so CI in other repos (not building all of ROCm) skips it. Builds the runtime MSI from this run's artifacts and uploads it to the run's artifacts bucket. No release-bucket publish here. - build_native_windows flag plumbed like build_native_linux: input on multi_arch_ci.yml, exported by setup_multi_arch.yml, read into CIInputs/BuildConfig by configure_multi_arch_ci.py (windows-scoped, auto-disabled on partial builds), PR-label overrides ci:build/skip-native-windows (configure_ci_labels.py + docs). Shared core (also needed by the release follow-up): - multi_arch_build_native_windows_packages.yml: derive a WiX-legal numeric MSI ProductVersion (major.minor..0) from the PEP440 wheel version, which WiX rejects (WIX0108); keep the full version as the display version. compute_rocm_package_version.py gains package_type=msi; generator gains --display-version and per-major.minor UpgradeCode coexistence. - skip-ci-config.toml: register the build workflow (now transitively reached from multi_arch_ci.yml) so configure_ci_path_filters stays green. Co-Authored-By: Claude Opus 4 (1M context) --- ...lti_arch_build_native_windows_packages.yml | 40 ++++++++- .github/workflows/multi_arch_ci.yml | 8 ++ .github/workflows/multi_arch_ci_windows.yml | 22 +++++ .github/workflows/setup_multi_arch.yml | 5 ++ build_tools/compute_rocm_package_version.py | 73 +++++++++++++-- .../github_actions/configure_ci_labels.py | 2 + .../github_actions/configure_multi_arch_ci.py | 16 ++++ .../github_actions/skip-ci-config.toml | 1 + .../tests/configure_multi_arch_ci_test.py | 67 +++++++++++++- .../packaging/windows/generate_msi_wxs.py | 73 +++++++++++++-- .../windows/tests/generate_msi_wxs_test.py | 90 +++++++++++++++++-- .../compute_rocm_package_version_test.py | 78 ++++++++++++++++ docs/development/ci_behavior_manipulation.md | 2 + docs/packaging/versioning.md | 56 +++++++++++- 14 files changed, 507 insertions(+), 26 deletions(-) diff --git a/.github/workflows/multi_arch_build_native_windows_packages.yml b/.github/workflows/multi_arch_build_native_windows_packages.yml index 601e1e4d602..8da027e528a 100644 --- a/.github/workflows/multi_arch_build_native_windows_packages.yml +++ b/.github/workflows/multi_arch_build_native_windows_packages.yml @@ -40,6 +40,13 @@ on: description: "Branch, tag, or SHA to checkout. Defaults to the triggering ref." type: string default: "" + quartz_tracking_id: + description: >- + Leave empty. Internal Quartz tracking id, set automatically by the + top-level orchestrator to link this run to its release lineage. Do not + set it manually. + type: string + default: "" workflow_call: inputs: artifact_github_repo: @@ -63,6 +70,9 @@ on: ref: type: string default: "" + quartz_tracking_id: + type: string + default: "" permissions: contents: read @@ -84,7 +94,7 @@ jobs: env: ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} ARTIFACT_GITHUB_REPO: ${{ inputs.artifact_github_repo }} - PACKAGE_VERSION: ${{ inputs.rocm_version }} + ROCM_VERSION: ${{ inputs.rocm_version }} PACKAGES: ${{ inputs.packages }} MSI_DIR: ${{ github.workspace }}/msi-output RELEASE_TYPE: ${{ inputs.release_type }} @@ -105,6 +115,31 @@ jobs: - name: Install Python requirements run: pip install -r requirements.txt + # The incoming rocm_version is the PEP440 wheel version (e.g. + # 10.2.0.dev0+), which WiX rejects (WIX0108: the MSI ProductVersion + # must be numeric #.#.#.#). Derive the numeric MSI ProductVersion + # (major.minor..0) for Package/@Version; the full + # rocm_version is kept as the display version (ARP + discovery key). Writes + # to GITHUB_ENV rather than capturing stdout, since the helper logs there. + - name: Compute MSI version + run: | + python - <<'PY' + import os, re + from compute_rocm_package_version import compute_msi_build_field + + rocm_version = os.environ["ROCM_VERSION"] + m = re.match(r"^(\d+)\.(\d+)\.", rocm_version) + if not m: + raise SystemExit(f"cannot parse major.minor from {rocm_version!r}") + major, minor = m.group(1), m.group(2) + msi_version = f"{major}.{minor}.{compute_msi_build_field()}.0" + with open(os.environ["GITHUB_ENV"], "a") as f: + f.write(f"MSI_PACKAGE_VERSION={msi_version}\n") + print(f"MSI ProductVersion: {msi_version} (display {rocm_version})") + PY + env: + PYTHONPATH: ${{ github.workspace }}/build_tools + # Pin to WiX v4: v5+ compiles the same schema but v7 gates every command # behind the OSMF EULA (error WIX7015), which would fail unattended CI. - name: Install WiX Toolset v4 @@ -131,7 +166,8 @@ jobs: --packages "${PACKAGES}" \ --run-id "${ARTIFACT_RUN_ID}" \ --run-github-repo "${ARTIFACT_GITHUB_REPO}" \ - --package-version "${PACKAGE_VERSION}" \ + --package-version "${MSI_PACKAGE_VERSION}" \ + --display-version "${ROCM_VERSION}" \ --output-dir "${MSI_DIR}" # Compile every generated .wxs into a matching .msi alongside it. diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 007a71d3f8f..663d2f18014 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -52,6 +52,10 @@ on: type: boolean default: true description: "Build native Linux packages and run install tests" + build_native_windows: + type: boolean + default: true + description: "Build native Windows packages (MSI)" # --- Artifact reuse --- prebuilt_stages: type: string @@ -113,6 +117,9 @@ on: build_native_linux: type: boolean default: true + build_native_windows: + type: boolean + default: true prebuilt_stages: type: string default: "" @@ -170,6 +177,7 @@ jobs: build_pytorch: ${{ github.event_name != 'workflow_dispatch' || inputs.build_pytorch }} build_jax: ${{ github.event_name != 'workflow_dispatch' || inputs.build_jax }} build_native_linux: ${{ github.event_name != 'workflow_dispatch' || inputs.build_native_linux }} + build_native_windows: ${{ github.event_name != 'workflow_dispatch' || inputs.build_native_windows }} build_stages: ${{ inputs.build_stages || '' }} linux_build_and_test: diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index 681b050716a..0c905157c7f 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -184,6 +184,28 @@ jobs: contents: read id-token: write + # Build the native Windows MSI installer(s) from this run's artifacts. In CI + # the MSI is produced and uploaded to the run's artifacts bucket (a build + # artifact) to exercise the packaging path; it is not published to a release + # bucket here (that happens only in the release workflow). + build_native_msi_packages: + needs: [build_multi_arch_stages] + name: Build Native MSI Packages + if: ${{ !failure() && !cancelled() && !contains(needs.*.result, 'cancelled') && fromJSON(inputs.build_config).build_native_windows == true }} + uses: ./.github/workflows/multi_arch_build_native_windows_packages.yml + with: + artifact_run_id: ${{ github.run_id }} + rocm_version: ${{ inputs.rocm_package_version }} + # Only the runtime package is published in nightly artifacts today; core's + # Windows artifacts (core-amdsmi, core-runtime) are not yet available. + packages: runtime + release_type: ${{ inputs.release_type }} + repository: ${{ inputs.repository }} + ref: ${{ inputs.ref }} + permissions: + contents: read + id-token: write + test_python_packages_per_family: needs: [build_python_packages] name: Test Python diff --git a/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml index 9dc8fc62649..4d73ab36c75 100644 --- a/.github/workflows/setup_multi_arch.yml +++ b/.github/workflows/setup_multi_arch.yml @@ -91,6 +91,10 @@ on: description: "Build native Linux packages (deb/rpm) and run their install tests." type: boolean default: true + build_native_windows: + description: "Build native Windows packages (MSI)." + type: boolean + default: true python_version: description: "Single Python version for wheel builds; empty uses the default matrix." type: string @@ -246,6 +250,7 @@ jobs: BUILD_PYTORCH: ${{ inputs.build_pytorch }} BUILD_JAX: ${{ inputs.build_jax }} BUILD_NATIVE_LINUX: ${{ inputs.build_native_linux }} + BUILD_NATIVE_WINDOWS: ${{ inputs.build_native_windows }} PYTHON_VERSION: ${{ inputs.python_version }} LINUX_AMDGPU_FAMILIES: ${{ inputs.linux_amdgpu_families }} WINDOWS_AMDGPU_FAMILIES: ${{ inputs.windows_amdgpu_families }} diff --git a/build_tools/compute_rocm_package_version.py b/build_tools/compute_rocm_package_version.py index 9e702972677..463a6fe10e4 100644 --- a/build_tools/compute_rocm_package_version.py +++ b/build_tools/compute_rocm_package_version.py @@ -6,11 +6,12 @@ """Computes a ROCm package version with an appropriate suffix for a given release type. For usage from other Python scripts, call the `compute_version()` function -directly. When used from GitHub Actions, this writes all three version outputs -to GITHUB_OUTPUT: +directly. When used from GitHub Actions, this writes all version outputs to +GITHUB_OUTPUT: - 'rocm_package_version' for wheel packages - 'rocm_deb_package_version' for deb packages - 'rocm_rpm_package_version' for rpm packages + - 'rocm_msi_package_version' for Windows MSI packages Sample usage: @@ -18,6 +19,7 @@ # rocm_package_version=7.10.0.dev0+f689a8ea40232f3f6be1ec958354b108349023ff # rocm_deb_package_version=7.10.0~dev20251203 # rocm_rpm_package_version=7.10.0~20251203gf689a8e + # rocm_msi_package_version=7.10.3929.0 (major.minor..0) python compute_rocm_package_version.py --release-type=prerelease --prerelease-version=2 # rocm_package_version=7.10.0rc2 @@ -43,7 +45,7 @@ import argparse from collections.abc import Mapping -from datetime import datetime +from datetime import date, datetime from pathlib import Path import json import os @@ -56,6 +58,16 @@ THIS_SCRIPT_DIR = Path(__file__).resolve().parent THEROCK_DIR = THIS_SCRIPT_DIR.parent +# Epoch for the MSI ProductVersion build field: the first ROCm release (ROCm 1.0, +# April 2016). The MSI version's third field is the number of days since this +# date, giving a strictly increasing, WiX-legal integer. See compute_msi_build_field. +MSI_EPOCH = date(2016, 4, 1) + +# Windows Installer compares only the first three ProductVersion fields and caps +# the third (build) field at 65535. days-since-MSI_EPOCH stays under this until +# ~year 2195, after which a build would raise rather than silently wrap. +MSI_BUILD_FIELD_MAX = 65535 + def _log(*args, **kwargs): print(*args, **kwargs) @@ -137,6 +149,27 @@ def get_current_date(): return datetime.today().strftime("%Y%m%d") +def compute_msi_build_field(today: date | None = None) -> int: + """Return the MSI ProductVersion build field: days since MSI_EPOCH. + + This is the third field of the four-field MSI version. It increases by one + per day, so a later build always outranks an earlier one within its + major.minor line (Windows Installer compares only the first three fields). + Raises if it would exceed the installer's 65535 limit. + """ + if today is None: + today = date.today() + days = (today - MSI_EPOCH).days + if days < 0: + raise ValueError(f"build date {today} precedes the MSI epoch {MSI_EPOCH}") + if days > MSI_BUILD_FIELD_MAX: + raise ValueError( + f"MSI build field {days} exceeds the Windows Installer limit of " + f"{MSI_BUILD_FIELD_MAX} (third ProductVersion field)" + ) + return days + + def compute_version( package_type: str = "wheel", release_type: str | None = None, @@ -149,7 +182,7 @@ def compute_version( """Compute package version based on package type and release type. Args: - package_type: Type of package ("wheel", "deb", or "rpm") + package_type: Type of package ("wheel", "deb", "rpm", or "msi") release_type: Release type ("ci", "dev", "dev-bkc", "nightly", "nightly-bkc", "prerelease", or "release") custom_version_suffix: Custom suffix to override automatic suffix @@ -183,6 +216,32 @@ def compute_version( f"for release type '{release_type}'" ) + # Handle Windows MSI packages (WiX ProductVersion: numeric major.minor.build.0). + # + # Unlike the other package types, the MSI version does NOT carry a dev/nightly + # suffix: Windows Installer versions must be numeric #.#.#.# and it compares + # only the first three fields. We keep major.minor from version.json visible + # and put the build DATE (days since the ROCm 1.0 epoch) in the third field, + # so every build outranks the previous one within its major.minor line. The + # marketing patch (e.g. 10.2.0) is surfaced separately via the MSI's + # ARPDISPLAYVERSION property, not here. Cross-line coexistence (10.2 vs 10.3) + # is handled by a per-major.minor UpgradeCode in the generator, so this field + # never has to order across lines. All release types share one shape; a + # release simply has the highest build field in its line because it is built + # last (no further nightlies follow a major.minor's release). + if package_type == "msi": + parts = base_version.split(".") + if len(parts) < 2 or not parts[0] or not parts[1]: + raise ValueError( + f"base version must be at least major.minor for msi (got " + f"{base_version!r})" + ) + major, minor = parts[0], parts[1] + build_field = compute_msi_build_field() + rocm_package_version = f"{major}.{minor}.{build_field}.0" + _log(f"Full version : '{rocm_package_version}'") + return rocm_package_version + # Handle wheel packages (Python packaging standards) if package_type == "wheel": if custom_version_suffix: @@ -315,9 +374,9 @@ def main(argv): version_data = load_version_file() - # Compute versions for all three package types: wheel, deb, and rpm + # Compute versions for all package types: wheel, deb, rpm, and msi. outputs = {} - for pkg_type in ["wheel", "deb", "rpm"]: + for pkg_type in ["wheel", "deb", "rpm", "msi"]: version = compute_version( package_type=pkg_type, release_type=args.release_type, @@ -331,7 +390,7 @@ def main(argv): # Set appropriate output variable based on package type if pkg_type == "wheel": outputs["rocm_package_version"] = version - else: # deb or rpm + else: # deb, rpm, or msi outputs[f"rocm_{pkg_type}_package_version"] = version gha_set_output(outputs) diff --git a/build_tools/github_actions/configure_ci_labels.py b/build_tools/github_actions/configure_ci_labels.py index e25dc2e6957..c8c2a7f01a7 100755 --- a/build_tools/github_actions/configure_ci_labels.py +++ b/build_tools/github_actions/configure_ci_labels.py @@ -173,10 +173,12 @@ class Label: Label("ci:build-pytorch", COLOR_CI_PACKAGING, "Opt-in to building PyTorch packages"), Label("ci:build-jax", COLOR_CI_PACKAGING, "Opt-in to building JAX packages"), Label("ci:build-native-linux", COLOR_CI_PACKAGING, "Opt-in to building native Linux packages"), + Label("ci:build-native-windows", COLOR_CI_PACKAGING, "Opt-in to building native Windows packages"), Label("ci:build-python-packages", COLOR_CI_PACKAGING, "Opt-in to building Python packages"), Label("ci:skip-pytorch", COLOR_CI_PACKAGING, "Skip PyTorch package builds"), Label("ci:skip-jax", COLOR_CI_PACKAGING, "Skip JAX package builds"), Label("ci:skip-native-linux", COLOR_CI_PACKAGING, "Skip native Linux package builds"), + Label("ci:skip-native-windows", COLOR_CI_PACKAGING, "Skip native Windows package builds"), Label("ci:skip-python-packages", COLOR_CI_PACKAGING, "Skip Python package builds"), ] # fmt: on diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index d9da99c8272..793cd411204 100755 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -225,6 +225,7 @@ class CIInputs: build_pytorch: bool = True build_jax: bool = False build_native_linux: bool = True + build_native_windows: bool = True python_versions: list[str] = field(default_factory=list) # Labels from the PR event payload or workflow_dispatch pr_labels input. @@ -330,6 +331,9 @@ def from_environ() -> "CIInputs": build_native_linux = ( os.environ.get("BUILD_NATIVE_LINUX", "true").lower() != "false" ) + build_native_windows = ( + os.environ.get("BUILD_NATIVE_WINDOWS", "true").lower() != "false" + ) python_version = os.environ.get("PYTHON_VERSION", "").strip() pr_labels: list[str] = [] @@ -414,6 +418,7 @@ def from_environ() -> "CIInputs": build_pytorch=build_pytorch, build_jax=build_jax, build_native_linux=build_native_linux, + build_native_windows=build_native_windows, python_versions=[python_version] if python_version else [], pr_labels=pr_labels, pr_base_ref=pr_base_ref, @@ -720,6 +725,7 @@ class BuildConfig: build_variant_suffix: str build_variant_cmake_preset: str build_native_linux: bool + build_native_windows: bool build_python_packages: bool build_pytorch: bool build_jax: bool @@ -1752,6 +1758,8 @@ def _expand_build_config_for_platform( else [] ) build_native_linux = ci_inputs.build_native_linux + # Native Windows (MSI) packages are built only on the windows platform. + build_native_windows = ci_inputs.build_native_windows and platform == "windows" # PR labels can override packaging build decisions. pr_labels = ci_inputs.pr_labels @@ -1760,6 +1768,12 @@ def _expand_build_config_for_platform( elif "ci:skip-native-linux" in pr_labels: build_native_linux = False + if platform == "windows": + if "ci:build-native-windows" in pr_labels: + build_native_windows = True + elif "ci:skip-native-windows" in pr_labels: + build_native_windows = False + if "ci:build-python-packages" in pr_labels: build_python_packages = True elif "ci:skip-python-packages" in pr_labels: @@ -1785,6 +1799,7 @@ def _expand_build_config_for_platform( build_pytorch = False build_jax = False build_native_linux = False + build_native_windows = False pytorch_build_matrix = [] jax_build_matrix = [] test_python_packages_matrix = [] @@ -1797,6 +1812,7 @@ def _expand_build_config_for_platform( build_variant_suffix=suffix, build_variant_cmake_preset=variant_config["build_variant_cmake_preset"], build_native_linux=build_native_linux, + build_native_windows=build_native_windows, build_python_packages=build_python_packages, build_pytorch=build_pytorch, build_jax=build_jax, diff --git a/build_tools/github_actions/skip-ci-config.toml b/build_tools/github_actions/skip-ci-config.toml index b77a10a967f..e78e4ef230b 100644 --- a/build_tools/github_actions/skip-ci-config.toml +++ b/build_tools/github_actions/skip-ci-config.toml @@ -74,6 +74,7 @@ filenames = [ "build_windows_python_packages.yml", "manifest-diff.yml", "multi_arch_build_native_linux_packages.yml", + "multi_arch_build_native_windows_packages.yml", "multi_arch_build_portable_linux_artifacts.yml", "multi_arch_build_portable_linux_pytorch_wheels_ci.yml", "multi_arch_build_linux_jax_wheels_ci.yml", diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 3ac24c7a0f3..5518c299bba 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -1550,6 +1550,7 @@ def test_build_config_to_dict_has_all_fields(self): build_pytorch=True, build_jax=False, build_native_linux=True, + build_native_windows=False, build_python_packages=True, ) d = config.to_dict() @@ -1582,6 +1583,7 @@ def test_build_config_serialization_empty_vs_present(self): build_pytorch=True, build_jax=False, build_native_linux=True, + build_native_windows=False, build_python_packages=True, ) # Present config → valid JSON @@ -1889,6 +1891,40 @@ def test_build_native_linux_input_disables_native_packages(self): ) self.assertFalse(result.linux.build_native_linux) + def test_build_native_windows_input_disables_native_packages(self): + """build_native_windows=False disables the Windows MSI build.""" + targets = cm.TargetSelection(windows_families=["gfx110x"]) + enabled = cm.expand_build_configs( + ci_inputs=self._inputs(build_native_windows=True), + git_context=cm.GitContext(), + targets=targets, + jobs=_jobs(), + ) + self.assertTrue(enabled.windows.build_native_windows) + + disabled = cm.expand_build_configs( + ci_inputs=self._inputs(build_native_windows=False), + git_context=cm.GitContext(), + targets=targets, + jobs=_jobs(), + ) + self.assertFalse(disabled.windows.build_native_windows) + + def test_build_native_windows_only_on_windows_platform(self): + """build_native_windows never enables the flag on the Linux config.""" + targets = cm.TargetSelection( + linux_families=["gfx94x"], + windows_families=["gfx110x"], + ) + result = cm.expand_build_configs( + ci_inputs=self._inputs(build_native_windows=True), + git_context=cm.GitContext(), + targets=targets, + jobs=_jobs(), + ) + self.assertFalse(result.linux.build_native_windows) + self.assertTrue(result.windows.build_native_windows) + 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. @@ -2198,6 +2234,29 @@ def test_packaging_label_overrides(self): self.assertIsNotNone(result.linux) self.assertEqual(getattr(result.linux, config_field), expected) + def test_native_windows_label_overrides(self): + """PR labels can enable/disable the native-windows (MSI) build.""" + targets = cm.TargetSelection(windows_families=["gfx110x"]) + cases = [ + # (label, input_default, expected) + ("ci:build-native-windows", False, True), + ("ci:skip-native-windows", True, False), + ] + for label, default, expected in cases: + with self.subTest(label=label): + result = cm.expand_build_configs( + ci_inputs=self._inputs( + event_name="pull_request", + build_native_windows=default, + pr_labels=[label], + ), + git_context=cm.GitContext(), + targets=targets, + jobs=_jobs(build_pytorch=False, build_jax=False), + ) + self.assertIsNotNone(result.windows) + self.assertEqual(result.windows.build_native_windows, expected) + def test_pytorch_forces_python_packages_even_with_skip_label(self): """PyTorch/JAX depend on python packages, so skip label is ignored.""" targets = cm.select_targets(self._inputs(event_name="pull_request")) @@ -2566,12 +2625,16 @@ def test_linux_workflow_uses_all_ci_fields(self): 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)} + # build_native_windows is Windows-only, so Linux CI does not consume it. + unused_fields = { + "build_native_windows", + } self.assertEqual( yaml_fields, - python_fields, + python_fields - unused_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}", + f" In Python but not YAML: {python_fields - yaml_fields - unused_fields}", ) def test_windows_workflow_uses_all_ci_fields(self): diff --git a/build_tools/packaging/windows/generate_msi_wxs.py b/build_tools/packaging/windows/generate_msi_wxs.py index d5f4dc24388..25253abf61f 100644 --- a/build_tools/packaging/windows/generate_msi_wxs.py +++ b/build_tools/packaging/windows/generate_msi_wxs.py @@ -76,7 +76,12 @@ class PackageDef: # {version} (X.Y.Z), {major}, and {minor}. install_subdir: str - # Fixed GUID for MajorUpgrade matching across versions. Must never change. + # Base namespace GUID for this package. The actual MSI UpgradeCode is derived + # per-major.minor as uuid5(upgrade_code, "major.minor") (see upgrade_code_for), + # so e.g. 10.2 and 10.3 get distinct UpgradeCodes and install side by side, + # while builds within one major.minor share an UpgradeCode and upgrade in + # place. This base GUID is a namespace, never emitted as an UpgradeCode + # directly; it must never change. upgrade_code: str # WiX Feature Id and Title for the primary feature. @@ -532,6 +537,17 @@ def parse_args() -> argparse.Namespace: metavar="X.Y.Z", help=(f"MSI version string. Default: {default_version} (from version.json)"), ) + parser.add_argument( + "--display-version", + default=default_version, + metavar="X.Y.Z", + help=( + "Marketing version shown in Add/Remove Programs (ARPDISPLAYVERSION). " + f"Default: {default_version} (from version.json). Unlike " + "--package-version, this is not required to be a numeric MSI version; " + "it is the human-facing ROCm version." + ), + ) args = parser.parse_args() @@ -630,6 +646,21 @@ def parse_major_minor(version: str) -> tuple[str, str]: return parts[0], parts[1] +def upgrade_code_for(package: PackageDef, version: str) -> str: + """Return the per-major.minor MSI UpgradeCode for ``package`` at ``version``. + + Derived deterministically as uuid5(package base GUID, "major.minor"), so a + given major.minor always maps to the same UpgradeCode (builds upgrade in + place) while different major.minor values map to different UpgradeCodes + (distinct products that coexist, e.g. ROCm 10.2 alongside 10.3). Formatted + uppercase inside braces, as WiX expects. + """ + major, minor = parse_major_minor(version) + namespace = uuid.UUID(package.upgrade_code) + derived = uuid.uuid5(namespace, f"{major}.{minor}") + return "{" + str(derived).upper() + "}" + + def expected_msi_filename(package: str) -> str: """Return the MSI filename the generator writes for ``package``. @@ -668,6 +699,7 @@ class PackageInputs: package: PackageDef version: str + display_version: str files: list[tuple[Path, Path]] legacy_dlls: list[tuple[str, Path]] @@ -684,6 +716,7 @@ class InstallLayout: product_dir: str version_dir: str version: str + display_version: str subdir_name: str major: str minor: str @@ -753,6 +786,7 @@ def resolve_package_inputs( return PackageInputs( package=package, version=args.package_version, + display_version=args.display_version, files=collect_files_from_catalog(artifact_dir, package), legacy_dlls=resolve_legacy_dlls( artifact_dir, @@ -774,18 +808,26 @@ def resolve_install_layout(args: argparse.Namespace, version: str) -> InstallLay product_dir=args.product_dir, version_dir=args.version_dir, version=version, + display_version=getattr(args, "display_version", version), subdir_name=subdir_name, major=major, minor=minor, ) -def create_wix_document(package: PackageDef, version: str) -> WixDocument: +def create_wix_document( + package: PackageDef, version: str, display_version: str +) -> WixDocument: """Create the / skeleton with summary, upgrade, and properties. Emits everything that precedes the directory tree: SummaryInformation, MajorUpgrade, MediaTemplate, the three control Properties, and the INSTALLFOLDER redirect. + + ``version`` is the numeric MSI ProductVersion (major.minor..0); + ``display_version`` is the human-facing marketing version (e.g. 10.2.0) + surfaced in Add/Remove Programs via ARPDISPLAYVERSION, since ProductVersion + carries a date in its third field rather than the marketing patch. """ root = ET.Element(_tag("Wix")) pkg = ET.SubElement( @@ -794,7 +836,10 @@ def create_wix_document(package: PackageDef, version: str) -> WixDocument: Name=package.product_name, Version=version, Manufacturer="Advanced Micro Devices, Inc.", - UpgradeCode=package.upgrade_code, + # Per-major.minor UpgradeCode: 10.2 and 10.3 are distinct products that + # coexist; builds within one major.minor share this code and upgrade in + # place. See upgrade_code_for. + UpgradeCode=upgrade_code_for(package, version), Language="1033", Codepage="1252", InstallerVersion="500", @@ -808,11 +853,25 @@ def create_wix_document(package: PackageDef, version: str) -> WixDocument: pkg, _tag("SummaryInformation"), Keywords="ROCm AMD GPU", - Description=f"{package.product_name} {version}", + Description=f"{package.product_name} {display_version}", + ) + # Show the marketing version (e.g. 10.2.0) in Add/Remove Programs; the + # numeric ProductVersion above carries the build date, not the patch. + ET.SubElement( + pkg, + _tag("Property"), + Id="ARPDISPLAYVERSION", + Value=display_version, + Secure="yes", ) + # AllowSameVersionUpgrades: two builds on the same day share the third + # ProductVersion field (the date). Without this, Windows Installer would let + # the second install side by side rather than replacing the first; with it, + # the later build replaces the earlier one. ET.SubElement( pkg, _tag("MajorUpgrade"), + AllowSameVersionUpgrades="yes", DowngradeErrorMessage=f"A newer version of {package.product_name} is already installed.", ) ET.SubElement(pkg, _tag("MediaTemplate"), EmbedCab="yes") @@ -1033,8 +1092,10 @@ def add_discovery_registry( _tag("RegistryValue"), Root="HKLM", Key=discovery_key, + # The marketing version (e.g. 10.2.0), not the dated MSI ProductVersion: + # tools discovering ROCm via this key want the human-facing version. Name="Version", - Value=layout.version, + Value=layout.display_version, Type="string", ) ET.SubElement(feature, _tag("ComponentRef"), Id="RocmDiscovery") @@ -1253,7 +1314,7 @@ def build_wxs(args: argparse.Namespace, artifact_dir: Path | None = None) -> Non inputs = resolve_package_inputs(args, artifact_dir) layout = resolve_install_layout(args, inputs.version) - doc = create_wix_document(inputs.package, inputs.version) + doc = create_wix_document(inputs.package, inputs.version, inputs.display_version) add_install_directory_tree(doc, layout) feature = add_primary_feature(doc, inputs.package) add_payload_components(doc, feature, inputs.files) diff --git a/build_tools/packaging/windows/tests/generate_msi_wxs_test.py b/build_tools/packaging/windows/tests/generate_msi_wxs_test.py index c576e193bf9..2dd311bf57f 100644 --- a/build_tools/packaging/windows/tests/generate_msi_wxs_test.py +++ b/build_tools/packaging/windows/tests/generate_msi_wxs_test.py @@ -9,6 +9,7 @@ import tempfile import unittest import unittest.mock +import uuid # Only parses XML generated by these tests. import xml.etree.ElementTree as ET # nosec B405 @@ -33,6 +34,7 @@ add_legacy_system32_feature, discovery_registry_key, parse_major_minor, + upgrade_code_for, expected_msi_filename, install_subdir_for, _stable_guid, @@ -342,6 +344,7 @@ def _run( product_dir="AMD", version_dir="ROCm", package_version="1.2.3", + display_version="1.2.3", run_id=None, run_github_repo=None, artifacts_cache_dir=root / "artifact-cache", @@ -369,7 +372,10 @@ def test_package_element_attributes(self): pkg = root.find(_ns("Package")) self.assertEqual(pkg.get("Version"), "1.2.3") self.assertEqual(pkg.get("Manufacturer"), "Advanced Micro Devices, Inc.") - self.assertEqual(pkg.get("UpgradeCode"), PACKAGES["runtime"].upgrade_code) + self.assertEqual( + pkg.get("UpgradeCode"), + upgrade_code_for(PACKAGES["runtime"], "1.2.3"), + ) def test_install_subdir_uses_version(self): with tempfile.TemporaryDirectory() as tmp: @@ -448,6 +454,7 @@ def test_no_files_emits_warning_not_error(self): product_dir="AMD", version_dir="ROCm", package_version="1.2.3", + display_version="1.2.3", run_id=None, run_github_repo=None, artifacts_cache_dir=root_path / "artifact-cache", @@ -700,7 +707,10 @@ def test_runtime_package(self): with tempfile.TemporaryDirectory() as tmp: root = self._run(tmp, self._minimal_specs("runtime"), package="runtime") pkg = root.find(_ns("Package")) - self.assertEqual(pkg.get("UpgradeCode"), PACKAGES["runtime"].upgrade_code) + self.assertEqual( + pkg.get("UpgradeCode"), + upgrade_code_for(PACKAGES["runtime"], "1.2.3"), + ) names = [d.get("Name") for d in root.iter(_ns("Directory"))] self.assertIn("core-1.2", names) @@ -758,6 +768,7 @@ def _args(self, root: Path, package_list: list, **overrides) -> argparse.Namespa product_dir="AMD", version_dir="ROCm", package_version="1.2.3", + display_version="1.2.3", run_id=None, run_github_repo=None, artifacts_cache_dir=root / "artifact-cache", @@ -941,6 +952,63 @@ def test_install_subdir_contains_version_placeholder(self): ) +class TestUpgradeCode(unittest.TestCase): + """Per-major.minor UpgradeCode derivation (coexistence of 10.2 and 10.3).""" + + def test_distinct_across_major_minor(self): + # Different major.minor -> different UpgradeCode -> distinct products that + # coexist side by side. + pkg = PACKAGES["runtime"] + code_10_2 = upgrade_code_for(pkg, "10.2.3850.0") + code_10_3 = upgrade_code_for(pkg, "10.3.3850.0") + self.assertNotEqual(code_10_2, code_10_3) + + def test_stable_within_major_minor(self): + # Same major.minor -> same UpgradeCode regardless of the build fields, so + # builds within a line upgrade in place. + pkg = PACKAGES["runtime"] + self.assertEqual( + upgrade_code_for(pkg, "10.2.3850.0"), + upgrade_code_for(pkg, "10.2.9999.0"), + ) + + def test_distinct_across_packages(self): + # The same major.minor in two packages must not collide: each package has + # its own base namespace GUID. + self.assertNotEqual( + upgrade_code_for(PACKAGES["runtime"], "10.2.0"), + upgrade_code_for(PACKAGES["core"], "10.2.0"), + ) + + def test_format_is_braced_uppercase_guid(self): + code = upgrade_code_for(PACKAGES["runtime"], "10.2.0") + self.assertTrue(code.startswith("{") and code.endswith("}")) + inner = code[1:-1] + self.assertEqual(inner, inner.upper()) + # Parses as a GUID. + uuid.UUID(inner) + + def test_arpdisplayversion_is_marketing_version(self): + # ProductVersion carries the dated build field; ARPDISPLAYVERSION carries + # the human-facing marketing version shown in Add/Remove Programs. + doc = create_wix_document(PACKAGES["runtime"], "10.2.3850.0", "10.2.0") + prop = next( + p + for p in doc.package.iter(_ns("Property")) + if p.get("Id") == "ARPDISPLAYVERSION" + ) + self.assertEqual(prop.get("Value"), "10.2.0") + self.assertEqual(doc.package.get("Version"), "10.2.3850.0") + + def test_allow_same_version_upgrades(self): + # Two builds the same day share the third ProductVersion field; the + # MajorUpgrade must allow same-version replacement so the later build + # replaces rather than installing beside the earlier one. + doc = create_wix_document(PACKAGES["runtime"], "10.2.3850.0", "10.2.0") + major = doc.package.find(_ns("MajorUpgrade")) + self.assertEqual(major.get("AllowSameVersionUpgrades"), "yes") + + class TestBuildWxsHelpers(unittest.TestCase): """Unit tests for the individually-testable build_wxs building blocks.""" @@ -1024,24 +1092,30 @@ def test_discovery_registry_key(self): def test_create_wix_document_emits_control_properties(self): pkg = PACKAGES["runtime"] - doc = create_wix_document(pkg, "1.2.3") + doc = create_wix_document(pkg, "1.2.3", "1.2.3") self.assertIsInstance(doc, WixDocument) prop_ids = {el.get("Id") for el in doc.package.findall(_ns("Property"))} self.assertEqual( - prop_ids, {"ENABLE_LONG_PATHS", "INSTALLFOLDER", "LEGACY_SYSTEM32_INSTALL"} + prop_ids, + { + "ARPDISPLAYVERSION", + "ENABLE_LONG_PATHS", + "INSTALLFOLDER", + "LEGACY_SYSTEM32_INSTALL", + }, ) def test_add_install_directory_tree_standard_vs_custom(self): pkg = PACKAGES["runtime"] # Standard token -> StandardDirectory root. - std_doc = create_wix_document(pkg, "1.2.3") + std_doc = create_wix_document(pkg, "1.2.3", "1.2.3") add_install_directory_tree( std_doc, resolve_install_layout(self._args(), "1.2.3") ) self.assertIsNotNone(std_doc.package.find(_ns("StandardDirectory"))) self.assertEqual(std_doc.install_dir.get("Id"), "InstallDir") # Absolute path -> TARGETDIR/CustomInstallRoot chain instead. - custom_doc = create_wix_document(pkg, "1.2.3") + custom_doc = create_wix_document(pkg, "1.2.3", "1.2.3") add_install_directory_tree( custom_doc, resolve_install_layout(self._args(install_root="C:\\AMD"), "1.2.3"), @@ -1051,7 +1125,7 @@ def test_add_install_directory_tree_standard_vs_custom(self): self.assertEqual(targetdir.get("Id"), "TARGETDIR") def test_add_legacy_system32_feature_no_dlls_is_noop(self): - doc = create_wix_document(PACKAGES["runtime"], "1.2.3") + doc = create_wix_document(PACKAGES["runtime"], "1.2.3", "1.2.3") add_legacy_system32_feature(doc, []) self.assertIsNone(doc.package.find(_ns("StandardDirectory"))) feature_ids = {f.get("Id") for f in doc.package.findall(_ns("Feature"))} @@ -1064,7 +1138,7 @@ def test_legacy_system32_feature_is_opt_in(self): # installed) and is raised to Level 1 only when LEGACY_SYSTEM32_INSTALL=1. The # LEGACY_SYSTEM32_INSTALL property defaults to "0". Guards against regressing to # the old install-by-default behavior. - doc = create_wix_document(PACKAGES["runtime"], "1.2.3") + doc = create_wix_document(PACKAGES["runtime"], "1.2.3", "1.2.3") prop = next( p for p in doc.package.iter(_ns("Property")) diff --git a/build_tools/tests/compute_rocm_package_version_test.py b/build_tools/tests/compute_rocm_package_version_test.py index b44b4b39bcc..31b7ff58d6b 100644 --- a/build_tools/tests/compute_rocm_package_version_test.py +++ b/build_tools/tests/compute_rocm_package_version_test.py @@ -600,6 +600,74 @@ def test_custom_version_suffix(self): self.assertEqual(version, "8.0.0~custom1") +class MsiPackageVersionTest(unittest.TestCase): + RELEASE_TYPES = ("ci", "dev", "nightly", "release") + + def _compute(self, release_type, base="10.2.0"): + return compute_rocm_package_version.compute_version( + package_type="msi", + release_type=release_type, + override_base_version=base, + ) + + def test_is_four_field_numeric(self): + # WiX requires a numeric #.#.#.# ProductVersion (WIX0108 otherwise). + for release_type in self.RELEASE_TYPES: + with self.subTest(release_type=release_type): + version = self._compute(release_type) + fields = version.split(".") + self.assertEqual(len(fields), 4) + for f in fields: + self.assertTrue(f.isdigit(), msg=f"non-numeric field in {version}") + + def test_keeps_major_minor_and_zero_fourth_field(self): + for release_type in self.RELEASE_TYPES: + with self.subTest(release_type=release_type): + major, minor, _build, fourth = self._compute(release_type).split(".") + self.assertEqual((major, minor), ("10", "2")) + self.assertEqual(fourth, "0") + + def test_build_field_within_installer_limit(self): + # The third field must fit Windows Installer's 65535 cap. + for release_type in self.RELEASE_TYPES: + with self.subTest(release_type=release_type): + build = int(self._compute(release_type).split(".")[2]) + self.assertLessEqual(build, 65535) + + def test_all_release_types_share_one_shape(self): + # No dev/nightly suffix: every release type yields the same version on a + # given day (release "wins" only because it is built last, not via a + # different formula). + versions = {self._compute(rt) for rt in self.RELEASE_TYPES} + self.assertEqual(len(versions), 1) + + def test_build_field_is_monotonic_by_date(self): + # A later build date must yield a strictly greater third field so a newer + # build upgrades an older one within its major.minor line. + import datetime + + earlier = compute_rocm_package_version.compute_msi_build_field( + datetime.date(2026, 10, 14) + ) + later = compute_rocm_package_version.compute_msi_build_field( + datetime.date(2026, 10, 16) + ) + self.assertEqual(later - earlier, 2) + self.assertGreater(later, earlier) + + def test_rejects_base_without_minor(self): + with self.assertRaisesRegex(ValueError, "major.minor"): + self._compute("release", base="10") + + def test_rejects_date_past_installer_limit(self): + import datetime + + with self.assertRaisesRegex(ValueError, "65535"): + compute_rocm_package_version.compute_msi_build_field( + datetime.date(2200, 1, 1) + ) + + # Test meaningful combinations of argparse options through the real computation path, # including main()'s side effect of writing versions to GitHub Actions outputs. class MainFunctionTest(unittest.TestCase): @@ -630,6 +698,7 @@ def test_sets_dev_outputs_with_version_overrides(self): "rocm_package_version", "rocm_deb_package_version", "rocm_rpm_package_version", + "rocm_msi_package_version", }, ) self.assertEqual( @@ -639,6 +708,9 @@ def test_sets_dev_outputs_with_version_overrides(self): self.assertTrue(outputs["rocm_deb_package_version"].startswith("7.99.0~dev")) self.assertTrue(outputs["rocm_rpm_package_version"].startswith("7.99.0~")) self.assertTrue(outputs["rocm_rpm_package_version"].endswith("gabcdef12")) + # MSI: major.minor..0 (no dev suffix). + build_field = compute_rocm_package_version.compute_msi_build_field() + self.assertEqual(outputs["rocm_msi_package_version"], f"7.99.{build_field}.0") def test_sets_prerelease_outputs(self): with mock.patch.object( @@ -655,11 +727,13 @@ def test_sets_prerelease_outputs(self): ] ) + build_field = compute_rocm_package_version.compute_msi_build_field() gha_set_output.assert_called_once_with( { "rocm_package_version": "7.99.0rc2", "rocm_deb_package_version": "7.99.0~pre2", "rocm_rpm_package_version": "7.99.0~rc2", + "rocm_msi_package_version": f"7.99.{build_field}.0", } ) @@ -676,11 +750,15 @@ def test_sets_custom_suffix_outputs(self): ] ) + # MSI versions must stay numeric #.#.#.#, so the msi branch ignores the + # custom suffix and always emits major.minor..0. + build_field = compute_rocm_package_version.compute_msi_build_field() gha_set_output.assert_called_once_with( { "rocm_package_version": "7.99.0.custom1", "rocm_deb_package_version": "7.99.0.custom1", "rocm_rpm_package_version": "7.99.0.custom1", + "rocm_msi_package_version": f"7.99.{build_field}.0", } ) diff --git a/docs/development/ci_behavior_manipulation.md b/docs/development/ci_behavior_manipulation.md index 68bf1a07a26..cffba89c6a3 100644 --- a/docs/development/ci_behavior_manipulation.md +++ b/docs/development/ci_behavior_manipulation.md @@ -37,6 +37,8 @@ The following labels may be added to a pull request to modify CI behavior: | `test_filter:...` | Override the test level (e.g. `test_filter:comprehensive`, `test_filter:quick`). Takes priority over all other test level logic. See [test_filtering.md](./test_filtering.md) for allowed values. | | `ci:build-native-linux` | Opt-in to building native Linux packages (deb/rpm). | | `ci:skip-native-linux` | Skip native Linux package builds. | +| `ci:build-native-windows` | Opt-in to building native Windows packages (MSI). | +| `ci:skip-native-windows` | Skip native Windows package builds. | | `ci:build-python-packages` | Opt-in to building Python packages. | | `ci:skip-python-packages` | Skip Python package builds. | | `ci:build-pytorch` | Opt-in to building PyTorch packages. | diff --git a/docs/packaging/versioning.md b/docs/packaging/versioning.md index ba7aba46fb1..58f6cb7c08a 100644 --- a/docs/packaging/versioning.md +++ b/docs/packaging/versioning.md @@ -414,4 +414,58 @@ The `python-debian` package can be used to work with debian package versions: ## Native Windows package versions -TODO: fill this in together with https://github.com/ROCm/TheRock/pull/2159 +TheRock ships Windows MSI installer packages. MSI versions are handled by the +same script as the Linux packages: + +- [`build_tools/compute_rocm_package_version.py`](/build_tools/compute_rocm_package_version.py) + (`package_type="msi"`) + - [`build_tools/tests/compute_rocm_package_version_test.py`](/build_tools/tests/compute_rocm_package_version_test.py) + +### ProductVersion + +Windows Installer requires a numeric four-field `ProductVersion` (`#.#.#.#`) and +compares only the first three fields when deciding upgrades; the fourth field is +ignored and the third (build) field is capped at 65535. A PEP440 wheel version +such as `10.2.0.dev0+` is therefore rejected (WiX error WIX0108). + +The MSI `ProductVersion` is instead: + +| Field | Value | Source | +| -------- | -------------------------------------------- | ------------------------------ | +| major | `X` | `rocm-version` in version.json | +| minor | `Y` | `rocm-version` in version.json | +| build | days since 2016-04-01 (ROCm 1.0, April 2016) | the build date | +| (fourth) | `0` | constant | + +Example: a build on 2026-10-16 of ROCm `10.2.x` produces ProductVersion +`10.2.3850.0`. + +All release types (ci/dev/nightly/release) share this one shape — there is no +`~dev`/`~rc`-style suffix, because the version must stay numeric. The build field +increases by one per day, so a later build always outranks an earlier one. A +`release` is simply the highest build in its line because it is built last; no +further nightlies are produced for a `major.minor` once it releases. + +The human-facing ROCm version (e.g. `10.2.0.dev0+` or `10.2.0`) is preserved +separately as the MSI `ARPDISPLAYVERSION` property (shown in Add/Remove Programs) +and in the shared `HKLM\Software\AMD\ROCm\{X.Y}` discovery key, so the dated +`ProductVersion` is an installer-ordering detail rather than the version users +see. + +### Coexistence of multiple major.minor versions + +Different ROCm `major.minor` versions (e.g. 10.2 and 10.3) install side by side, +like the CUDA Toolkit. Each MSI package derives its Windows Installer +`UpgradeCode` per `major.minor` +(`uuid5(, "major.minor")` in +[`generate_msi_wxs.py`](/build_tools/packaging/windows/generate_msi_wxs.py)), so: + +- 10.2 and 10.3 have different UpgradeCodes → distinct products that coexist, + each under its own `Program Files\AMD\ROCm\core-{X.Y}\` directory. +- Builds within one `major.minor` share an UpgradeCode → a newer build upgrades + the older one in place (`MajorUpgrade` with `AllowSameVersionUpgrades="yes"` so + two builds on the same day still replace rather than install side by side). + +Because coexistence is handled by the per-`major.minor` UpgradeCode, the +`ProductVersion` only ever has to order builds *within* one line, never across +lines. From b0f345034a9a8df8c550cbedb61cfe60b1c65e79 Mon Sep 17 00:00:00 2001 From: Dass Date: Tue, 20 Oct 2026 12:16:49 -0400 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20address=20review=20=E2=80=94=20pass?= =?UTF-8?q?=20MSI=20version=20as=20input,=20platform=20symmetry,=20cleanup?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses ScottTodd's review on #9370. Version passing (no inline logic in workflows): - Compute the WiX-legal MSI ProductVersion once in setup_multi_arch.yml (the script already emits rocm_msi_package_version) and thread it through multi_arch_ci.yml -> multi_arch_ci_windows.yml -> the build workflow as a new `msi_version` input, mirroring how deb/rpm versions are passed. Delete the inline-python "Compute MSI version" step from the build workflow; it now receives both the display version (rocm_version) and the numeric ProductVersion (msi_version) directly. Follows the github_actions style guide (no inline logic; compute upstream, pass through). configure_multi_arch_ci.py: - Gate build_native_linux with `and platform == "linux"` to match build_native_windows, and wrap the native-linux label overrides in `if platform == "linux":` so each platform consults only its own labels. Drop the now-redundant comment. compute_rocm_package_version.py: - Reorder the package-type branches to wheel -> deb/rpm -> msi (matching versioning.md / releases.md) and add an explicit `else: raise AssertionError` for unexpected types. Tests / wording: - MsiPackageVersionTest: call compute_version() directly (drop the _compute helper), use a stand-out base version (8.1.0), and patch compute_msi_build_field to a constant so expected versions are literals. Split the build-field-function tests into MsiBuildFieldTest. - Trim "MSI" -> "native windows" in CI test docstrings; drop the inaccurate "nightly" wording and an unnecessary comment in multi_arch_ci_windows.yml. Co-Authored-By: Claude Opus 4 (1M context) --- ...lti_arch_build_native_windows_packages.yml | 37 ++----- .github/workflows/multi_arch_ci.yml | 1 + .github/workflows/multi_arch_ci_windows.yml | 9 +- .github/workflows/setup_multi_arch.yml | 4 + build_tools/compute_rocm_package_version.py | 60 ++++++----- .../github_actions/configure_multi_arch_ci.py | 12 +-- .../tests/configure_multi_arch_ci_test.py | 4 +- .../compute_rocm_package_version_test.py | 102 +++++++++++------- 8 files changed, 122 insertions(+), 107 deletions(-) diff --git a/.github/workflows/multi_arch_build_native_windows_packages.yml b/.github/workflows/multi_arch_build_native_windows_packages.yml index 8da027e528a..e97cdda683c 100644 --- a/.github/workflows/multi_arch_build_native_windows_packages.yml +++ b/.github/workflows/multi_arch_build_native_windows_packages.yml @@ -25,7 +25,11 @@ on: type: string default: runtime rocm_version: - description: "ROCm version to stamp on the MSI (e.g. '7.15.0')." + description: "User-facing ROCm version shown in the installer (e.g. '7.15.0')." + type: string + required: true + msi_version: + description: "WiX-legal numeric MSI ProductVersion (e.g. '7.15.3800.0')." type: string required: true release_type: @@ -61,6 +65,9 @@ on: rocm_version: type: string required: true + msi_version: + type: string + required: true release_type: type: string default: ci @@ -95,6 +102,7 @@ jobs: ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} ARTIFACT_GITHUB_REPO: ${{ inputs.artifact_github_repo }} ROCM_VERSION: ${{ inputs.rocm_version }} + MSI_VERSION: ${{ inputs.msi_version }} PACKAGES: ${{ inputs.packages }} MSI_DIR: ${{ github.workspace }}/msi-output RELEASE_TYPE: ${{ inputs.release_type }} @@ -115,31 +123,6 @@ jobs: - name: Install Python requirements run: pip install -r requirements.txt - # The incoming rocm_version is the PEP440 wheel version (e.g. - # 10.2.0.dev0+), which WiX rejects (WIX0108: the MSI ProductVersion - # must be numeric #.#.#.#). Derive the numeric MSI ProductVersion - # (major.minor..0) for Package/@Version; the full - # rocm_version is kept as the display version (ARP + discovery key). Writes - # to GITHUB_ENV rather than capturing stdout, since the helper logs there. - - name: Compute MSI version - run: | - python - <<'PY' - import os, re - from compute_rocm_package_version import compute_msi_build_field - - rocm_version = os.environ["ROCM_VERSION"] - m = re.match(r"^(\d+)\.(\d+)\.", rocm_version) - if not m: - raise SystemExit(f"cannot parse major.minor from {rocm_version!r}") - major, minor = m.group(1), m.group(2) - msi_version = f"{major}.{minor}.{compute_msi_build_field()}.0" - with open(os.environ["GITHUB_ENV"], "a") as f: - f.write(f"MSI_PACKAGE_VERSION={msi_version}\n") - print(f"MSI ProductVersion: {msi_version} (display {rocm_version})") - PY - env: - PYTHONPATH: ${{ github.workspace }}/build_tools - # Pin to WiX v4: v5+ compiles the same schema but v7 gates every command # behind the OSMF EULA (error WIX7015), which would fail unattended CI. - name: Install WiX Toolset v4 @@ -166,7 +149,7 @@ jobs: --packages "${PACKAGES}" \ --run-id "${ARTIFACT_RUN_ID}" \ --run-github-repo "${ARTIFACT_GITHUB_REPO}" \ - --package-version "${MSI_PACKAGE_VERSION}" \ + --package-version "${MSI_VERSION}" \ --display-version "${ROCM_VERSION}" \ --output-dir "${MSI_DIR}" diff --git a/.github/workflows/multi_arch_ci.yml b/.github/workflows/multi_arch_ci.yml index 663d2f18014..795dbe06679 100644 --- a/.github/workflows/multi_arch_ci.yml +++ b/.github/workflows/multi_arch_ci.yml @@ -214,6 +214,7 @@ jobs: build_config: ${{ needs.setup.outputs.windows_build_config }} test_labels: ${{ needs.setup.outputs.windows_test_labels }} rocm_package_version: ${{ needs.setup.outputs.rocm_package_version }} + rocm_msi_package_version: ${{ needs.setup.outputs.rocm_msi_package_version }} test_type: ${{ needs.setup.outputs.test_type }} changed_projects: ${{ inputs.changed_projects || '' }} permissions: diff --git a/.github/workflows/multi_arch_ci_windows.yml b/.github/workflows/multi_arch_ci_windows.yml index 0c905157c7f..89adcc6e66d 100644 --- a/.github/workflows/multi_arch_ci_windows.yml +++ b/.github/workflows/multi_arch_ci_windows.yml @@ -20,6 +20,8 @@ on: type: string rocm_package_version: type: string + rocm_msi_package_version: + type: string test_type: type: string release_type: @@ -184,10 +186,6 @@ jobs: contents: read id-token: write - # Build the native Windows MSI installer(s) from this run's artifacts. In CI - # the MSI is produced and uploaded to the run's artifacts bucket (a build - # artifact) to exercise the packaging path; it is not published to a release - # bucket here (that happens only in the release workflow). build_native_msi_packages: needs: [build_multi_arch_stages] name: Build Native MSI Packages @@ -196,7 +194,8 @@ jobs: with: artifact_run_id: ${{ github.run_id }} rocm_version: ${{ inputs.rocm_package_version }} - # Only the runtime package is published in nightly artifacts today; core's + msi_version: ${{ inputs.rocm_msi_package_version }} + # Only the runtime package is published in CI artifacts today; core's # Windows artifacts (core-amdsmi, core-runtime) are not yet available. packages: runtime release_type: ${{ inputs.release_type }} diff --git a/.github/workflows/setup_multi_arch.yml b/.github/workflows/setup_multi_arch.yml index 4d73ab36c75..75b4de0fd3b 100644 --- a/.github/workflows/setup_multi_arch.yml +++ b/.github/workflows/setup_multi_arch.yml @@ -154,6 +154,9 @@ on: rocm_rpm_package_version: description: ROCm RPM package version for native packages. value: ${{ jobs.setup.outputs.rocm_rpm_package_version }} + rocm_msi_package_version: + description: ROCm MSI package version for native Windows packages. + value: ${{ jobs.setup.outputs.rocm_msi_package_version }} ref: description: >- Resolved commit SHA from checkout. Use this instead of the input ref @@ -181,6 +184,7 @@ jobs: rocm_package_version: ${{ steps.rocm_package_version.outputs.rocm_package_version }} rocm_deb_package_version: ${{ steps.rocm_package_version.outputs.rocm_deb_package_version }} rocm_rpm_package_version: ${{ steps.rocm_package_version.outputs.rocm_rpm_package_version }} + rocm_msi_package_version: ${{ steps.rocm_package_version.outputs.rocm_msi_package_version }} ref: ${{ steps.checkout.outputs.commit }} external_repo_config: ${{ steps.external_repo_config.outputs.config_json }} env: diff --git a/build_tools/compute_rocm_package_version.py b/build_tools/compute_rocm_package_version.py index 463a6fe10e4..833f8aaafe9 100644 --- a/build_tools/compute_rocm_package_version.py +++ b/build_tools/compute_rocm_package_version.py @@ -216,32 +216,6 @@ def compute_version( f"for release type '{release_type}'" ) - # Handle Windows MSI packages (WiX ProductVersion: numeric major.minor.build.0). - # - # Unlike the other package types, the MSI version does NOT carry a dev/nightly - # suffix: Windows Installer versions must be numeric #.#.#.# and it compares - # only the first three fields. We keep major.minor from version.json visible - # and put the build DATE (days since the ROCm 1.0 epoch) in the third field, - # so every build outranks the previous one within its major.minor line. The - # marketing patch (e.g. 10.2.0) is surfaced separately via the MSI's - # ARPDISPLAYVERSION property, not here. Cross-line coexistence (10.2 vs 10.3) - # is handled by a per-major.minor UpgradeCode in the generator, so this field - # never has to order across lines. All release types share one shape; a - # release simply has the highest build field in its line because it is built - # last (no further nightlies follow a major.minor's release). - if package_type == "msi": - parts = base_version.split(".") - if len(parts) < 2 or not parts[0] or not parts[1]: - raise ValueError( - f"base version must be at least major.minor for msi (got " - f"{base_version!r})" - ) - major, minor = parts[0], parts[1] - build_field = compute_msi_build_field() - rocm_package_version = f"{major}.{minor}.{build_field}.0" - _log(f"Full version : '{rocm_package_version}'") - return rocm_package_version - # Handle wheel packages (Python packaging standards) if package_type == "wheel": if custom_version_suffix: @@ -276,8 +250,8 @@ def compute_version( return rocm_package_version - # Handle native packages (deb/rpm) - else: # package_type in ["deb", "rpm"] + # Handle native Linux packages (deb/rpm) + elif package_type in ("deb", "rpm"): if custom_version_suffix: # Custom suffix uses the provided value version_suffix_str = f"{custom_version_suffix}" @@ -319,6 +293,36 @@ def compute_version( return rocm_package_version + # Handle native Windows packages (MSI; WiX ProductVersion: numeric + # major.minor.build.0). + # + # Unlike the other package types, the MSI version does NOT carry a dev/nightly + # suffix: Windows Installer versions must be numeric #.#.#.# and it compares + # only the first three fields. We keep major.minor from version.json visible + # and put the build DATE (days since the ROCm 1.0 epoch) in the third field, + # so every build outranks the previous one within its major.minor line. The + # marketing patch (e.g. 10.2.0) is surfaced separately via the MSI's + # ARPDISPLAYVERSION property, not here. Cross-line coexistence (10.2 vs 10.3) + # is handled by a per-major.minor UpgradeCode in the generator, so this field + # never has to order across lines. All release types share one shape; a + # release simply has the highest build field in its line because it is built + # last (no further nightlies follow a major.minor's release). + elif package_type == "msi": + parts = base_version.split(".") + if len(parts) < 2 or not parts[0] or not parts[1]: + raise ValueError( + f"base version must be at least major.minor for msi (got " + f"{base_version!r})" + ) + major, minor = parts[0], parts[1] + build_field = compute_msi_build_field() + rocm_package_version = f"{major}.{minor}.{build_field}.0" + _log(f"Full version : '{rocm_package_version}'") + return rocm_package_version + + else: + raise AssertionError(f"Unexpected package type: {package_type}") + def main(argv): parser = argparse.ArgumentParser(prog="compute_rocm_package_version") diff --git a/build_tools/github_actions/configure_multi_arch_ci.py b/build_tools/github_actions/configure_multi_arch_ci.py index 793cd411204..f08b6597e5f 100755 --- a/build_tools/github_actions/configure_multi_arch_ci.py +++ b/build_tools/github_actions/configure_multi_arch_ci.py @@ -1757,16 +1757,16 @@ def _expand_build_config_for_platform( if build_python_packages else [] ) - build_native_linux = ci_inputs.build_native_linux - # Native Windows (MSI) packages are built only on the windows platform. + build_native_linux = ci_inputs.build_native_linux and platform == "linux" build_native_windows = ci_inputs.build_native_windows and platform == "windows" # PR labels can override packaging build decisions. pr_labels = ci_inputs.pr_labels - if "ci:build-native-linux" in pr_labels: - build_native_linux = True - elif "ci:skip-native-linux" in pr_labels: - build_native_linux = False + if platform == "linux": + if "ci:build-native-linux" in pr_labels: + build_native_linux = True + elif "ci:skip-native-linux" in pr_labels: + build_native_linux = False if platform == "windows": if "ci:build-native-windows" in pr_labels: diff --git a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py index 5518c299bba..5727d818e6f 100644 --- a/build_tools/github_actions/tests/configure_multi_arch_ci_test.py +++ b/build_tools/github_actions/tests/configure_multi_arch_ci_test.py @@ -1892,7 +1892,7 @@ def test_build_native_linux_input_disables_native_packages(self): self.assertFalse(result.linux.build_native_linux) def test_build_native_windows_input_disables_native_packages(self): - """build_native_windows=False disables the Windows MSI build.""" + """build_native_windows=False disables the native Windows build.""" targets = cm.TargetSelection(windows_families=["gfx110x"]) enabled = cm.expand_build_configs( ci_inputs=self._inputs(build_native_windows=True), @@ -2235,7 +2235,7 @@ def test_packaging_label_overrides(self): self.assertEqual(getattr(result.linux, config_field), expected) def test_native_windows_label_overrides(self): - """PR labels can enable/disable the native-windows (MSI) build.""" + """PR labels can enable/disable the native Windows build.""" targets = cm.TargetSelection(windows_families=["gfx110x"]) cases = [ # (label, input_default, expected) diff --git a/build_tools/tests/compute_rocm_package_version_test.py b/build_tools/tests/compute_rocm_package_version_test.py index 31b7ff58d6b..c41a6ca5cc3 100644 --- a/build_tools/tests/compute_rocm_package_version_test.py +++ b/build_tools/tests/compute_rocm_package_version_test.py @@ -603,61 +603,85 @@ def test_custom_version_suffix(self): class MsiPackageVersionTest(unittest.TestCase): RELEASE_TYPES = ("ci", "dev", "nightly", "release") - def _compute(self, release_type, base="10.2.0"): - return compute_rocm_package_version.compute_version( - package_type="msi", - release_type=release_type, - override_base_version=base, + # Pin the build field (days since the MSI epoch) so each expected version is a + # literal, not a value recomputed in the test. 3800 is an arbitrary in-range + # stand-in; the real value depends on the current date. + BUILD_FIELD = 3800 + + def setUp(self): + patcher = mock.patch.object( + compute_rocm_package_version, + "compute_msi_build_field", + return_value=self.BUILD_FIELD, ) + patcher.start() + self.addCleanup(patcher.stop) def test_is_four_field_numeric(self): # WiX requires a numeric #.#.#.# ProductVersion (WIX0108 otherwise). for release_type in self.RELEASE_TYPES: with self.subTest(release_type=release_type): - version = self._compute(release_type) - fields = version.split(".") - self.assertEqual(len(fields), 4) - for f in fields: - self.assertTrue(f.isdigit(), msg=f"non-numeric field in {version}") - - def test_keeps_major_minor_and_zero_fourth_field(self): - for release_type in self.RELEASE_TYPES: - with self.subTest(release_type=release_type): - major, minor, _build, fourth = self._compute(release_type).split(".") - self.assertEqual((major, minor), ("10", "2")) - self.assertEqual(fourth, "0") - - def test_build_field_within_installer_limit(self): - # The third field must fit Windows Installer's 65535 cap. - for release_type in self.RELEASE_TYPES: - with self.subTest(release_type=release_type): - build = int(self._compute(release_type).split(".")[2]) - self.assertLessEqual(build, 65535) + version = compute_rocm_package_version.compute_version( + package_type="msi", + release_type=release_type, + override_base_version="8.1.0", + ) + self.assertEqual(version, "8.1.3800.0") def test_all_release_types_share_one_shape(self): # No dev/nightly suffix: every release type yields the same version on a # given day (release "wins" only because it is built last, not via a # different formula). - versions = {self._compute(rt) for rt in self.RELEASE_TYPES} - self.assertEqual(len(versions), 1) + versions = { + compute_rocm_package_version.compute_version( + package_type="msi", + release_type=release_type, + override_base_version="8.1.0", + ) + for release_type in self.RELEASE_TYPES + } + self.assertEqual(versions, {"8.1.3800.0"}) + + def test_rejects_base_without_minor(self): + with self.assertRaisesRegex(ValueError, "major.minor"): + compute_rocm_package_version.compute_version( + package_type="msi", + release_type="release", + override_base_version="8", + ) + - def test_build_field_is_monotonic_by_date(self): - # A later build date must yield a strictly greater third field so a newer - # build upgrades an older one within its major.minor line. +class MsiBuildFieldTest(unittest.TestCase): + """Tests for compute_msi_build_field() itself (no version-string assembly).""" + + def test_is_days_since_epoch(self): + # The build field is the number of days since 2016-04-01. import datetime - earlier = compute_rocm_package_version.compute_msi_build_field( - datetime.date(2026, 10, 14) - ) - later = compute_rocm_package_version.compute_msi_build_field( - datetime.date(2026, 10, 16) + self.assertEqual( + compute_rocm_package_version.compute_msi_build_field( + datetime.date(2016, 4, 11) + ), + 10, ) - self.assertEqual(later - earlier, 2) - self.assertGreater(later, earlier) - def test_rejects_base_without_minor(self): - with self.assertRaisesRegex(ValueError, "major.minor"): - self._compute("release", base="10") + def test_is_monotonic_by_date(self): + # A later build date must yield a strictly greater field so a newer build + # upgrades an older one within its major.minor line. + import datetime + + self.assertEqual( + compute_rocm_package_version.compute_msi_build_field( + datetime.date(2026, 10, 14) + ), + 3848, + ) + self.assertEqual( + compute_rocm_package_version.compute_msi_build_field( + datetime.date(2026, 10, 16) + ), + 3850, + ) def test_rejects_date_past_installer_limit(self): import datetime From cccd032dedd304cea58b67ac1ea4ad4c66d8513b Mon Sep 17 00:00:00 2001 From: Dass Date: Mon, 26 Oct 2026 10:52:40 -0400 Subject: [PATCH 3/3] feat(packaging): improve MSI generator logging Add package-level and file-level logging to generate_msi_wxs.py so the CI step shows which packages are being built, the MSI and display versions, the install path, each payload file and where it came from, and which legacy System32 DLLs are included. Before: Fetching artifacts for run 39458231723 (windows) ... Written: .../amdrocm-runtime.wxs Files: 7 Install: [ProgramFiles64Folder]\AMD\ROCm\core-10.2\ After: Building packages: runtime Fetching artifacts for run 39458231723 (windows) ... Package: AMD ROCm Runtime Version: 10.2.3850.0 (display: 10.2.0) Install: [ProgramFiles64Folder]\AMD\ROCm\core-10.2\ Files: 7 bin\amdhip64_7.dll <- amdhip64_7.dll ... Legacy System32 DLLs (5): amdhip64_6.dll <- ... ... Written: .../amdrocm-runtime.wxs Co-Authored-By: Claude --- build_tools/packaging/windows/generate_msi_wxs.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/build_tools/packaging/windows/generate_msi_wxs.py b/build_tools/packaging/windows/generate_msi_wxs.py index 25253abf61f..1c5ee82a258 100644 --- a/build_tools/packaging/windows/generate_msi_wxs.py +++ b/build_tools/packaging/windows/generate_msi_wxs.py @@ -1325,9 +1325,17 @@ def build_wxs(args: argparse.Namespace, artifact_dir: Path | None = None) -> Non write_wxs(doc.root, args.output) - print(f"Written: {args.output}") - print(f"Files: {len(inputs.files)}") + print(f"Package: {inputs.package.product_name}") + print(f"Version: {inputs.version} (display: {inputs.display_version})") print(f"Install: {layout.display_path}") + print(f"Files: {len(inputs.files)}") + for install_rel, source in inputs.files: + print(f" {install_rel} <- {source.name}") + if inputs.legacy_dlls: + print(f"Legacy System32 DLLs ({len(inputs.legacy_dlls)}):") + for dll_name, source in inputs.legacy_dlls: + print(f" {dll_name} <- {source}") + print(f"Written: {args.output}") def build_all(args: argparse.Namespace) -> None: @@ -1337,6 +1345,7 @@ def build_all(args: argparse.Namespace) -> None: package-specific — then loops over args.package_list, writing each package's .wxs to its resolved output path. """ + print(f"Building packages: {', '.join(args.package_list)}") artifact_dir = resolve_artifact_dir(args) explicit_output = args.output # Set only for single-package --output. for pkg_name in args.package_list: