build(release): cross-compile the release tools in presubmit (#4262)

Presubmit built the release tools only for each CI host's own platform,
so 8 of the 11 platforms in dev/platforms were built only when the
release workflow ran.

A new target, //dev/release_artifacts:cross_compiled_artifacts, builds
the tools for the platforms that the host can cross-compile for, with a
split transition on --platforms. BazelCI's default builds of //... on
Linux x86_64, macOS arm64, and Windows x86_64 thus cover all 11
platforms without new jobs. BazelCI has no Windows Arm machines, so
Windows x86_64 hosts also get a Rust toolchain for Arm Windows.

On all three hosts, developers' machines included, builds of //... now
also cross-compile, e.g. the 5 musl variants on Linux x86_64. The
release workflow still builds aarch64-pc-windows-msvc and
x86_64-apple-darwin natively.

Work towards #4216
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index dc13b38..219a1e5 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -35,16 +35,24 @@
     # As a regression test for #225, check that wheel targets still build when
     # their package path is qualified with the repo name.
     - "@rules_python//examples/wheel/..."
+  # Tasks can merge these into their own build_flags or test_flags with `<<:`.
+  # To change a merged flag, such as a tag filter, list it again: Bazel uses the
+  # last value.
   build_flags: &reusable_config_build_flags
     ? "--experimental_repository_cache_hardlinks=false"
     ? "--keep_going"
     ? "--build_tag_filters=-integration-test"
     ? "--verbose_failures"
+    # Among other things, makes missing Visual Studio ARM64 build tools an
+    # error on Windows; see .bazelrc. test_flags repeat it so that `bazel test`
+    # reuses the build's analysis cache.
+    ? "--config=ci"
   test_targets:
     - "--"
     - "..."
-  test_flags:
+  test_flags: &reusable_config_test_flags
     ? "--test_tag_filters=-integration-test"
+    ? "--config=ci"
 .common_workspace_flags_min_bazel: &common_workspace_flags_min_bazel
   build_flags:
     ? "--experimental_repository_cache_hardlinks=false"
@@ -245,8 +253,8 @@
       - "--"
       - "//tests/..."
     test_flags:
+      <<: *reusable_config_test_flags
       ? "--keep_going"
-      ? "--test_tag_filters=-integration-test"
   ubuntu_workspace:
     <<: *reusable_config
     <<: *common_workspace_flags
@@ -299,6 +307,7 @@
     platform: windows
     bazel: ${{ bazel }}
     test_flags:
+      <<: *reusable_config_test_flags
       ? "--test_tag_filters=-integration-test,-fix-windows"
   rbe_min:
     <<: *minimum_supported_version
@@ -306,7 +315,7 @@
     name: "RBE: Ubuntu, minimum Bazel"
     platform: rbe_ubuntu2404
     build_flags:
-      ? "--experimental_repository_cache_hardlinks=false"
+      <<: *reusable_config_build_flags
       # BazelCI sets --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1,
       # which prevents cc toolchain autodetection from working correctly
       # on Bazel 5.4 and earlier. To workaround this, manually specify the
@@ -314,6 +323,7 @@
       ? "--extra_toolchains=@buildkite_config//config:cc-toolchain"
       ? "--build_tag_filters=-docs"
     test_flags:
+      <<: *reusable_config_test_flags
       ? "--test_tag_filters=-integration-test,-acceptance-test,-docs"
       # BazelCI sets --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1,
       # which prevents cc toolchain autodetection from working correctly
@@ -328,6 +338,7 @@
     # See https://github.com/bazelbuild/rules_python/issues/2499
     bazel: 8.x
     test_flags:
+      <<: *reusable_config_test_flags
       ? "--test_tag_filters=-integration-test,-acceptance-test"
       ? "--extra_toolchains=@buildkite_config//config:cc-toolchain"
 
diff --git a/.bazelrc b/.bazelrc
index 12d1b33..24aba93 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -48,13 +48,19 @@
 common --repository_cache=~/.cache/bazel/bazel-repo-cache
 # Hardlinks save disk space and copy time when unpacking repositories
 common --experimental_repository_cache_hardlinks=true
-# To work around bug on bazel 7
-common:ci --experimental_repository_cache_hardlinks=false
 # Drop `experimental_` prefix once Bazel 7 is no longer supported
 common --experimental_downloader_config=downloader_config.cfg
 common --http_timeout_scaling=10.0
 common --experimental_repository_downloader_retries=10
 
+# BazelCI's default and RBE bzlmod jobs pass --config=ci; see
+# .bazelci/presubmit.yml.
+# To work around bug on bazel 7
+common:ci --experimental_repository_cache_hardlinks=false
+# Make missing Visual Studio ARM64 build tools an error on Windows x86_64; see
+# dev/release_artifacts/BUILD.bazel.
+common:ci --//dev/release_artifacts:require_msvc_arm64=true
+
 # Additional config to use for readthedocs builds.
 # See .readthedocs.yml for additional flags that can only be determined from
 # the runtime environment.
diff --git a/MODULE.bazel b/MODULE.bazel
index 532c0c1..989aa89 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -230,10 +230,11 @@
     dev_dependency = True,
 )
 
-# A Rust toolchain to cross-compile the release artifacts for 32-bit Windows
-# from Windows x86_64, with MSVC's x86 tools. Unlike the Linux ones, it isn't
-# registered here: no default toolchain targets 32-bit Windows, and rules_rust
-# registers it.
+# Rust toolchains to cross-compile the release artifacts for 32-bit and Arm
+# Windows from Windows x86_64, with MSVC's cross tools; the Arm one needs
+# Visual Studio's optional ARM64 build tools. Unlike the Linux ones, they
+# aren't registered here: no default toolchain targets these platforms from
+# Windows x86_64, and rules_rust registers them.
 rust.repository_set(
     # As above, its x86_64 toolchain's name must sort after the default one's.
     # On Windows, rules_rust names toolchains after a hash of the full name:
@@ -250,6 +251,16 @@
     versions = ["1.97.1"],
 )
 
+# Another target for the same set; the first tag sets the shared attributes.
+rust.repository_set(
+    name = "rust_release_windows",
+    target_compatible_with = [
+        "@platforms//cpu:aarch64",
+        "@platforms//os:windows",
+    ],
+    target_triple = "aarch64-pc-windows-msvc",
+)
+
 # Use the Rust-based build tools when developing rules_python itself.
 register_toolchains(
     "//dev/dev_only_toolchains:exe_zip_maker_toolchain",
diff --git a/RELEASING.md b/RELEASING.md
index cbd9bc1..2bf5e59 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -73,6 +73,9 @@
 for each platform to verify it builds. For now, the files aren't attached to
 the release, so a failed "Build tools" job doesn't block it.
 
+Presubmit builds it for every platform too: natively on each CI host, and
+through `//dev/release_artifacts:cross_compiled_artifacts` for the others.
+
 ### Manually publishing to PyPI
 
 If PyPI publishing failed or was skipped during the main release, the PyPI
diff --git a/dev/platforms/BUILD.bazel b/dev/platforms/BUILD.bazel
index 90657fb..17f19cc 100644
--- a/dev/platforms/BUILD.bazel
+++ b/dev/platforms/BUILD.bazel
@@ -5,9 +5,13 @@
 # aarch64 macOS file. s390x isn't covered yet: Rust ships no musl std for it,
 # and a static glibc build needs a C cross toolchain.
 #
-# Linux x86_64 hosts cross-compile for the musl ones, and Windows x86_64 hosts
-# for i686-pc-windows-msvc, with the Rust toolchains in MODULE.bazel. The
-# others are built on hosts that match them.
+# Linux x86_64 hosts can cross-compile for the musl ones, Windows x86_64 hosts
+# for the other Windows ones (Arm needs Visual Studio's ARM64 build tools), and
+# macOS arm64 hosts for x86_64-apple-darwin, with the Rust toolchains in
+# MODULE.bazel and rules_rust's defaults.
+# //dev/release_artifacts:cross_compiled_artifacts cross-compiles them all; the
+# release workflow builds aarch64-pc-windows-msvc and x86_64-apple-darwin on
+# hosts that match them instead.
 
 package(default_visibility = ["//:__subpackages__"])
 
diff --git a/dev/release_artifacts/BUILD.bazel b/dev/release_artifacts/BUILD.bazel
index 4c7f904..d81b529 100644
--- a/dev/release_artifacts/BUILD.bazel
+++ b/dev/release_artifacts/BUILD.bazel
@@ -1,6 +1,11 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
 load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
-load(":release_files.bzl", "release_files")
+load(
+    ":cc_toolchain_available_config_setting.bzl",
+    "cc_toolchain_available_config_setting",
+)
+load(":release_files.bzl", "cross_compiled_files", "release_files")
 
 package(default_visibility = ["//:__subpackages__"])
 
@@ -14,8 +19,99 @@
     tags = [] if BZLMOD_ENABLED else ["manual"],
 )
 
+# artifacts_for_release for the other platforms in dev/platforms that the host
+# can cross-compile for, so that builds of //... on each host cover them. The
+# select()s match the target platform, which is the host's unless --platforms
+# is set. Arm Windows needs its own select(): Windows x86_64 hosts can match
+# both is_windows_x86_64 and is_windows_x86_64_with_msvc_arm64, and in a single
+# select() that ambiguous match would be an error.
+#
+# To reproduce a failure, build artifacts_for_release with
+# --platforms=//dev/platforms:<triple>; see build.sh. If a platform's build
+# breaks presubmit without a related change, tag this target "manual" until
+# it's fixed.
+cross_compiled_files(
+    name = "cross_compiled_artifacts",
+    src = ":artifacts_for_release",
+    platforms = select({
+        ":is_linux_x86_64": [
+            "//dev/platforms:aarch64-unknown-linux-musl",
+            "//dev/platforms:armv7-unknown-linux-musleabihf",
+            "//dev/platforms:i686-unknown-linux-musl",
+            "//dev/platforms:powerpc64le-unknown-linux-musl",
+            "//dev/platforms:riscv64gc-unknown-linux-musl",
+        ],
+        ":is_macos_aarch64": ["//dev/platforms:x86_64-apple-darwin"],
+        ":is_windows_x86_64": ["//dev/platforms:i686-pc-windows-msvc"],
+        "//conditions:default": [],
+    }) + select({
+        ":is_windows_x86_64_with_msvc_arm64": [
+            "//dev/platforms:aarch64-pc-windows-msvc",
+        ],
+        "//conditions:default": [],
+    }),
+    # See artifacts_for_release.
+    tags = [] if BZLMOD_ENABLED else ["manual"],
+)
+
+# Matches on Windows x86_64 if Visual Studio's optional ARM64 build tools
+# (Microsoft.VisualStudio.Component.VC.Tools.ARM64) are installed, so that the
+# C++ toolchain for Windows arm64 works. Without them, aarch64-pc-windows-msvc
+# is skipped, or the build fails if require_msvc_arm64 is set. After installing
+# them, run `bazel fetch --configure --force` so that rules_cc detects them.
+cc_toolchain_available_config_setting(
+    name = "is_windows_x86_64_with_msvc_arm64",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:windows",
+    ],
+    platform = "//dev/platforms:aarch64-pc-windows-msvc",
+    required_flag = ":require_msvc_arm64",
+)
+
+# BazelCI sets this through --config=ci (see .bazelrc). Its Windows images have
+# the ARM64 build tools, so losing them fails the build instead of going
+# unnoticed. It has no effect unless the target platform is Windows x86_64.
+bool_flag(
+    name = "require_msvc_arm64",
+    build_setting_default = False,
+)
+
+config_setting(
+    name = "is_linux_x86_64",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+)
+
+config_setting(
+    name = "is_macos_aarch64",
+    constraint_values = [
+        "@platforms//cpu:aarch64",
+        "@platforms//os:macos",
+    ],
+)
+
+config_setting(
+    name = "is_windows_x86_64",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:windows",
+    ],
+)
+
 bzl_library(
     name = "release_files",
     srcs = ["release_files.bzl"],
     deps = ["//python/private:common_labels"],
 )
+
+bzl_library(
+    name = "cc_toolchain_available_config_setting",
+    srcs = ["cc_toolchain_available_config_setting.bzl"],
+    deps = [
+        "@bazel_skylib//rules:common_settings",
+        "@rules_cc//cc:find_cc_toolchain_bzl",
+    ],
+)
diff --git a/dev/release_artifacts/cc_toolchain_available_config_setting.bzl b/dev/release_artifacts/cc_toolchain_available_config_setting.bzl
new file mode 100644
index 0000000..44122da
--- /dev/null
+++ b/dev/release_artifacts/cc_toolchain_available_config_setting.bzl
@@ -0,0 +1,122 @@
+"""Config setting for whether a C++ toolchain can build for a platform."""
+
+load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
+load(
+    "@rules_cc//cc:find_cc_toolchain.bzl",
+    "find_cc_toolchain",
+    "use_cc_toolchain",
+)
+
+_ProbeInfo = provider(
+    doc = "The result of looking for a C++ toolchain.",
+    fields = {"available": "bool: whether a usable C++ toolchain was found."},
+)
+
+def _probe_impl(ctx):
+    cc_toolchain = find_cc_toolchain(ctx, mandatory = False)
+
+    # rules_cc registers its Windows toolchains even if their tools are
+    # missing, and then runs an error script instead of them, e.g.
+    # vc_installation_error_arm64.bat or clang_installation_error.bat.
+    available = cc_toolchain != None and (
+        "installation_error" not in cc_toolchain.compiler_executable
+    )
+    return [_ProbeInfo(available = available)]
+
+_probe = rule(
+    implementation = _probe_impl,
+    toolchains = use_cc_toolchain(mandatory = False),
+)
+
+def _platform_transition_impl(_settings, attr):
+    return {"//command_line_option:platforms": str(attr.platform)}
+
+_platform_transition = transition(
+    implementation = _platform_transition_impl,
+    inputs = [],
+    outputs = ["//command_line_option:platforms"],
+)
+
+def _cc_toolchain_available_impl(ctx):
+    for constraint in ctx.attr.constraint_values:
+        if not ctx.target_platform_has_constraint(
+            constraint[platform_common.ConstraintValueInfo],
+        ):
+            return [config_common.FeatureFlagInfo(value = "no")]
+
+    # With a transition, ctx.attr.probe is a list.
+    available = ctx.attr.probe[0][_ProbeInfo].available
+    required = (
+        ctx.attr.required_flag != None and
+        ctx.attr.required_flag[BuildSettingInfo].value
+    )
+    if required and not available:
+        fail((
+            "{} is set, but no usable C++ toolchain can build for {}. " +
+            "Install one, or unset the flag."
+        ).format(ctx.attr.required_flag.label, ctx.attr.platform.label))
+    return [config_common.FeatureFlagInfo(value = "yes" if available else "no")]
+
+_cc_toolchain_available = rule(
+    implementation = _cc_toolchain_available_impl,
+    attrs = {
+        "constraint_values": attr.label_list(
+            providers = [platform_common.ConstraintValueInfo],
+        ),
+        "platform": attr.label(
+            mandatory = True,
+            providers = [platform_common.PlatformInfo],
+        ),
+        "probe": attr.label(
+            cfg = _platform_transition,
+            mandatory = True,
+            providers = [_ProbeInfo],
+        ),
+        "required_flag": attr.label(providers = [BuildSettingInfo]),
+    },
+)
+
+def cc_toolchain_available_config_setting(
+        name,
+        platform,
+        constraint_values = None,
+        required_flag = None,
+        visibility = None):
+    """Matches if a usable C++ toolchain can build for `platform`.
+
+    Toolchain resolution for `platform` finds the toolchain, so the result
+    reflects the registered toolchains and execution platforms. A toolchain
+    that runs one of rules_cc's error scripts instead of a compiler isn't
+    usable.
+
+    Args:
+        name: The name of the `config_setting`.
+        platform: The platform to look for a C++ toolchain for.
+        constraint_values: Constraint values that the target platform must
+            also have, as in `config_setting`.
+        required_flag: The label of a `bool_flag`. When it's true and the
+            target platform has `constraint_values`, a missing or unusable
+            toolchain fails the build instead of not matching.
+        visibility: The visibility of the `config_setting`.
+    """
+    constraint_values = constraint_values or []
+
+    _probe(
+        name = name + "_probe",
+        tags = ["manual"],
+        visibility = ["//visibility:private"],
+    )
+    _cc_toolchain_available(
+        name = name + "_flag",
+        constraint_values = constraint_values,
+        platform = platform,
+        probe = ":" + name + "_probe",
+        required_flag = required_flag,
+        tags = ["manual"],
+        visibility = ["//visibility:private"],
+    )
+    native.config_setting(
+        name = name,
+        flag_values = {":" + name + "_flag": "yes"},
+        visibility = visibility,
+    )
diff --git a/dev/release_artifacts/release_files.bzl b/dev/release_artifacts/release_files.bzl
index b5bde5f..ae6563c 100644
--- a/dev/release_artifacts/release_files.bzl
+++ b/dev/release_artifacts/release_files.bzl
@@ -1,4 +1,4 @@
-"""Macro to build the files for a release and list where they are."""
+"""Macro and rule to build the files for a release and list where they are."""
 
 load("//python/private:common_labels.bzl", "labels")  # buildifier: disable=bzl-visibility
 
@@ -102,3 +102,47 @@
         }),
         **kwargs
     )
+
+def _platforms_transition_impl(_settings, attr):
+    # A split transition: one configuration per platform, keyed by its label.
+    return {
+        str(platform): {"//command_line_option:platforms": str(platform)}
+        for platform in attr.platforms
+    }
+
+_platforms_transition = transition(
+    implementation = _platforms_transition_impl,
+    inputs = [],
+    outputs = ["//command_line_option:platforms"],
+)
+
+def _cross_compiled_files_impl(ctx):
+    # Without platforms, the transition leaves src in this target's
+    # configuration, so skip it.
+    if not ctx.attr.platforms:
+        return [DefaultInfo()]
+
+    # With a transition, ctx.attr.src is a list: one target per platform.
+    files = [src[DefaultInfo].files for src in ctx.attr.src]
+    return [DefaultInfo(files = depset(transitive = files))]
+
+cross_compiled_files = rule(
+    implementation = _cross_compiled_files_impl,
+    doc = """
+Builds `src` for each platform in `platforms`, in one build.
+
+`--platforms` selects only one target platform per build. With no platforms,
+this builds nothing.
+""",
+    attrs = {
+        "platforms": attr.label_list(
+            doc = "The platforms to build `src` for.",
+            providers = [platform_common.PlatformInfo],
+        ),
+        "src": attr.label(
+            cfg = _platforms_transition,
+            doc = "The target to build.",
+            mandatory = True,
+        ),
+    },
+)
diff --git a/tests/dev/BUILD.bazel b/tests/dev/BUILD.bazel
new file mode 100644
index 0000000..5ed87cd
--- /dev/null
+++ b/tests/dev/BUILD.bazel
@@ -0,0 +1,12 @@
+load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
+
+# Builds the cross-compiled release files in test runs too, e.g. with
+# --build_tests_only.
+build_test(
+    name = "cross_compiled_artifacts_build_test",
+    # Under WORKSPACE, rules_rust is a stub, so the target can't build; see
+    # //dev/release_artifacts:artifacts_for_release.
+    tags = [] if BZLMOD_ENABLED else ["manual"],
+    targets = ["//dev/release_artifacts:cross_compiled_artifacts"],
+)