diff --git a/.github/workflows/multi_arch_build_native_windows_packages.yml b/.github/workflows/multi_arch_build_native_windows_packages.yml index 601e1e4d602..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: @@ -40,6 +44,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: @@ -54,6 +65,9 @@ on: rocm_version: type: string required: true + msi_version: + type: string + required: true release_type: type: string default: ci @@ -63,6 +77,9 @@ on: ref: type: string default: "" + quartz_tracking_id: + type: string + default: "" permissions: contents: read @@ -84,7 +101,8 @@ 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 }} + MSI_VERSION: ${{ inputs.msi_version }} PACKAGES: ${{ inputs.packages }} MSI_DIR: ${{ github.workspace }}/msi-output RELEASE_TYPE: ${{ inputs.release_type }} @@ -131,7 +149,8 @@ jobs: --packages "${PACKAGES}" \ --run-id "${ARTIFACT_RUN_ID}" \ --run-github-repo "${ARTIFACT_GITHUB_REPO}" \ - --package-version "${PACKAGE_VERSION}" \ + --package-version "${MSI_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..795dbe06679 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: @@ -206,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 681b050716a..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,6 +186,25 @@ jobs: contents: read id-token: write + 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 }} + 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 }} + 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..75b4de0fd3b 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 @@ -150,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 @@ -177,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: @@ -246,6 +254,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..833f8aaafe9 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 @@ -217,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}" @@ -260,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") @@ -315,9 +378,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 +394,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..f08b6597e5f 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 @@ -1751,14 +1757,22 @@ def _expand_build_config_for_platform( if build_python_packages else [] ) - build_native_linux = ci_inputs.build_native_linux + 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: + 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 @@ -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..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 @@ -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 native Windows 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 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..1c5ee82a258 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) @@ -1264,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: @@ -1276,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: 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..c41a6ca5cc3 100644 --- a/build_tools/tests/compute_rocm_package_version_test.py +++ b/build_tools/tests/compute_rocm_package_version_test.py @@ -600,6 +600,98 @@ def test_custom_version_suffix(self): self.assertEqual(version, "8.0.0~custom1") +class MsiPackageVersionTest(unittest.TestCase): + RELEASE_TYPES = ("ci", "dev", "nightly", "release") + + # 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 = 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 = { + 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", + ) + + +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 + + self.assertEqual( + compute_rocm_package_version.compute_msi_build_field( + datetime.date(2016, 4, 11) + ), + 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 + + 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 +722,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 +732,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 +751,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 +774,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.