From 9e5b19087668350d39c92e60e94a799e2be1cfd2 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:48:38 +0200 Subject: [PATCH 01/12] feat(script): support Windows x64 and ARM64 execution --- crates/rattler_build_core/src/env_vars.rs | 29 ++- .../src/package_test/run_test.rs | 121 +++++++---- crates/rattler_build_core/src/script.rs | 38 ++-- crates/rattler_build_core/src/staging.rs | 22 +- crates/rattler_build_script/src/activation.rs | 37 ++-- crates/rattler_build_script/src/execution.rs | 197 ++++++++++++------ .../src/execution_context.rs | 151 ++++++++++++++ .../src/interpreter/cmd_exe.rs | 12 +- .../src/interpreter/mod.rs | 115 +++++----- crates/rattler_build_script/src/lib.rs | 4 + .../src/native_runner/bash.rs | 27 +-- .../src/native_runner/cmd_exe.rs | 66 ++++-- .../src/native_runner/mod.rs | 117 ++++++++++- crates/rattler_build_script/src/runtime.rs | 20 +- py-rattler-build/rust/src/render.rs | 1 + src/lib.rs | 1 + 16 files changed, 709 insertions(+), 249 deletions(-) create mode 100644 crates/rattler_build_script/src/execution_context.rs diff --git a/crates/rattler_build_core/src/env_vars.rs b/crates/rattler_build_core/src/env_vars.rs index 490e59de5..aefcb9630 100644 --- a/crates/rattler_build_core/src/env_vars.rs +++ b/crates/rattler_build_core/src/env_vars.rs @@ -203,6 +203,7 @@ pub fn os_vars( prefix: &Path, target_platform: &Platform, host_platform: &Platform, + build_platform: &Platform, env_isolation: EnvironmentIsolation, work_dir: &Path, ) -> HashMap> { @@ -260,13 +261,12 @@ pub fn os_vars( env_isolation, )); } - let build_platform = Platform::current(); if build_platform.is_windows() { - vars.extend(windows::env::default_env_vars_build(&build_platform)); + vars.extend(windows::env::default_env_vars_build(build_platform)); } else if build_platform.is_osx() { - vars.extend(macos::env::default_env_vars_build(&build_platform)); + vars.extend(macos::env::default_env_vars_build(build_platform)); } else if build_platform.is_linux() { - vars.extend(linux::env::default_env_vars_build(&build_platform)); + vars.extend(linux::env::default_env_vars_build(build_platform)); } if build_platform.is_windows() { @@ -502,6 +502,7 @@ mod test { prefix, &Platform::NoArch, &Platform::Win64, + &Platform::Win64, EnvironmentIsolation::Strict, work_dir, ); @@ -516,6 +517,25 @@ mod test { ); } + #[test] + fn build_vars_follow_configured_build_platform() { + let vars = os_vars( + Path::new("/some/prefix"), + &Platform::WinArm64, + &Platform::WinArm64, + &Platform::WinArm64, + EnvironmentIsolation::Strict, + Path::new("/some/work"), + ); + + let expected = + std::env::var("BUILD").unwrap_or_else(|_| "arm64-pc-windows-19.0.0".to_string()); + assert_eq!( + vars.get("BUILD").and_then(|value| value.as_deref()), + Some(expected.as_str()) + ); + } + /// noarch on a non-Windows host does not emit Windows target vars. #[test] fn test_noarch_non_windows_host_has_no_windows_vars() { @@ -526,6 +546,7 @@ mod test { prefix, &Platform::NoArch, &Platform::Linux64, + &Platform::Linux64, EnvironmentIsolation::Strict, work_dir, ); diff --git a/crates/rattler_build_core/src/package_test/run_test.rs b/crates/rattler_build_core/src/package_test/run_test.rs index 13d93c09c..9b6bf6390 100644 --- a/crates/rattler_build_core/src/package_test/run_test.rs +++ b/crates/rattler_build_core/src/package_test/run_test.rs @@ -12,7 +12,9 @@ use rattler_build_recipe::stage1::{ TestType, tests::{CommandsTest, DownstreamTest, PerlTest, PythonTest, PythonVersion, RTest, RubyTest}, }; -use rattler_build_script::{EnvironmentIsolation, Script, ScriptContent}; +use rattler_build_script::{ + EnvironmentIsolation, ExecutionContext, RuntimeEnv, Script, ScriptContent, +}; use rattler_build_types::NormalizedKey; use rattler_conda_types::{ Channel, ChannelUrl, MatchSpec, PackageName, PackageNameMatcher, ParseStrictness, Platform, @@ -204,16 +206,16 @@ impl Tests { ))) })?; - let platform = Platform::current(); let mut env_vars = env_vars::os_vars( environment, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, tmp_dir.path(), ); if config.env_isolation == EnvironmentIsolation::None { - env_vars.retain(|key, _| key != ShellEnum::default().path_var(&platform)); + env_vars.retain(|key, _| key != ShellEnum::default().path_var(&build_platform)); } env_vars.extend(env_vars::test_vars( target_platform, @@ -223,7 +225,7 @@ impl Tests { env_vars.extend(env_vars::python_vars_from_records( resolved_records, environment, - platform, + host_platform, )); env_vars.extend(pkg_vars.iter().map(|(k, v)| (k.clone(), Some(v.clone())))); env_vars.insert( @@ -231,6 +233,8 @@ impl Tests { Some(environment.to_string_lossy().to_string()), ); + let context = shared_test_context(environment, host_platform); + match self { Tests::Commands(path) => { let script = Script { @@ -243,8 +247,7 @@ impl Tests { env_vars, tmp_dir.path(), cwd, - environment, - None, + context, None:: Result>, None, config.env_isolation, @@ -264,8 +267,7 @@ impl Tests { env_vars, tmp_dir.path(), cwd, - environment, - None, + context, None:: Result>, None, config.env_isolation, @@ -343,6 +345,23 @@ pub struct TestConfiguration { pub env_isolation: EnvironmentIsolation, } +fn configured_test_platforms(config: &TestConfiguration) -> (Platform, Platform, Platform) { + let target_platform = config.target_platform.unwrap_or(Platform::current()); + let build_platform = config.current_platform.platform; + let host_platform = config + .host_platform + .as_ref() + .map(|platform| platform.platform) + .unwrap_or(target_platform); + (target_platform, build_platform, host_platform) +} + +/// Tests without a separate build environment execute programs from the test +/// prefix, so their wrapper architecture must match the package host platform. +fn shared_test_context(prefix: &Path, host_platform: Platform) -> ExecutionContext { + ExecutionContext::shared(RuntimeEnv::current(), prefix, host_platform, host_platform) +} + fn env_vars_from_package(index_json: &IndexJson) -> HashMap { let mut res = HashMap::new(); @@ -796,24 +815,26 @@ async fn run_python_test_inner( ..Script::default() }; - let platform = Platform::current(); + let (target_platform, build_platform, host_platform) = configured_test_platforms(config); let test_dir = prefix.join("test"); fs::create_dir_all(&test_dir)?; let test_env_vars = env_vars::os_vars( &test_prefix, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, &test_dir, ); + let context = shared_test_context(&test_prefix, host_platform); + script .run_script( test_env_vars.clone(), &test_dir, path, - &test_prefix, - None, + context.clone(), None:: Result>, None, config.env_isolation, @@ -836,8 +857,7 @@ async fn run_python_test_inner( test_env_vars, path, path, - &test_prefix, - None, + context, None:: Result>, None, config.env_isolation, @@ -908,22 +928,23 @@ async fn run_perl_test( let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; - let platform = Platform::current(); + let (target_platform, build_platform, host_platform) = configured_test_platforms(config); let test_env_vars = env_vars::os_vars( &test_prefix, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, &test_folder, ); + let context = shared_test_context(&test_prefix, host_platform); script .run_script( test_env_vars, &test_folder, path, - &test_prefix, - None, + context, None:: Result>, None, config.env_isolation, @@ -1021,16 +1042,16 @@ async fn run_commands_test( ))) })?; - let platform = Platform::current(); let mut env_vars = env_vars::os_vars( &run_prefix, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, &test_dir, ); if config.env_isolation == EnvironmentIsolation::None { - env_vars.retain(|key, _| key != ShellEnum::default().path_var(&platform)); + env_vars.retain(|key, _| key != ShellEnum::default().path_var(&build_platform)); } env_vars.extend(env_vars::test_vars( target_platform, @@ -1040,7 +1061,7 @@ async fn run_commands_test( env_vars.extend(env_vars::python_vars_from_records( &resolved_records, &run_prefix, - platform, + host_platform, )); env_vars.extend(pkg_vars.iter().map(|(k, v)| (k.clone(), Some(v.clone())))); env_vars.insert( @@ -1048,6 +1069,18 @@ async fn run_commands_test( Some(run_prefix.to_string_lossy().to_string()), ); + let context = if let Some(build_prefix) = build_prefix { + ExecutionContext::separate( + RuntimeEnv::current(), + build_prefix, + build_platform, + &run_prefix, + host_platform, + ) + } else { + shared_test_context(&run_prefix, host_platform) + }; + tracing::info!("Testing commands:"); commands_test .script @@ -1055,8 +1088,7 @@ async fn run_commands_test( env_vars, &test_dir, path, - &run_prefix, - build_prefix.as_ref(), + context, None:: Result>, None, config.env_isolation, @@ -1221,22 +1253,23 @@ async fn run_r_test( let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; - let platform = Platform::current(); + let (target_platform, build_platform, host_platform) = configured_test_platforms(config); let test_env_vars = env_vars::os_vars( &test_prefix, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, &test_folder, ); + let context = shared_test_context(&test_prefix, host_platform); script .run_script( test_env_vars, &test_folder, path, - &test_prefix, - None, + context, None:: Result>, None, config.env_isolation, @@ -1302,22 +1335,23 @@ async fn run_ruby_test( let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; - let platform = Platform::current(); + let (target_platform, build_platform, host_platform) = configured_test_platforms(config); let test_env_vars = env_vars::os_vars( &test_prefix, - &platform, - &platform, + &target_platform, + &host_platform, + &build_platform, config.env_isolation, &test_folder, ); + let context = shared_test_context(&test_prefix, host_platform); script .run_script( test_env_vars, &test_folder, path, - &test_prefix, - None, + context, None:: Result>, None, config.env_isolation, @@ -1332,6 +1366,15 @@ async fn run_ruby_test( mod tests { use super::*; + #[test] + fn shared_test_context_executes_the_host_prefix_platform() { + let context = shared_test_context(Path::new("test-prefix"), Platform::WinArm64); + assert_eq!(context.build().path(), Path::new("test-prefix")); + assert_eq!(context.host().path(), Path::new("test-prefix")); + assert_eq!(context.build().platform(), Platform::WinArm64); + assert_eq!(context.host().platform(), Platform::WinArm64); + } + /// Verifies that a trailing-underscore version (the openssl ordering /// convention, e.g. `3.7_`) is assembled into a `MatchSpec` that matches the /// exact version, without degrading into the broken string re-parse result. diff --git a/crates/rattler_build_core/src/script.rs b/crates/rattler_build_core/src/script.rs index 2d26ba177..7d02d6748 100644 --- a/crates/rattler_build_core/src/script.rs +++ b/crates/rattler_build_core/src/script.rs @@ -9,8 +9,8 @@ use rattler_build_jinja::Jinja; // Re-export from rattler_build_script pub use rattler_build_script::{ - ExecutionArgs, InterpreterError, ResolvedScriptContents, RuntimeEnv, SandboxArguments, - SandboxConfiguration, Script, ScriptContent, platform_script_extensions, + ExecutionArgs, ExecutionContext, InterpreterError, ResolvedScriptContents, RuntimeEnv, + SandboxArguments, SandboxConfiguration, Script, ScriptContent, platform_script_extensions, }; use crate::{ @@ -37,6 +37,7 @@ impl Output { &host_prefix, &target_platform, &host_platform, + &self.build_configuration.build_platform.platform, env_isolation, &self.build_configuration.directories.work_dir, )); @@ -44,10 +45,21 @@ impl Output { let jinja_renderer = self.jinja_renderer(); - let build_prefix = if self.recipe.build().merge_build_and_host_envs { - None + let context = if self.recipe.build().merge_build_and_host_envs { + ExecutionContext::shared( + RuntimeEnv::current(), + &host_prefix, + self.build_configuration.build_platform.platform, + host_platform, + ) } else { - Some(&self.build_configuration.directories.build_prefix) + ExecutionContext::separate( + RuntimeEnv::current(), + &self.build_configuration.directories.build_prefix, + self.build_configuration.build_platform.platform, + &host_prefix, + host_platform, + ) }; let work_dir = &self.build_configuration.directories.work_dir; @@ -63,9 +75,7 @@ impl Output { .filter_map(|(k, v)| v.map(|v| (k, v))) .collect(), secrets: IndexMap::new(), - build_prefix: build_prefix.map(|p| p.to_owned()), - run_prefix: host_prefix, - runtime: RuntimeEnv::current(), + context, work_dir: work_dir.clone(), sandbox_config: self.build_configuration.sandbox_config().cloned(), env_isolation, @@ -101,12 +111,9 @@ impl Output { Err(err) => return Err(err.into()), } - let exec_args = self.prepare_build_script().await?; - let build_prefix = if self.recipe.build().merge_build_and_host_envs { - None - } else { - Some(&self.build_configuration.directories.build_prefix) - }; + let mut exec_args = self.prepare_build_script().await?; + exec_args.apply_platform_environment(); + let context = exec_args.context.clone(); // Create Jinja context with environment variables let mut jinja = Jinja::new(self.build_configuration.selector_config()) @@ -134,8 +141,7 @@ impl Output { .collect(), &self.build_configuration.directories.work_dir, &self.build_configuration.directories.recipe_dir, - &self.build_configuration.directories.host_prefix, - build_prefix, + context, Some(jinja_renderer), self.build_configuration.sandbox_config(), self.build_configuration.env_isolation, diff --git a/crates/rattler_build_core/src/staging.rs b/crates/rattler_build_core/src/staging.rs index 5d3bf70ff..49779bdb3 100644 --- a/crates/rattler_build_core/src/staging.rs +++ b/crates/rattler_build_core/src/staging.rs @@ -14,6 +14,7 @@ use miette::{Context, IntoDiagnostic}; use minijinja::Value; use rattler_build_jinja::{Jinja, Variable}; use rattler_build_recipe::stage1::{InheritsFrom, StagingCache}; +use rattler_build_script::{ExecutionContext, RuntimeEnv}; use rattler_build_types::NormalizedKey; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -280,6 +281,7 @@ impl Output { self.prefix(), &target_platform, &host_platform, + &self.build_configuration.build_platform.platform, self.build_configuration.env_isolation, &self.build_configuration.directories.work_dir, )); @@ -324,10 +326,21 @@ impl Output { jinja.render_str(template).map_err(|e| e.to_string()) }; - let build_prefix = if staging.build.merge_build_and_host_envs { - None + let context = if staging.build.merge_build_and_host_envs { + ExecutionContext::shared( + RuntimeEnv::current(), + &self.build_configuration.directories.host_prefix, + self.build_configuration.build_platform.platform, + host_platform, + ) } else { - Some(&self.build_configuration.directories.build_prefix) + ExecutionContext::separate( + RuntimeEnv::current(), + &self.build_configuration.directories.build_prefix, + self.build_configuration.build_platform.platform, + &self.build_configuration.directories.host_prefix, + host_platform, + ) }; staging @@ -337,8 +350,7 @@ impl Output { env_vars, &self.build_configuration.directories.work_dir, &self.build_configuration.directories.recipe_dir, - &self.build_configuration.directories.host_prefix, - build_prefix, + context, Some(jinja_renderer), self.build_configuration.sandbox_config(), self.build_configuration.env_isolation, diff --git a/crates/rattler_build_script/src/activation.rs b/crates/rattler_build_script/src/activation.rs index f97358074..28269eb37 100644 --- a/crates/rattler_build_script/src/activation.rs +++ b/crates/rattler_build_script/src/activation.rs @@ -11,29 +11,33 @@ use rattler_shell::{ shell::{self, Shell}, }; -use crate::execution::ExecutionArgs; +use crate::{PrefixLayout, execution::ExecutionArgs}; /// Returns the shell-specific activation script sourced/called by the native wrapper. pub(crate) fn activation_script( args: &ExecutionArgs, shell_type: T, ) -> Result { - let platform = args.runtime.platform(); - let mut shell_script = shell::ShellScript::new(shell_type.clone(), platform); + let mut shell_script = + shell::ShellScript::new(shell_type.clone(), args.context.build().platform()); for (k, v) in args.env_vars.iter() { shell_script.set_env_var(k, v)?; } // Re-entrancy marker: this way the preamble sources this file // once and nested shells skip re-sourcing it. shell_script.set_env_var("CONDA_BUILD", "1")?; - let host_prefix_activator = - Activator::from_path(&args.run_prefix, shell_type.clone(), platform)?; + let host_prefix_activator = Activator::from_path( + args.context.host().path(), + shell_type.clone(), + args.context.host().platform(), + )?; // Do not pass the host CONDA_PREFIX to the activation. When // CONDA_PREFIX is set (e.g. running inside a pixi/conda env), the // activator generates deactivation scripts for that environment. let current_env = args - .runtime + .context + .runtime() .vars() .map(|(k, v)| (k.to_owned(), v.to_owned())) .collect::>(); @@ -46,9 +50,12 @@ pub(crate) fn activation_script( let host_activation = host_prefix_activator.activation(activation_vars)?; - if let Some(build_prefix) = &args.build_prefix { - let build_prefix_activator = - Activator::from_path(build_prefix, shell_type.clone(), platform)?; + if args.context.layout() == PrefixLayout::Separate { + let build_prefix_activator = Activator::from_path( + args.context.build().path(), + shell_type.clone(), + args.context.build().platform(), + )?; let activation_vars = ActivationVariables { conda_prefix: None, path: None, @@ -73,7 +80,7 @@ mod tests { use rattler_shell::shell; use crate::execution::{EnvironmentIsolation, ExecutionArgs, ResolvedScriptContents}; - use crate::runtime::RuntimeEnv; + use crate::{ExecutionContext, runtime::RuntimeEnv}; /// When a build prefix is present, both the run prefix and build prefix are /// activated and the generated script references both paths. @@ -90,9 +97,13 @@ mod tests { interpreter: None, env_vars: IndexMap::new(), secrets: IndexMap::new(), - runtime: RuntimeEnv::for_test(Platform::current()), - build_prefix: Some(build_prefix.clone()), - run_prefix: run_prefix.clone(), + context: ExecutionContext::separate( + RuntimeEnv::for_test(Platform::current()), + build_prefix.clone(), + Platform::current(), + run_prefix.clone(), + Platform::current(), + ), work_dir: tmp.path().to_path_buf(), sandbox_config: None, env_isolation: EnvironmentIsolation::None, diff --git a/crates/rattler_build_script/src/execution.rs b/crates/rattler_build_script/src/execution.rs index 794db0f3d..3be556439 100644 --- a/crates/rattler_build_script/src/execution.rs +++ b/crates/rattler_build_script/src/execution.rs @@ -4,9 +4,9 @@ //! [`generate_build_script`], executes them with [`run_script`], and provides //! subprocess output handling via [`run_process_with_replacements`]. -use crate::runtime::RuntimeEnv; use crate::sandbox::SandboxConfiguration; use crate::script::{Script, ScriptContent}; +use crate::{execution_context::ExecutionContext, runtime::RuntimeEnv}; use fs_err as fs; use futures::TryStreamExt; use indexmap::IndexMap; @@ -82,14 +82,8 @@ pub struct ExecutionArgs { /// Secrets to set as env vars and replace in the output pub secrets: IndexMap, - /// The environment rattler-build is running in: process environment - /// variables (including `PATH`) and the platform scripts execute on. - pub runtime: RuntimeEnv, - - /// The build prefix that should contain the interpreter to use - pub build_prefix: Option, - /// The prefix to use for the script execution - pub run_prefix: PathBuf, + /// Process and platform-aware build and host prefixes for this execution. + pub context: ExecutionContext, /// The working directory (`cwd`) in which the script should execute pub work_dir: PathBuf, @@ -107,14 +101,12 @@ impl ExecutionArgs { /// will be replaced with the actual variable name. pub(crate) fn replacements(&self, template: &str) -> HashMap { let mut replacements = HashMap::new(); - if let Some(build_prefix) = &self.build_prefix { - replacements.insert( - build_prefix.display().to_string(), - template.replace("((var))", "BUILD_PREFIX"), - ); - }; replacements.insert( - self.run_prefix.display().to_string(), + self.context.build().path().display().to_string(), + template.replace("((var))", "BUILD_PREFIX"), + ); + replacements.insert( + self.context.host().path().display().to_string(), template.replace("((var))", "PREFIX"), ); @@ -136,6 +128,23 @@ impl ExecutionArgs { replacements } + + /// Normalizes mutable Windows process architecture variables when the + /// configured build environment deliberately switches between x64 and ARM64. + pub fn apply_platform_environment(&mut self) { + if let Some(machine) = crate::native_runner::windows_machine_transition( + self.context.runtime().process_platform(), + self.context.build().platform(), + ) { + self.env_vars.insert( + "PROCESSOR_ARCHITECTURE".to_string(), + machine.processor_architecture().to_string(), + ); + // `set "VAR="` in build_env.bat removes this compatibility marker. + self.env_vars + .insert("PROCESSOR_ARCHITEW6432".to_string(), String::new()); + } + } } /// The resolved contents of a script. @@ -194,8 +203,7 @@ impl Script { env_vars: HashMap>, work_dir: &Path, recipe_dir: &Path, - run_prefix: &Path, - build_prefix: Option<&PathBuf>, + context: ExecutionContext, jinja_renderer: Option, sandbox_config: Option<&SandboxConfiguration>, env_isolation: EnvironmentIsolation, @@ -215,7 +223,7 @@ impl Script { crate::platform_script_extensions(), )?; - let runtime = RuntimeEnv::current(); + let runtime = context.runtime(); let secrets = self .secrets() @@ -233,25 +241,24 @@ impl Script { .collect::>(); let work_dir = if let Some(cwd) = self.cwd.as_ref() { - run_prefix.join(cwd) + context.host().path().join(cwd) } else { work_dir.to_owned() }; tracing::debug!("Running script in {}", work_dir.display()); - let exec_args = ExecutionArgs { + let mut exec_args = ExecutionArgs { script: contents, interpreter: self.interpreter.clone(), env_vars, secrets, - build_prefix: build_prefix.map(|p| p.to_owned()), - run_prefix: run_prefix.to_owned(), - runtime, + context, work_dir, sandbox_config: sandbox_config.cloned(), env_isolation, }; + exec_args.apply_platform_environment(); crate::execution::run_script(exec_args).await?; @@ -475,7 +482,7 @@ fn section_script_filename(extension: &str, index: SectionIndex) -> String { pub(crate) async fn generate_build_script( args: &ExecutionArgs, ) -> Result { - let runner = crate::native_runner::native_runner(args.runtime.platform()); + let runner = crate::native_runner::native_runner(args.context.runtime().process_platform()); let shell = runner.shell(); let script_extension = shell.extension(); @@ -605,11 +612,7 @@ async fn build_section_body( }; // Resolve from the activated environment (build/host prefix, then PATH). - let executable = interpreter.resolve_executable( - args.build_prefix.as_deref(), - &args.run_prefix, - &args.runtime, - )?; + let executable = interpreter.resolve_executable(&args.context)?; // Quote so a prefix or script path with spaces survives the native shell. let mut command = vec![executable.to_string_lossy().into_owned()]; @@ -627,14 +630,17 @@ async fn build_section_body( } /// Runs a script with the given execution arguments. -pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::InterpreterError> { - let runner = crate::native_runner::native_runner(exec_args.runtime.platform()); +pub(crate) async fn run_script( + mut exec_args: ExecutionArgs, +) -> Result<(), crate::InterpreterError> { + exec_args.apply_platform_environment(); + let runner = + crate::native_runner::native_runner(exec_args.context.runtime().process_platform()); let build_script_path = generate_build_script(&exec_args).await?; - let build_script_path_str = build_script_path.to_string_lossy().to_string(); - let cmd_args = runner.command_to_run_script(&build_script_path_str); + let command_spec = runner.command_to_run_script(&build_script_path, &exec_args.context); let output = crate::execution::run_process_with_replacements( - &cmd_args, + &command_spec, &exec_args.work_dir, &exec_args.replacements(runner.replacements_template()), &exec_args.env_vars, @@ -645,17 +651,13 @@ pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::In } else { None }, - &exec_args.runtime, + exec_args.context.runtime(), ) .await?; if !output.status.success() { let status_code = output.status.code().unwrap_or(1); - let debug_info = runner.debug_info( - &exec_args.work_dir, - &exec_args.run_prefix, - exec_args.build_prefix.as_deref(), - ); + let debug_info = runner.debug_info(&exec_args.work_dir, &exec_args.context); tracing::error!("Script failed with status {}", status_code); tracing::error!("{}", debug_info); return Err(crate::InterpreterError::ExecutionFailed( @@ -670,7 +672,8 @@ pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::In } /// Creates build script files without executing them. -pub async fn create_build_script(exec_args: ExecutionArgs) -> Result<(), std::io::Error> { +pub async fn create_build_script(mut exec_args: ExecutionArgs) -> Result<(), std::io::Error> { + exec_args.apply_platform_environment(); let build_script_path = generate_build_script(&exec_args) .await .map_err(|err| match err { @@ -792,7 +795,7 @@ fn configure_subprocess_env( /// This is used to replace the host prefix with $PREFIX and the build prefix with $BUILD_PREFIX #[allow(clippy::too_many_arguments)] pub(crate) async fn run_process_with_replacements( - args: &[&str], + command_spec: &crate::native_runner::CommandSpec, cwd: &Path, replacements: &HashMap, env_vars: &IndexMap, @@ -820,8 +823,8 @@ pub(crate) async fn run_process_with_replacements( cmd.args(&sandbox_args); // Add the actual command to execute (as positional arguments) - cmd.arg(args[0]); - cmd.args(&args[1..]); + cmd.arg(&command_spec.program); + cmd.args(&command_spec.args); cmd } else { @@ -833,7 +836,7 @@ pub(crate) async fn run_process_with_replacements( )); } } else { - tokio::process::Command::new(args[0]) + tokio::process::Command::new(&command_spec.program) }; configure_subprocess_env(&mut command, env_vars, secrets, env_isolation, runtime); @@ -843,7 +846,7 @@ pub(crate) async fn run_process_with_replacements( // when using `pixi global install bash` the current work dir // causes some strange issues that are fixed when setting the `PWD` .env("PWD", cwd) - .args(&args[1..]) + .args(&command_spec.args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -925,6 +928,7 @@ pub(crate) async fn run_process_with_replacements( #[cfg(test)] mod tests { use super::*; + use crate::ExecutionContext; use rattler_conda_types::Platform; use tokio_util::bytes::BytesMut; @@ -943,9 +947,12 @@ mod tests { interpreter: None, env_vars: IndexMap::new(), secrets: IndexMap::new(), - runtime: RuntimeEnv::for_test(Platform::current()), - build_prefix: None, - run_prefix: prefix, + context: ExecutionContext::shared( + RuntimeEnv::for_test(Platform::current()), + prefix, + Platform::current(), + Platform::current(), + ), work_dir: tmp.path().to_path_buf(), sandbox_config: None, env_isolation: EnvironmentIsolation::None, @@ -958,6 +965,66 @@ mod tests { ); } + #[test] + fn architecture_transition_normalizes_processor_environment() { + let mut args = ExecutionArgs { + script: ResolvedScriptContents::Missing, + interpreter: None, + env_vars: IndexMap::new(), + secrets: IndexMap::new(), + context: ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::WinArm64, + Platform::WinArm64, + ), + work_dir: PathBuf::from("work"), + sandbox_config: None, + env_isolation: EnvironmentIsolation::Strict, + }; + args.env_vars.insert( + "PROCESSOR_IDENTIFIER".to_string(), + "ARMv8 (64-bit) Family".to_string(), + ); + args.apply_platform_environment(); + assert_eq!( + args.env_vars.get("PROCESSOR_ARCHITECTURE"), + Some(&"ARM64".to_string()) + ); + assert_eq!( + args.env_vars.get("PROCESSOR_ARCHITEW6432"), + Some(&String::new()) + ); + assert_eq!( + args.env_vars.get("PROCESSOR_IDENTIFIER"), + Some(&"ARMv8 (64-bit) Family".to_string()), + "the host processor identifier is intentionally preserved" + ); + + args.context = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::WinArm64), + "prefix", + Platform::Win64, + Platform::Win64, + ); + args.env_vars.clear(); + args.apply_platform_environment(); + assert_eq!( + args.env_vars.get("PROCESSOR_ARCHITECTURE"), + Some(&"AMD64".to_string()) + ); + + args.context = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::Win64, + Platform::Win64, + ); + args.env_vars.clear(); + args.apply_platform_environment(); + assert!(args.env_vars.is_empty()); + } + /// The outer subprocess must start without `CONDA_BUILD` set, otherwise /// the preamble skips sourcing the activation script. #[test] @@ -1050,9 +1117,12 @@ mod tests { interpreter: None, env_vars: IndexMap::new(), secrets: IndexMap::new(), - runtime: RuntimeEnv::for_test(Platform::Win64), - build_prefix: None, - run_prefix: prefix, + context: ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + prefix, + Platform::Win64, + Platform::Win64, + ), work_dir: tmp.path().to_path_buf(), sandbox_config: None, env_isolation: EnvironmentIsolation::None, @@ -1283,9 +1353,12 @@ mod tests { interpreter: interpreter.map(str::to_string), env_vars: IndexMap::new(), secrets: IndexMap::new(), - runtime: RuntimeEnv::current(), - build_prefix: None, - run_prefix, + context: ExecutionContext::shared( + RuntimeEnv::current(), + run_prefix, + Platform::current(), + Platform::current(), + ), work_dir, sandbox_config: None, env_isolation: EnvironmentIsolation::None, @@ -1508,7 +1581,9 @@ mod tests { None, ); let args = ExecutionArgs { - runtime: RuntimeEnv::for_test(Platform::Linux64), + context: args + .context + .with_runtime(RuntimeEnv::for_test(Platform::Linux64)), ..args }; generate_build_script(&args).await.unwrap(); @@ -1534,7 +1609,9 @@ mod tests { None, ); let args = ExecutionArgs { - runtime: RuntimeEnv::for_test(Platform::Linux64), + context: args + .context + .with_runtime(RuntimeEnv::for_test(Platform::Linux64)), ..args }; generate_build_script(&args).await.unwrap(); @@ -1560,7 +1637,9 @@ mod tests { None, ); let args = ExecutionArgs { - runtime: RuntimeEnv::for_test(Platform::Win64), + context: args + .context + .with_runtime(RuntimeEnv::for_test(Platform::Win64)), ..args }; generate_build_script(&args).await.unwrap(); diff --git a/crates/rattler_build_script/src/execution_context.rs b/crates/rattler_build_script/src/execution_context.rs new file mode 100644 index 000000000..63c6b930c --- /dev/null +++ b/crates/rattler_build_script/src/execution_context.rs @@ -0,0 +1,151 @@ +//! Platform-aware prefix and process context for script execution. + +use std::path::{Path, PathBuf}; + +use rattler_conda_types::Platform; + +use crate::RuntimeEnv; + +/// A conda prefix together with the platform of the environment it contains. +#[derive(Debug, Clone)] +pub struct PrefixWithPlatform { + path: PathBuf, + platform: Platform, +} + +impl PrefixWithPlatform { + /// Creates a prefix execution descriptor. + pub fn new(path: impl Into, platform: Platform) -> Self { + Self { + path: path.into(), + platform, + } + } + + /// The prefix path. + pub fn path(&self) -> &Path { + &self.path + } + + /// The platform of the environment installed in this prefix. + pub fn platform(&self) -> Platform { + self.platform + } +} + +/// Whether build and host environments have distinct or shared prefixes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrefixLayout { + /// Build and host environments are separate prefixes. + Separate, + /// Build and host environments share one prefix and must be activated once. + Shared, +} + +/// Process and prefix information needed to execute a build or test script. +#[derive(Debug, Clone)] +pub struct ExecutionContext { + runtime: RuntimeEnv, + build: PrefixWithPlatform, + host: PrefixWithPlatform, + layout: PrefixLayout, +} + +impl ExecutionContext { + /// Creates a context with separate build and host prefixes. + pub fn separate( + runtime: RuntimeEnv, + build_path: impl Into, + build_platform: Platform, + host_path: impl Into, + host_platform: Platform, + ) -> Self { + Self { + runtime, + build: PrefixWithPlatform::new(build_path, build_platform), + host: PrefixWithPlatform::new(host_path, host_platform), + layout: PrefixLayout::Separate, + } + } + + /// Creates a context whose build and host environments share one prefix. + pub fn shared( + runtime: RuntimeEnv, + path: impl Into, + build_platform: Platform, + host_platform: Platform, + ) -> Self { + let path = path.into(); + Self { + runtime, + build: PrefixWithPlatform::new(path.clone(), build_platform), + host: PrefixWithPlatform::new(path, host_platform), + layout: PrefixLayout::Shared, + } + } + + /// The environment and architecture of the rattler-build process. + pub fn runtime(&self) -> &RuntimeEnv { + &self.runtime + } + + /// Returns a copy with a different rattler-build process runtime. + #[must_use] + pub fn with_runtime(mut self, runtime: RuntimeEnv) -> Self { + self.runtime = runtime; + self + } + + /// The prefix that supplies build tools and the platform they execute on. + pub fn build(&self) -> &PrefixWithPlatform { + &self.build + } + + /// The prefix that supplies host dependencies and the platform it represents. + pub fn host(&self) -> &PrefixWithPlatform { + &self.host + } + + /// Whether build and host prefixes are separate or shared. + pub fn layout(&self) -> PrefixLayout { + self.layout + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn separate_context_retains_both_prefixes() { + let context = ExecutionContext::separate( + RuntimeEnv::for_test(Platform::Win64), + "build", + Platform::Win64, + "host", + Platform::WinArm64, + ); + + assert_eq!(context.layout(), PrefixLayout::Separate); + assert_eq!(context.build().path(), Path::new("build")); + assert_eq!(context.build().platform(), Platform::Win64); + assert_eq!(context.host().path(), Path::new("host")); + assert_eq!(context.host().platform(), Platform::WinArm64); + } + + #[test] + fn shared_context_uses_one_path_with_both_platforms() { + let context = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::Win64, + Platform::WinArm64, + ); + + assert_eq!(context.layout(), PrefixLayout::Shared); + assert_eq!(context.build().path(), Path::new("prefix")); + assert_eq!(context.host().path(), Path::new("prefix")); + assert_eq!(context.build().platform(), Platform::Win64); + assert_eq!(context.host().platform(), Platform::WinArm64); + } +} diff --git a/crates/rattler_build_script/src/interpreter/cmd_exe.rs b/crates/rattler_build_script/src/interpreter/cmd_exe.rs index 1a7d66639..c17b0f1a2 100644 --- a/crates/rattler_build_script/src/interpreter/cmd_exe.rs +++ b/crates/rattler_build_script/src/interpreter/cmd_exe.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use rattler_conda_types::Platform; use super::{InterpreterInvocation, InterpreterSearchScope}; -use crate::runtime::RuntimeEnv; +use crate::ExecutionContext; pub struct CmdExeInvocation; @@ -35,21 +35,19 @@ impl InterpreterInvocation for CmdExeInvocation { fn resolve_executable( &self, - build_prefix: Option<&Path>, - run_prefix: &Path, - runtime: &RuntimeEnv, + context: &ExecutionContext, ) -> Result { - let platform = runtime.platform(); + let platform = context.build().platform(); let scope = self.search_scope(&platform); if platform.is_windows() && scope.allows_system_fallback() - && let Some(comspec) = runtime.var("COMSPEC") + && let Some(comspec) = context.runtime().var("COMSPEC") && comspec.to_lowercase().contains("cmd.exe") { return Ok(PathBuf::from(comspec)); } - super::find_interpreter("cmd", build_prefix, run_prefix, runtime, scope) + super::find_interpreter("cmd", context, scope) .ok_or_else(|| super::InterpreterError::InterpreterNotFound("cmd".to_string())) } diff --git a/crates/rattler_build_script/src/interpreter/mod.rs b/crates/rattler_build_script/src/interpreter/mod.rs index c6547f33d..cc837a4d4 100644 --- a/crates/rattler_build_script/src/interpreter/mod.rs +++ b/crates/rattler_build_script/src/interpreter/mod.rs @@ -21,7 +21,7 @@ use std::path::{Path, PathBuf}; use rattler_conda_types::Platform; use rattler_shell::activation::prefix_path_entries; -use crate::runtime::RuntimeEnv; +use crate::ExecutionContext; /// Describes interpreter execution and lookup errors. #[derive(Debug, thiserror::Error)] @@ -126,23 +126,24 @@ impl InterpreterSearchScope { pub(crate) fn find_interpreter( name: &str, - build_prefix: Option<&Path>, - run_prefix: &Path, - runtime: &RuntimeEnv, + context: &ExecutionContext, scope: InterpreterSearchScope, ) -> Option { + let runtime = context.runtime(); let exe_name = format!("{}{}", name, runtime.exe_suffix()); - let platform = runtime.platform(); let mut search_path = Vec::new(); if scope.search_build { - // When build and host are merged there is no separate build prefix; the - // run prefix is the build environment. - let build_env = build_prefix.unwrap_or(run_prefix); - search_path.extend(prefix_path_entries(build_env, &platform)); + search_path.extend(prefix_path_entries( + context.build().path(), + &context.build().platform(), + )); } if scope.search_host { - search_path.extend(prefix_path_entries(run_prefix, &platform)); + search_path.extend(prefix_path_entries( + context.host().path(), + &context.host().platform(), + )); } if scope.system_fallback { search_path.extend(std::env::split_paths(runtime.path())); @@ -151,11 +152,8 @@ pub(crate) fn find_interpreter( if search_path.is_empty() { return None; } - // The interpreter is resolved on the host filesystem, so use the host's path - // conventions throughout: `std::env::{split_paths,join_paths}` and `which` - // (path-list separator, `PATHEXT`, executable bit) all key off the host - // platform. `runtime.platform()` always equals the host here, so there is no - // cross-platform case to handle. + // Prefix paths use their configured platform conventions. System fallback + // uses the rattler-build process environment and host filesystem rules. which::which_in_global(exe_name, std::env::join_paths(search_path).ok()) .ok()? .next() @@ -212,18 +210,13 @@ pub(crate) trait InterpreterInvocation: Send + Sync { /// /// The default implementation provides shared lookup behavior; interpreters /// can override it for platform-specific behavior. - fn resolve_executable( - &self, - build_prefix: Option<&Path>, - run_prefix: &Path, - runtime: &RuntimeEnv, - ) -> Result { - let platform = runtime.platform(); + fn resolve_executable(&self, context: &ExecutionContext) -> Result { + let platform = context.build().platform(); let scope = self.search_scope(&platform); let mut unusable_candidate = None; for executable_name in self.executable_names(&platform) { - match find_interpreter(executable_name, build_prefix, run_prefix, runtime, scope) { + match find_interpreter(executable_name, context, scope) { Some(path) => match self.is_usable_executable(&path) { Ok(()) => return Ok(path), Err(err) => unusable_candidate = Some((path, err)), @@ -300,12 +293,10 @@ impl SelectedInterpreter { /// Resolve the executable, remapping internal errors to the user-facing name. pub(crate) fn resolve_executable( &self, - build_prefix: Option<&Path>, - run_prefix: &Path, - runtime: &RuntimeEnv, + context: &ExecutionContext, ) -> Result { self.invocation - .resolve_executable(build_prefix, run_prefix, runtime) + .resolve_executable(context) .map_err(|err| match err { InterpreterError::InterpreterNotFound(_) => { InterpreterError::InterpreterNotFound(self.user_name.clone()) @@ -323,7 +314,10 @@ impl SelectedInterpreter { #[cfg(test)] mod tests { use super::*; - use crate::execution::{ExecutionArgs, ResolvedScriptContents}; + use crate::{ + ExecutionContext, RuntimeEnv, + execution::{ExecutionArgs, ResolvedScriptContents}, + }; use fs_err as fs; use indexmap::IndexMap; use rattler_conda_types::Platform; @@ -341,15 +335,22 @@ mod tests { interpreter: interpreter.map(str::to_string), env_vars: IndexMap::new(), secrets: IndexMap::new(), - runtime: RuntimeEnv::current(), - build_prefix: None, - run_prefix, + context: ExecutionContext::shared( + RuntimeEnv::current(), + run_prefix, + Platform::current(), + Platform::current(), + ), work_dir, sandbox_config: None, env_isolation: crate::execution::EnvironmentIsolation::None, } } + fn shared_context(runtime: RuntimeEnv, prefix: &Path) -> ExecutionContext { + ExecutionContext::shared(runtime, prefix, Platform::current(), Platform::current()) + } + fn native_build_script_path(work_dir: &Path) -> PathBuf { work_dir.join(if cfg!(windows) { "conda_build.bat" @@ -590,11 +591,7 @@ mod tests { let stub = RejectFirstStub; let resolved = stub - .resolve_executable( - Some(prefix.as_path()), - prefix.as_path(), - &RuntimeEnv::current(), - ) + .resolve_executable(&shared_context(RuntimeEnv::current(), &prefix)) .expect("second candidate should resolve"); assert_eq!(resolved, second); } @@ -613,11 +610,7 @@ mod tests { let stub = RejectFirstStub; let err = stub - .resolve_executable( - Some(prefix.as_path()), - prefix.as_path(), - &RuntimeEnv::current(), - ) + .resolve_executable(&shared_context(RuntimeEnv::current(), &prefix)) .expect_err("only candidate is rejected"); match err { InterpreterError::InvalidInterpreter { interpreter, .. } => { @@ -647,11 +640,7 @@ mod tests { }; let err = selected - .resolve_executable( - Some(prefix.as_path()), - prefix.as_path(), - &RuntimeEnv::current(), - ) + .resolve_executable(&shared_context(RuntimeEnv::current(), &prefix)) .expect_err("only candidate is rejected"); match err { InterpreterError::InvalidInterpreter { @@ -691,18 +680,15 @@ mod tests { let runtime = RuntimeEnv::for_test(Platform::current()) .with_var("PATH", path_dir.to_string_lossy().into_owned()); + let context = shared_context(runtime, &prefix); let found_via_path = find_interpreter( "rb_path_only_tool", - Some(prefix.as_path()), - prefix.as_path(), - &runtime, + &context, InterpreterSearchScope::build_and_host_with_system_fallback(), ); let found_build_only = find_interpreter( "rb_path_only_tool", - Some(prefix.as_path()), - prefix.as_path(), - &runtime, + &context, InterpreterSearchScope::build_only(), ); @@ -765,20 +751,23 @@ mod tests { // Empty runtime PATH so resolution can only come from a prefix. let runtime = RuntimeEnv::for_test(Platform::current()).with_var("PATH", ""); + let context = ExecutionContext::separate( + runtime, + &build_prefix, + Platform::current(), + &host_prefix, + Platform::current(), + ); let found = find_interpreter( "rb_host_tool", - Some(build_prefix.as_path()), - host_prefix.as_path(), - &runtime, + &context, InterpreterSearchScope::build_and_host_with_system_fallback(), ); assert_eq!(found.as_deref(), Some(tool.as_path())); let build_only = find_interpreter( "rb_host_tool", - Some(build_prefix.as_path()), - host_prefix.as_path(), - &runtime, + &context, InterpreterSearchScope::build_only(), ); assert!( @@ -811,11 +800,7 @@ mod tests { let second = create_fake_executable(&prefix, "stub_second"); let resolved = RejectFirstStub - .resolve_executable( - Some(prefix.as_path()), - prefix.as_path(), - &RuntimeEnv::current(), - ) + .resolve_executable(&shared_context(RuntimeEnv::current(), &prefix)) .expect("second candidate should resolve when the first is absent"); assert_eq!(resolved, second); } @@ -834,8 +819,8 @@ mod tests { let runtime = RuntimeEnv::for_test(Platform::Win64) .with_var("COMSPEC", fake_cmd.to_string_lossy().into_owned()); - let resolved = - super::cmd_exe::CmdExeInvocation.resolve_executable(None, tmp.path(), &runtime); + let resolved = super::cmd_exe::CmdExeInvocation + .resolve_executable(&shared_context(runtime, tmp.path())); assert_eq!(resolved.unwrap(), fake_cmd); } diff --git a/crates/rattler_build_script/src/lib.rs b/crates/rattler_build_script/src/lib.rs index 49d117d29..aa53ff070 100644 --- a/crates/rattler_build_script/src/lib.rs +++ b/crates/rattler_build_script/src/lib.rs @@ -22,6 +22,8 @@ mod activation; #[cfg(feature = "execution")] mod execution; #[cfg(feature = "execution")] +mod execution_context; +#[cfg(feature = "execution")] mod interpreter; #[cfg(feature = "execution")] mod native_runner; @@ -33,6 +35,8 @@ pub use execution::{ EnvironmentIsolation, ExecutionArgs, ResolvedScriptContents, create_build_script, }; #[cfg(feature = "execution")] +pub use execution_context::{ExecutionContext, PrefixLayout, PrefixWithPlatform}; +#[cfg(feature = "execution")] pub use interpreter::{InterpreterError, closest_interpreter}; #[cfg(feature = "execution")] pub use runtime::RuntimeEnv; diff --git a/crates/rattler_build_script/src/native_runner/bash.rs b/crates/rattler_build_script/src/native_runner/bash.rs index efe3fd3e9..a192da139 100644 --- a/crates/rattler_build_script/src/native_runner/bash.rs +++ b/crates/rattler_build_script/src/native_runner/bash.rs @@ -4,7 +4,8 @@ use std::path::Path; use indexmap::IndexMap; use rattler_shell::shell::{self, Shell}; -use super::NativeShellRunner; +use super::{CommandSpec, NativeShellRunner}; +use crate::{ExecutionContext, PrefixLayout}; pub(crate) struct BashNativeRunner; @@ -34,8 +35,12 @@ set -x ) } - fn command_to_run_script<'a>(&self, build_script_path: &'a str) -> Vec<&'a str> { - vec!["bash", build_script_path] + fn command_to_run_script( + &self, + build_script_path: &Path, + _context: &ExecutionContext, + ) -> CommandSpec { + CommandSpec::new("bash", [build_script_path.to_string_lossy().into_owned()]) } fn replacements_template(&self) -> &'static str { @@ -71,20 +76,18 @@ set -x } /// Returns reproduction instructions for the failed bash wrapper script. - fn debug_info( - &self, - work_dir: &Path, - run_prefix: &Path, - build_prefix: Option<&Path>, - ) -> String { + fn debug_info(&self, work_dir: &Path, context: &ExecutionContext) -> String { let mut output = String::new(); output.push_str("\nScript execution failed.\n\n"); output.push_str(&format!(" Work directory: {}\n", work_dir.display())); - output.push_str(&format!(" Prefix: {}\n", run_prefix.display())); + output.push_str(&format!(" Prefix: {}\n", context.host().path().display())); - if let Some(build_prefix) = build_prefix { - output.push_str(&format!(" Build prefix: {}\n", build_prefix.display())); + if context.layout() == PrefixLayout::Separate { + output.push_str(&format!( + " Build prefix: {}\n", + context.build().path().display() + )); } else { output.push_str(" Build prefix: None\n"); } diff --git a/crates/rattler_build_script/src/native_runner/cmd_exe.rs b/crates/rattler_build_script/src/native_runner/cmd_exe.rs index a27191254..d387157f8 100644 --- a/crates/rattler_build_script/src/native_runner/cmd_exe.rs +++ b/crates/rattler_build_script/src/native_runner/cmd_exe.rs @@ -4,7 +4,8 @@ use std::path::Path; use indexmap::IndexMap; use rattler_shell::shell::{self, Shell}; -use super::NativeShellRunner; +use super::{CommandSpec, NativeShellRunner, windows_machine_transition}; +use crate::{ExecutionContext, PrefixLayout}; pub(crate) struct CmdExeNativeRunner; @@ -33,8 +34,43 @@ IF "%CONDA_BUILD%" == "" ( ) } - fn command_to_run_script<'a>(&self, build_script_path: &'a str) -> Vec<&'a str> { - vec!["cmd.exe", "/d", "/c", build_script_path] + fn command_to_run_script( + &self, + build_script_path: &Path, + context: &ExecutionContext, + ) -> CommandSpec { + if let Some(machine) = windows_machine_transition( + context.runtime().process_platform(), + context.build().platform(), + ) { + let script_name = build_script_path + .file_name() + .expect("generated build script has a filename") + .to_string_lossy(); + let command = format!( + "start /b /wait /machine {} cmd.exe /d /c {} & exit /b !ERRORLEVEL!", + machine.start_argument(), + script_name, + ); + CommandSpec::new( + "cmd.exe", + [ + "/d".to_string(), + "/v:on".to_string(), + "/c".to_string(), + command, + ], + ) + } else { + CommandSpec::new( + "cmd.exe", + [ + "/d".to_string(), + "/c".to_string(), + build_script_path.to_string_lossy().into_owned(), + ], + ) + } } fn replacements_template(&self) -> &'static str { @@ -75,26 +111,30 @@ IF "%CONDA_BUILD%" == "" ( } /// Returns reproduction instructions for the failed cmd wrapper script. - fn debug_info( - &self, - work_dir: &Path, - run_prefix: &Path, - build_prefix: Option<&Path>, - ) -> String { + fn debug_info(&self, work_dir: &Path, context: &ExecutionContext) -> String { let mut output = String::new(); output.push_str("\nScript execution failed.\n\n"); output.push_str(&format!(" Work directory: {}\n", work_dir.display())); - output.push_str(&format!(" Prefix: {}\n", run_prefix.display())); + output.push_str(&format!(" Prefix: {}\n", context.host().path().display())); - if let Some(build_prefix) = build_prefix { - output.push_str(&format!(" Build prefix: {}\n", build_prefix.display())); + if context.layout() == PrefixLayout::Separate { + output.push_str(&format!( + " Build prefix: {}\n", + context.build().path().display() + )); } else { output.push_str(" Build prefix: None\n"); } + let command = self.command_to_run_script(&work_dir.join("conda_build.bat"), context); output.push_str("\nTo run the script manually, use the following command:\n"); - output.push_str(&format!(" cd {:?} && ./conda_build.bat\n\n", work_dir)); + output.push_str(&format!( + " cd {:?} && {} {}\n\n", + work_dir, + command.program, + command.args.join(" ") + )); output.push_str("To run commands interactively in the build environment:\n"); output.push_str(&format!(" cd {:?} && call build_env.bat", work_dir)); diff --git a/crates/rattler_build_script/src/native_runner/mod.rs b/crates/rattler_build_script/src/native_runner/mod.rs index c9d490cd7..2ee5335e4 100644 --- a/crates/rattler_build_script/src/native_runner/mod.rs +++ b/crates/rattler_build_script/src/native_runner/mod.rs @@ -10,10 +10,68 @@ mod cmd_exe; use std::path::Path; -use indexmap::IndexMap; use rattler_conda_types::Platform; + +use indexmap::IndexMap; use rattler_shell::shell::{Shell, ShellEnum}; +use crate::ExecutionContext; + +/// A process invocation with owned arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandSpec { + pub(crate) program: String, + pub(crate) args: Vec, +} + +impl CommandSpec { + pub(crate) fn new( + program: impl Into, + args: impl IntoIterator>, + ) -> Self { + Self { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + } + } +} + +/// Requested Windows child process architecture for a supported transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowsMachine { + Amd64, + Arm64, +} + +impl WindowsMachine { + pub(crate) fn start_argument(self) -> &'static str { + match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + } + } + + pub(crate) fn processor_architecture(self) -> &'static str { + match self { + Self::Amd64 => "AMD64", + Self::Arm64 => "ARM64", + } + } +} + +/// Returns the required child architecture for the supported Windows x64/ARM64 +/// process transitions. Other platform pairs execute directly. +pub(crate) fn windows_machine_transition( + process_platform: Platform, + build_platform: Platform, +) -> Option { + match (process_platform, build_platform) { + (Platform::Win64, Platform::WinArm64) => Some(WindowsMachine::Arm64), + (Platform::WinArm64, Platform::Win64) => Some(WindowsMachine::Amd64), + _ => None, + } +} + /// Defines platform-native wrapper execution. pub(crate) trait NativeShellRunner: Send + Sync { /// Returns the shell syntax used for the generated native wrapper script. @@ -26,8 +84,12 @@ pub(crate) trait NativeShellRunner: Send + Sync { /// Returns the shell preamble inserted at the top of `conda_build.*`. fn preamble(&self, activation_script_path: &Path) -> String; - /// Returns process argv used to execute the generated native wrapper script. - fn command_to_run_script<'a>(&self, build_script_path: &'a str) -> Vec<&'a str>; + /// Returns the process invocation used to execute the generated native wrapper script. + fn command_to_run_script( + &self, + build_script_path: &Path, + context: &ExecutionContext, + ) -> CommandSpec; /// Returns the replacement template used when streaming process output. fn replacements_template(&self) -> &'static str; @@ -49,8 +111,7 @@ pub(crate) trait NativeShellRunner: Send + Sync { ) -> Result; /// Returns human-readable reproduction instructions shown when execution fails. - fn debug_info(&self, work_dir: &Path, run_prefix: &Path, build_prefix: Option<&Path>) - -> String; + fn debug_info(&self, work_dir: &Path, context: &ExecutionContext) -> String; } /// Selects the native wrapper shell for the given platform: `cmd.exe` on @@ -92,7 +153,8 @@ pub(crate) fn quote_arg(shell: &ShellEnum, arg: &str) -> String { #[cfg(test)] mod tests { - use super::{native_runner, quote_arg}; + use super::{native_runner, quote_arg, windows_machine_transition}; + use crate::{ExecutionContext, RuntimeEnv}; use indexmap::IndexMap; use rattler_conda_types::Platform; use rattler_shell::shell::{self, Shell}; @@ -170,6 +232,49 @@ mod tests { ); } + #[test] + fn cmd_switches_only_between_x64_and_arm64() { + let script = std::path::Path::new("work/conda_build.bat"); + let runner = native_runner(Platform::Win64); + + let x64_to_arm = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::WinArm64, + Platform::WinArm64, + ); + let arm_command = runner.command_to_run_script(script, &x64_to_arm); + assert_eq!(arm_command.program, "cmd.exe"); + assert_eq!(arm_command.args[..3], ["/d", "/v:on", "/c"]); + assert!(arm_command.args[3].contains("/machine arm64")); + assert!(arm_command.args[3].contains("conda_build.bat")); + assert!(arm_command.args[3].contains("exit /b !ERRORLEVEL!")); + + let arm_to_x64 = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::WinArm64), + "prefix", + Platform::Win64, + Platform::Win64, + ); + let x64_command = runner.command_to_run_script(script, &arm_to_x64); + assert!(x64_command.args[3].contains("/machine amd64")); + + let same_arch = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::Win64, + Platform::Win64, + ); + assert_eq!( + runner.command_to_run_script(script, &same_arch).args, + ["/d", "/c", "work/conda_build.bat"] + ); + assert_eq!( + windows_machine_transition(Platform::Win64, Platform::Win32), + None + ); + } + #[test] fn bash_preamble_enables_tracing_after_activation() { let preamble = diff --git a/crates/rattler_build_script/src/runtime.rs b/crates/rattler_build_script/src/runtime.rs index 03eeefc91..84d6e9677 100644 --- a/crates/rattler_build_script/src/runtime.rs +++ b/crates/rattler_build_script/src/runtime.rs @@ -15,7 +15,7 @@ use rattler_conda_types::Platform; #[derive(Debug, Clone)] pub struct RuntimeEnv { env: HashMap, - platform: Platform, + process_platform: Platform, } impl RuntimeEnv { @@ -23,7 +23,7 @@ impl RuntimeEnv { pub fn current() -> Self { Self { env: std::env::vars().collect(), - platform: Platform::current(), + process_platform: Platform::current(), } } @@ -33,13 +33,13 @@ impl RuntimeEnv { pub fn for_test(platform: Platform) -> Self { Self { env: HashMap::new(), - platform, + process_platform: platform, } } - /// The platform rattler-build is running on. - pub fn platform(&self) -> Platform { - self.platform + /// The platform of the rattler-build process. + pub fn process_platform(&self) -> Platform { + self.process_platform } /// Looks up an environment variable by name. @@ -56,7 +56,7 @@ impl RuntimeEnv { /// elsewhere), keyed off the platform rather than the one rattler-build was /// compiled for (unlike [`std::env::consts::EXE_SUFFIX`]). pub(crate) fn exe_suffix(&self) -> &'static str { - if self.platform.is_windows() { + if self.process_platform.is_windows() { ".exe" } else { "" @@ -75,10 +75,10 @@ impl RuntimeEnv { self } - /// Returns a copy that runs on the given platform (builder style, for tests). + /// Returns a copy with the given rattler-build process platform (for tests). #[must_use] - pub fn with_platform(mut self, platform: Platform) -> Self { - self.platform = platform; + pub fn with_process_platform(mut self, platform: Platform) -> Self { + self.process_platform = platform; self } } diff --git a/py-rattler-build/rust/src/render.rs b/py-rattler-build/rust/src/render.rs index efb1e3967..b331f3d6d 100644 --- a/py-rattler-build/rust/src/render.rs +++ b/py-rattler-build/rust/src/render.rs @@ -79,6 +79,7 @@ impl PyRenderConfig { &std::path::PathBuf::new(), &target_platform, &host_platform, + &build_platform, EnvironmentIsolation::default(), &std::path::PathBuf::new(), ) diff --git a/src/lib.rs b/src/lib.rs index 107fe8752..fb3f11322 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -394,6 +394,7 @@ pub async fn get_build_output( &std::path::PathBuf::new(), &build_data.target_platform, &build_data.host_platform, + &build_data.build_platform, build_data.env_isolation, &std::path::PathBuf::new(), ) From 065fd345f3d0882d92e0b7548aef5ff77f97090f Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:03:12 +0200 Subject: [PATCH 02/12] fix(script): clarify Windows architecture execution --- crates/rattler_build_core/src/script.rs | 35 ++++---- crates/rattler_build_script/src/activation.rs | 52 +++++++++++ crates/rattler_build_script/src/execution.rs | 88 +------------------ .../src/execution_context.rs | 13 +++ .../src/native_runner/cmd_exe.rs | 10 +++ .../src/native_runner/mod.rs | 8 ++ 6 files changed, 106 insertions(+), 100 deletions(-) diff --git a/crates/rattler_build_core/src/script.rs b/crates/rattler_build_core/src/script.rs index 7d02d6748..24dbabd2e 100644 --- a/crates/rattler_build_core/src/script.rs +++ b/crates/rattler_build_core/src/script.rs @@ -32,19 +32,6 @@ impl Output { let target_platform = self.build_configuration.target_platform; let host_platform = self.host_platform().platform; let env_isolation = self.build_configuration.env_isolation; - let mut env_vars = env_vars::vars(self, "BUILD"); - env_vars.extend(env_vars::os_vars( - &host_prefix, - &target_platform, - &host_platform, - &self.build_configuration.build_platform.platform, - env_isolation, - &self.build_configuration.directories.work_dir, - )); - env_vars.extend(env_vars::env_vars_from_variant(self.variant())); - - let jinja_renderer = self.jinja_renderer(); - let context = if self.recipe.build().merge_build_and_host_envs { ExecutionContext::shared( RuntimeEnv::current(), @@ -62,6 +49,25 @@ impl Output { ) }; + let mut env_vars = env_vars::vars(self, "BUILD"); + env_vars.extend(env_vars::os_vars( + &host_prefix, + &target_platform, + &host_platform, + &self.build_configuration.build_platform.platform, + env_isolation, + &self.build_configuration.directories.work_dir, + )); + env_vars.extend(env_vars::env_vars_from_variant(self.variant())); + if let Some(architecture) = context.windows_processor_architecture() { + env_vars.insert( + "PROCESSOR_ARCHITECTURE".to_string(), + Some(architecture.to_string()), + ); + env_vars.insert("PROCESSOR_ARCHITEW6432".to_string(), Some(String::new())); + } + + let jinja_renderer = self.jinja_renderer(); let work_dir = &self.build_configuration.directories.work_dir; Ok(ExecutionArgs { interpreter: self.recipe.build().script.interpreter.clone(), @@ -111,8 +117,7 @@ impl Output { Err(err) => return Err(err.into()), } - let mut exec_args = self.prepare_build_script().await?; - exec_args.apply_platform_environment(); + let exec_args = self.prepare_build_script().await?; let context = exec_args.context.clone(); // Create Jinja context with environment variables diff --git a/crates/rattler_build_script/src/activation.rs b/crates/rattler_build_script/src/activation.rs index 28269eb37..d45f41a9f 100644 --- a/crates/rattler_build_script/src/activation.rs +++ b/crates/rattler_build_script/src/activation.rs @@ -23,6 +23,19 @@ pub(crate) fn activation_script( for (k, v) in args.env_vars.iter() { shell_script.set_env_var(k, v)?; } + + // `start /machine` changes the child `cmd.exe` architecture but inherits + // variables that describe the outer process. Set the values in the + // activation script, where the build environment is assembled, rather than + // mutating the execution arguments. `PROCESSOR_IDENTIFIER` intentionally + // remains untouched because it describes the physical processor. + if let Some(architecture) = args.context.windows_processor_architecture() { + shell_script.set_env_var("PROCESSOR_ARCHITECTURE", architecture)?; + // An empty value produces `set "PROCESSOR_ARCHITEW6432="` for cmd.exe, + // clearing this WOW64 compatibility marker from the activated shell. + shell_script.set_env_var("PROCESSOR_ARCHITEW6432", "")?; + } + // Re-entrancy marker: this way the preamble sources this file // once and nested shells skip re-sourcing it. shell_script.set_env_var("CONDA_BUILD", "1")?; @@ -82,6 +95,45 @@ mod tests { use crate::execution::{EnvironmentIsolation, ExecutionArgs, ResolvedScriptContents}; use crate::{ExecutionContext, runtime::RuntimeEnv}; + /// The activation script must set the Windows architecture variables after + /// caller-provided environment values, because `/machine` changes the child + /// process but not its inherited environment. + #[test] + fn architecture_transition_normalizes_processor_environment() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = tmp.path().join("prefix"); + fs_err::create_dir_all(&prefix).unwrap(); + let mut env_vars = IndexMap::new(); + env_vars.insert("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()); + env_vars.insert( + "PROCESSOR_IDENTIFIER".to_string(), + "ARMv8 (64-bit) Family".to_string(), + ); + let args = ExecutionArgs { + script: ResolvedScriptContents::Missing, + interpreter: None, + env_vars, + secrets: IndexMap::new(), + context: ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + &prefix, + Platform::WinArm64, + Platform::WinArm64, + ), + work_dir: tmp.path().to_path_buf(), + sandbox_config: None, + env_isolation: EnvironmentIsolation::Strict, + }; + + let script = super::activation_script(&args, shell::CmdExe).unwrap(); + assert!(script.contains(r#"@SET "PROCESSOR_ARCHITECTURE=ARM64""#)); + assert!(script.contains(r#"@SET "PROCESSOR_ARCHITEW6432=""#)); + assert!( + script.contains(r#"@SET "PROCESSOR_IDENTIFIER=ARMv8 (64-bit) Family""#), + "the host processor identifier must be preserved: {script}" + ); + } + /// When a build prefix is present, both the run prefix and build prefix are /// activated and the generated script references both paths. #[test] diff --git a/crates/rattler_build_script/src/execution.rs b/crates/rattler_build_script/src/execution.rs index 3be556439..1e85c609b 100644 --- a/crates/rattler_build_script/src/execution.rs +++ b/crates/rattler_build_script/src/execution.rs @@ -128,23 +128,6 @@ impl ExecutionArgs { replacements } - - /// Normalizes mutable Windows process architecture variables when the - /// configured build environment deliberately switches between x64 and ARM64. - pub fn apply_platform_environment(&mut self) { - if let Some(machine) = crate::native_runner::windows_machine_transition( - self.context.runtime().process_platform(), - self.context.build().platform(), - ) { - self.env_vars.insert( - "PROCESSOR_ARCHITECTURE".to_string(), - machine.processor_architecture().to_string(), - ); - // `set "VAR="` in build_env.bat removes this compatibility marker. - self.env_vars - .insert("PROCESSOR_ARCHITEW6432".to_string(), String::new()); - } - } } /// The resolved contents of a script. @@ -248,7 +231,7 @@ impl Script { tracing::debug!("Running script in {}", work_dir.display()); - let mut exec_args = ExecutionArgs { + let exec_args = ExecutionArgs { script: contents, interpreter: self.interpreter.clone(), env_vars, @@ -258,7 +241,6 @@ impl Script { sandbox_config: sandbox_config.cloned(), env_isolation, }; - exec_args.apply_platform_environment(); crate::execution::run_script(exec_args).await?; @@ -630,10 +612,7 @@ async fn build_section_body( } /// Runs a script with the given execution arguments. -pub(crate) async fn run_script( - mut exec_args: ExecutionArgs, -) -> Result<(), crate::InterpreterError> { - exec_args.apply_platform_environment(); +pub(crate) async fn run_script(exec_args: ExecutionArgs) -> Result<(), crate::InterpreterError> { let runner = crate::native_runner::native_runner(exec_args.context.runtime().process_platform()); let build_script_path = generate_build_script(&exec_args).await?; @@ -672,8 +651,7 @@ pub(crate) async fn run_script( } /// Creates build script files without executing them. -pub async fn create_build_script(mut exec_args: ExecutionArgs) -> Result<(), std::io::Error> { - exec_args.apply_platform_environment(); +pub async fn create_build_script(exec_args: ExecutionArgs) -> Result<(), std::io::Error> { let build_script_path = generate_build_script(&exec_args) .await .map_err(|err| match err { @@ -965,66 +943,6 @@ mod tests { ); } - #[test] - fn architecture_transition_normalizes_processor_environment() { - let mut args = ExecutionArgs { - script: ResolvedScriptContents::Missing, - interpreter: None, - env_vars: IndexMap::new(), - secrets: IndexMap::new(), - context: ExecutionContext::shared( - RuntimeEnv::for_test(Platform::Win64), - "prefix", - Platform::WinArm64, - Platform::WinArm64, - ), - work_dir: PathBuf::from("work"), - sandbox_config: None, - env_isolation: EnvironmentIsolation::Strict, - }; - args.env_vars.insert( - "PROCESSOR_IDENTIFIER".to_string(), - "ARMv8 (64-bit) Family".to_string(), - ); - args.apply_platform_environment(); - assert_eq!( - args.env_vars.get("PROCESSOR_ARCHITECTURE"), - Some(&"ARM64".to_string()) - ); - assert_eq!( - args.env_vars.get("PROCESSOR_ARCHITEW6432"), - Some(&String::new()) - ); - assert_eq!( - args.env_vars.get("PROCESSOR_IDENTIFIER"), - Some(&"ARMv8 (64-bit) Family".to_string()), - "the host processor identifier is intentionally preserved" - ); - - args.context = ExecutionContext::shared( - RuntimeEnv::for_test(Platform::WinArm64), - "prefix", - Platform::Win64, - Platform::Win64, - ); - args.env_vars.clear(); - args.apply_platform_environment(); - assert_eq!( - args.env_vars.get("PROCESSOR_ARCHITECTURE"), - Some(&"AMD64".to_string()) - ); - - args.context = ExecutionContext::shared( - RuntimeEnv::for_test(Platform::Win64), - "prefix", - Platform::Win64, - Platform::Win64, - ); - args.env_vars.clear(); - args.apply_platform_environment(); - assert!(args.env_vars.is_empty()); - } - /// The outer subprocess must start without `CONDA_BUILD` set, otherwise /// the preamble skips sourcing the activation script. #[test] diff --git a/crates/rattler_build_script/src/execution_context.rs b/crates/rattler_build_script/src/execution_context.rs index 63c6b930c..7de5dfddb 100644 --- a/crates/rattler_build_script/src/execution_context.rs +++ b/crates/rattler_build_script/src/execution_context.rs @@ -110,6 +110,19 @@ impl ExecutionContext { pub fn layout(&self) -> PrefixLayout { self.layout } + + /// The processor architecture to expose in a Windows child process. + /// + /// Returns a value only when the Windows runner must switch between x64 and + /// ARM64 with `start /machine`; otherwise the child inherits its normal + /// process environment. + pub fn windows_processor_architecture(&self) -> Option<&'static str> { + crate::native_runner::windows_machine_transition( + self.runtime.process_platform(), + self.build.platform(), + ) + .map(crate::native_runner::WindowsMachine::processor_architecture) + } } #[cfg(test)] diff --git a/crates/rattler_build_script/src/native_runner/cmd_exe.rs b/crates/rattler_build_script/src/native_runner/cmd_exe.rs index d387157f8..bd35eb979 100644 --- a/crates/rattler_build_script/src/native_runner/cmd_exe.rs +++ b/crates/rattler_build_script/src/native_runner/cmd_exe.rs @@ -43,10 +43,20 @@ IF "%CONDA_BUILD%" == "" ( context.runtime().process_platform(), context.build().platform(), ) { + // `start /machine` selects the architecture of the child `cmd.exe`. + // It normally returns immediately, so `/wait` is required to obtain + // the script's status. `cmd /c` otherwise returns the status of the + // `start` command itself, hence the explicit delayed `ERRORLEVEL` + // expansion and `exit /b` after the child finishes. + // + // The outer process runs in `work_dir`, so only the generated file + // name is needed. Quote it when necessary so a changed or reused + // filename containing whitespace remains a single argument. let script_name = build_script_path .file_name() .expect("generated build script has a filename") .to_string_lossy(); + let script_name = crate::native_runner::quote_arg(&self.shell(), &script_name); let command = format!( "start /b /wait /machine {} cmd.exe /d /c {} & exit /b !ERRORLEVEL!", machine.start_argument(), diff --git a/crates/rattler_build_script/src/native_runner/mod.rs b/crates/rattler_build_script/src/native_runner/mod.rs index 2ee5335e4..4cbeedddd 100644 --- a/crates/rattler_build_script/src/native_runner/mod.rs +++ b/crates/rattler_build_script/src/native_runner/mod.rs @@ -250,6 +250,14 @@ mod tests { assert!(arm_command.args[3].contains("conda_build.bat")); assert!(arm_command.args[3].contains("exit /b !ERRORLEVEL!")); + let spaced_script = std::path::Path::new("work/conda build.bat"); + assert!( + runner + .command_to_run_script(spaced_script, &x64_to_arm) + .args[3] + .contains(r#"cmd.exe /d /c "conda build.bat""#) + ); + let arm_to_x64 = ExecutionContext::shared( RuntimeEnv::for_test(Platform::WinArm64), "prefix", From c4d118b70b1a2a304f93a4f49a1f26ec7df4eae5 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:19:07 +0200 Subject: [PATCH 03/12] test(ci): add Windows ARM architecture experiment --- .../windows-architecture-experiment.yml | 63 +++++++++++++++++++ .../windows-architecture-execution/build.bat | 10 +++ .../recipe.yaml | 6 ++ .../verify-arm64.ps1 | 34 ++++++++++ 4 files changed, 113 insertions(+) create mode 100644 .github/workflows/windows-architecture-experiment.yml create mode 100644 test-data/recipes/windows-architecture-execution/build.bat create mode 100644 test-data/recipes/windows-architecture-execution/recipe.yaml create mode 100644 test-data/recipes/windows-architecture-execution/verify-arm64.ps1 diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml new file mode 100644 index 000000000..233955a4f --- /dev/null +++ b/.github/workflows/windows-architecture-experiment.yml @@ -0,0 +1,63 @@ +name: Windows architecture experiment + +# Temporary manual validation for x64 rattler-build on Windows ARM. Remove this +# workflow after it has run successfully on a Windows ARM runner. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-x64: + name: Build x64 rattler-build + runs-on: windows-2022 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + persist-credentials: false + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 + with: + cache: true + - name: Build release binary + run: pixi run build-release + - name: Upload x64 binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rattler-build-x64 + path: target/release/rattler-build.exe + if-no-files-found: error + + run-on-arm64: + name: Run x64 build on Windows ARM + needs: build-x64 + runs-on: windows-11-arm + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Download x64 binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rattler-build-x64 + path: bin + - name: Verify ARM child process and forwarded failure status + shell: pwsh + run: | + $output = & "$env:GITHUB_WORKSPACE\bin\rattler-build.exe" build ` + --recipe test-data/recipes/windows-architecture-execution ` + --build-platform win-arm64 ` + --host-platform win-arm64 ` + --target-platform win-arm64 ` + --output-dir output 2>&1 + $rattlerBuildExitCode = $LASTEXITCODE + $outputText = $output | Out-String + Write-Host $outputText + + if ($rattlerBuildExitCode -eq 0) { + throw "Expected the recipe's intentional exit code 37 to fail the build." + } + if ($outputText -notmatch "Script failed with status 37") { + throw "Expected rattler-build to report the child exit code 37." + } diff --git a/test-data/recipes/windows-architecture-execution/build.bat b/test-data/recipes/windows-architecture-execution/build.bat new file mode 100644 index 000000000..864ddaa51 --- /dev/null +++ b/test-data/recipes/windows-architecture-execution/build.bat @@ -0,0 +1,10 @@ +@echo off + +if /I not "%PROCESSOR_ARCHITECTURE%" == "ARM64" exit /b 64 +if not "%PROCESSOR_ARCHITEW6432%" == "" exit /b 65 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0verify-arm64.ps1" +if errorlevel 1 exit /b %errorlevel% + +rem This deliberate failure verifies that the outer cmd.exe forwards child status. +exit /b 37 diff --git a/test-data/recipes/windows-architecture-execution/recipe.yaml b/test-data/recipes/windows-architecture-execution/recipe.yaml new file mode 100644 index 000000000..a22534ffc --- /dev/null +++ b/test-data/recipes/windows-architecture-execution/recipe.yaml @@ -0,0 +1,6 @@ +package: + name: windows-architecture-execution + version: 0.1.0 + +build: + script: build.bat diff --git a/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 b/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 new file mode 100644 index 000000000..cefbc1770 --- /dev/null +++ b/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 @@ -0,0 +1,34 @@ +Add-Type @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class NativeMethods +{ + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool IsWow64Process2( + IntPtr process, + out ushort processMachine, + out ushort nativeMachine); +} +'@ + +[uint16] $processMachine = 0 +[uint16] $nativeMachine = 0 +if (-not [NativeMethods]::IsWow64Process2( + [NativeMethods]::GetCurrentProcess(), + [ref] $processMachine, + [ref] $nativeMachine +)) { + throw [Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) +} + +# IMAGE_FILE_MACHINE_UNKNOWN means the process is native. IMAGE_FILE_MACHINE_ARM64 +# is 0xAA64. Checking these values proves the child architecture independently +# from PROCESSOR_ARCHITECTURE. +if ($processMachine -ne 0 -or $nativeMachine -ne 0xAA64) { + throw "Expected a native ARM64 process, got process machine 0x{0:X4} and native machine 0x{1:X4}." -f $processMachine, $nativeMachine +} From 433452e8b5ceb6d31e3c6003112bd1541c96ca12 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:19:53 +0200 Subject: [PATCH 04/12] ci: run Windows architecture experiment on PRs --- .github/workflows/windows-architecture-experiment.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml index 233955a4f..0d6214eae 100644 --- a/.github/workflows/windows-architecture-experiment.yml +++ b/.github/workflows/windows-architecture-experiment.yml @@ -1,8 +1,12 @@ name: Windows architecture experiment -# Temporary manual validation for x64 rattler-build on Windows ARM. Remove this +# Temporary validation for x64 rattler-build on Windows ARM. Remove this # workflow after it has run successfully on a Windows ARM runner. on: + pull_request: + paths: + - ".github/workflows/windows-architecture-experiment.yml" + - "test-data/recipes/windows-architecture-execution/**" workflow_dispatch: permissions: From cc440fc23551f8758cc4bd4e3014dd9d3bb763ae Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:31:47 +0200 Subject: [PATCH 05/12] fix(script): use runtime platform in interpreter tests --- crates/rattler_build_script/src/interpreter/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/rattler_build_script/src/interpreter/mod.rs b/crates/rattler_build_script/src/interpreter/mod.rs index cc837a4d4..d5a87113f 100644 --- a/crates/rattler_build_script/src/interpreter/mod.rs +++ b/crates/rattler_build_script/src/interpreter/mod.rs @@ -348,7 +348,8 @@ mod tests { } fn shared_context(runtime: RuntimeEnv, prefix: &Path) -> ExecutionContext { - ExecutionContext::shared(runtime, prefix, Platform::current(), Platform::current()) + let platform = runtime.process_platform(); + ExecutionContext::shared(runtime, prefix, platform, platform) } fn native_build_script_path(work_dir: &Path) -> PathBuf { From 90fb3e7c0c40a4f000cfe19ab093d2a6b6f6433d Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:51:16 +0200 Subject: [PATCH 06/12] test(ci): mirror conda-build ARM architecture checks --- .../windows-architecture-experiment.yml | 6 +++ .../windows-architecture-execution/build.bat | 1 + .../verify-arm64.ps1 | 38 +++---------------- 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml index 0d6214eae..5677ef25b 100644 --- a/.github/workflows/windows-architecture-experiment.yml +++ b/.github/workflows/windows-architecture-experiment.yml @@ -62,6 +62,12 @@ jobs: if ($rattlerBuildExitCode -eq 0) { throw "Expected the recipe's intentional exit code 37 to fail the build." } + if ($outputText -notmatch "PROCESSOR_ARCHITECTURE=ARM64") { + throw "Expected the activated environment to report ARM64." + } + if ($outputText -notmatch "ProcessArchitecture=ARM64") { + throw "Expected the launched PowerShell process to be ARM64." + } if ($outputText -notmatch "Script failed with status 37") { throw "Expected rattler-build to report the child exit code 37." } diff --git a/test-data/recipes/windows-architecture-execution/build.bat b/test-data/recipes/windows-architecture-execution/build.bat index 864ddaa51..fc6b4bf07 100644 --- a/test-data/recipes/windows-architecture-execution/build.bat +++ b/test-data/recipes/windows-architecture-execution/build.bat @@ -1,5 +1,6 @@ @echo off +echo PROCESSOR_ARCHITECTURE=%PROCESSOR_ARCHITECTURE% if /I not "%PROCESSOR_ARCHITECTURE%" == "ARM64" exit /b 64 if not "%PROCESSOR_ARCHITEW6432%" == "" exit /b 65 diff --git a/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 b/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 index cefbc1770..f2aa8ee78 100644 --- a/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 +++ b/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 @@ -1,34 +1,8 @@ -Add-Type @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture +Write-Output "ProcessArchitecture=$($architecture.ToString().ToUpperInvariant())" -public static class NativeMethods -{ - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetCurrentProcess(); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool IsWow64Process2( - IntPtr process, - out ushort processMachine, - out ushort nativeMachine); -} -'@ - -[uint16] $processMachine = 0 -[uint16] $nativeMachine = 0 -if (-not [NativeMethods]::IsWow64Process2( - [NativeMethods]::GetCurrentProcess(), - [ref] $processMachine, - [ref] $nativeMachine -)) { - throw [Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) -} - -# IMAGE_FILE_MACHINE_UNKNOWN means the process is native. IMAGE_FILE_MACHINE_ARM64 -# is 0xAA64. Checking these values proves the child architecture independently -# from PROCESSOR_ARCHITECTURE. -if ($processMachine -ne 0 -or $nativeMachine -ne 0xAA64) { - throw "Expected a native ARM64 process, got process machine 0x{0:X4} and native machine 0x{1:X4}." -f $processMachine, $nativeMachine +# This reports the architecture of this PowerShell process, independently from +# PROCESSOR_ARCHITECTURE. It follows conda-build's Windows ARM integration test. +if ($architecture -ne [System.Runtime.InteropServices.Architecture]::Arm64) { + throw "Expected an ARM64 process, got $architecture." } From 45af20f68c244cafa698b82b9cecd4a0c18e0a17 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:20:09 +0200 Subject: [PATCH 07/12] feat(script): support Windows x86 build scripts --- .../windows-architecture-experiment.yml | 66 +++++++---- Cargo.lock | 1 + crates/rattler_build_core/src/script.rs | 7 +- crates/rattler_build_script/Cargo.toml | 6 + crates/rattler_build_script/src/activation.rs | 6 +- .../src/execution_context.rs | 28 ++++- .../src/native_runner/cmd_exe.rs | 14 ++- .../src/native_runner/mod.rs | 107 +++++++++++++++++- .../build.bat | 12 ++ .../recipe.yaml | 6 + .../windows-architecture-execution/build.bat | 3 +- .../verify-arm64.ps1 | 8 -- 12 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 test-data/recipes/windows-architecture-execution-x86/build.bat create mode 100644 test-data/recipes/windows-architecture-execution-x86/recipe.yaml delete mode 100644 test-data/recipes/windows-architecture-execution/verify-arm64.ps1 diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml index 5677ef25b..1038c2e42 100644 --- a/.github/workflows/windows-architecture-experiment.yml +++ b/.github/workflows/windows-architecture-experiment.yml @@ -7,6 +7,7 @@ on: paths: - ".github/workflows/windows-architecture-experiment.yml" - "test-data/recipes/windows-architecture-execution/**" + - "test-data/recipes/windows-architecture-execution-x86/**" workflow_dispatch: permissions: @@ -46,28 +47,51 @@ jobs: with: name: rattler-build-x64 path: bin - - name: Verify ARM child process and forwarded failure status + - name: Verify switched child processes and forwarded failure status shell: pwsh run: | - $output = & "$env:GITHUB_WORKSPACE\bin\rattler-build.exe" build ` - --recipe test-data/recipes/windows-architecture-execution ` - --build-platform win-arm64 ` - --host-platform win-arm64 ` - --target-platform win-arm64 ` - --output-dir output 2>&1 - $rattlerBuildExitCode = $LASTEXITCODE - $outputText = $output | Out-String - Write-Host $outputText + function Assert-CrossArchitecture { + param( + [string] $Recipe, + [string] $Platform, + [string] $ExpectedArchitecture, + [string] $ExpectedWow64Architecture + ) - if ($rattlerBuildExitCode -eq 0) { - throw "Expected the recipe's intentional exit code 37 to fail the build." - } - if ($outputText -notmatch "PROCESSOR_ARCHITECTURE=ARM64") { - throw "Expected the activated environment to report ARM64." - } - if ($outputText -notmatch "ProcessArchitecture=ARM64") { - throw "Expected the launched PowerShell process to be ARM64." - } - if ($outputText -notmatch "Script failed with status 37") { - throw "Expected rattler-build to report the child exit code 37." + $output = & "$env:GITHUB_WORKSPACE\bin\rattler-build.exe" build ` + --recipe $Recipe ` + --build-platform $Platform ` + --host-platform $Platform ` + --target-platform $Platform ` + --output-dir "output-$Platform" 2>&1 + $rattlerBuildExitCode = $LASTEXITCODE + $outputText = $output | Out-String + Write-Host $outputText + + if ($rattlerBuildExitCode -eq 0) { + throw "Expected the recipe's intentional exit code 37 to fail the build." + } + if ($outputText -notmatch "PROCESSOR_ARCHITECTURE=$ExpectedArchitecture") { + throw "Expected the activated environment to report $ExpectedArchitecture." + } + if ($outputText -notmatch "PROCESSOR_ARCHITEW6432=$ExpectedWow64Architecture") { + throw "Expected the activated environment to report PROCESSOR_ARCHITEW6432=$ExpectedWow64Architecture." + } + if ($outputText -notmatch "ProcessArchitecture=$ExpectedArchitecture") { + throw "Expected the launched PowerShell process to be $ExpectedArchitecture." + } + if ($outputText -notmatch "Script failed with status 37") { + throw "Expected rattler-build to report the child exit code 37." + } } + + Assert-CrossArchitecture ` + -Recipe test-data/recipes/windows-architecture-execution ` + -Platform win-arm64 ` + -ExpectedArchitecture ARM64 ` + -ExpectedWow64Architecture "" + Assert-CrossArchitecture ` + -Recipe test-data/recipes/windows-architecture-execution-x86 ` + -Platform win-32 ` + -ExpectedArchitecture x86 ` + -ExpectedWow64Architecture ARM64 diff --git a/Cargo.lock b/Cargo.lock index 3a6604425..f7e679d5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5125,6 +5125,7 @@ dependencies = [ "tokio-util", "tracing", "which", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/rattler_build_core/src/script.rs b/crates/rattler_build_core/src/script.rs index 24dbabd2e..6e75c15ef 100644 --- a/crates/rattler_build_core/src/script.rs +++ b/crates/rattler_build_core/src/script.rs @@ -64,7 +64,12 @@ impl Output { "PROCESSOR_ARCHITECTURE".to_string(), Some(architecture.to_string()), ); - env_vars.insert("PROCESSOR_ARCHITEW6432".to_string(), Some(String::new())); + } + if let Some(wow64_architecture) = context.windows_processor_architecture_w6432() { + env_vars.insert( + "PROCESSOR_ARCHITEW6432".to_string(), + Some(wow64_architecture.unwrap_or_default().to_string()), + ); } let jinja_renderer = self.jinja_renderer(); diff --git a/crates/rattler_build_script/Cargo.toml b/crates/rattler_build_script/Cargo.toml index b6114c681..f781b17ae 100644 --- a/crates/rattler_build_script/Cargo.toml +++ b/crates/rattler_build_script/Cargo.toml @@ -52,6 +52,12 @@ which = { workspace = true, optional = true } minijinja = { workspace = true, optional = true } strsim = { workspace = true, optional = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_System_SystemInformation", + "Win32_System_Threading", +] } + [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros", "fs"] } insta = { workspace = true, features = ["yaml"] } diff --git a/crates/rattler_build_script/src/activation.rs b/crates/rattler_build_script/src/activation.rs index d45f41a9f..7fe924d40 100644 --- a/crates/rattler_build_script/src/activation.rs +++ b/crates/rattler_build_script/src/activation.rs @@ -31,9 +31,11 @@ pub(crate) fn activation_script( // remains untouched because it describes the physical processor. if let Some(architecture) = args.context.windows_processor_architecture() { shell_script.set_env_var("PROCESSOR_ARCHITECTURE", architecture)?; + } + if let Some(wow64_architecture) = args.context.windows_processor_architecture_w6432() { // An empty value produces `set "PROCESSOR_ARCHITEW6432="` for cmd.exe, - // clearing this WOW64 compatibility marker from the activated shell. - shell_script.set_env_var("PROCESSOR_ARCHITEW6432", "")?; + // clearing the marker for a 64-bit child or native x86 process. + shell_script.set_env_var("PROCESSOR_ARCHITEW6432", wow64_architecture.unwrap_or(""))?; } // Re-entrancy marker: this way the preamble sources this file diff --git a/crates/rattler_build_script/src/execution_context.rs b/crates/rattler_build_script/src/execution_context.rs index 7de5dfddb..12003f1d5 100644 --- a/crates/rattler_build_script/src/execution_context.rs +++ b/crates/rattler_build_script/src/execution_context.rs @@ -113,9 +113,9 @@ impl ExecutionContext { /// The processor architecture to expose in a Windows child process. /// - /// Returns a value only when the Windows runner must switch between x64 and - /// ARM64 with `start /machine`; otherwise the child inherits its normal - /// process environment. + /// Returns a value only when the Windows runner must switch to x86 or + /// between x64 and ARM64 with `start /machine`; otherwise the child + /// inherits its normal process environment. pub fn windows_processor_architecture(&self) -> Option<&'static str> { crate::native_runner::windows_machine_transition( self.runtime.process_platform(), @@ -123,6 +123,28 @@ impl ExecutionContext { ) .map(crate::native_runner::WindowsMachine::processor_architecture) } + + /// The value to set for `PROCESSOR_ARCHITEW6432` in a switched child. + /// + /// `Some(None)` clears the marker for a 64-bit child. `Some(value)` sets + /// the detected native architecture for an x86 WOW64 child. `None` keeps + /// the inherited value when no transition is needed or native detection is + /// unavailable. + pub fn windows_processor_architecture_w6432(&self) -> Option> { + let machine = crate::native_runner::windows_machine_transition( + self.runtime.process_platform(), + self.build.platform(), + )?; + + match machine { + crate::native_runner::WindowsMachine::X86 => { + crate::native_runner::native_windows_machine() + .map(crate::native_runner::WindowsMachine::wow64_processor_architecture) + } + crate::native_runner::WindowsMachine::Amd64 + | crate::native_runner::WindowsMachine::Arm64 => Some(None), + } + } } #[cfg(test)] diff --git a/crates/rattler_build_script/src/native_runner/cmd_exe.rs b/crates/rattler_build_script/src/native_runner/cmd_exe.rs index bd35eb979..fbb41f7fd 100644 --- a/crates/rattler_build_script/src/native_runner/cmd_exe.rs +++ b/crates/rattler_build_script/src/native_runner/cmd_exe.rs @@ -57,9 +57,21 @@ IF "%CONDA_BUILD%" == "" ( .expect("generated build script has a filename") .to_string_lossy(); let script_name = crate::native_runner::quote_arg(&self.shell(), &script_name); + // `/machine x86` does not redirect an explicit `cmd.exe` lookup + // from System32. Launch the x86 command interpreter from SysWOW64 + // directly. SystemRoot is conventionally an unspaced system path, + // so keep it unquoted to avoid `start` treating it as a title. The + // other architectures use `cmd.exe`, whose image selection is + // handled by `/machine`. + let child_cmd = match machine { + crate::native_runner::WindowsMachine::X86 => r"%SystemRoot%\SysWOW64\cmd.exe", + crate::native_runner::WindowsMachine::Amd64 + | crate::native_runner::WindowsMachine::Arm64 => "cmd.exe", + }; let command = format!( - "start /b /wait /machine {} cmd.exe /d /c {} & exit /b !ERRORLEVEL!", + "start /b /wait /machine {} {} /d /c {} & exit /b !ERRORLEVEL!", machine.start_argument(), + child_cmd, script_name, ); CommandSpec::new( diff --git a/crates/rattler_build_script/src/native_runner/mod.rs b/crates/rattler_build_script/src/native_runner/mod.rs index 4cbeedddd..7697f870b 100644 --- a/crates/rattler_build_script/src/native_runner/mod.rs +++ b/crates/rattler_build_script/src/native_runner/mod.rs @@ -39,6 +39,7 @@ impl CommandSpec { /// Requested Windows child process architecture for a supported transition. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum WindowsMachine { + X86, Amd64, Arm64, } @@ -46,6 +47,7 @@ pub(crate) enum WindowsMachine { impl WindowsMachine { pub(crate) fn start_argument(self) -> &'static str { match self { + Self::X86 => "x86", Self::Amd64 => "amd64", Self::Arm64 => "arm64", } @@ -53,25 +55,83 @@ impl WindowsMachine { pub(crate) fn processor_architecture(self) -> &'static str { match self { + Self::X86 => "x86", Self::Amd64 => "AMD64", Self::Arm64 => "ARM64", } } + + /// The `PROCESSOR_ARCHITEW6432` marker Windows exposes to an x86 child. + pub(crate) fn wow64_processor_architecture(self) -> Option<&'static str> { + (self != Self::X86).then(|| self.processor_architecture()) + } + + #[cfg(windows)] + fn from_image_file_machine(machine: u16) -> Option { + use windows_sys::Win32::System::SystemInformation::{ + IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_I386, + }; + + match machine { + IMAGE_FILE_MACHINE_I386 => Some(Self::X86), + IMAGE_FILE_MACHINE_AMD64 => Some(Self::Amd64), + IMAGE_FILE_MACHINE_ARM64 => Some(Self::Arm64), + _ => None, + } + } } -/// Returns the required child architecture for the supported Windows x64/ARM64 -/// process transitions. Other platform pairs execute directly. +/// Returns the requested child architecture when a supported Windows build +/// process transition is needed. Rattler-build ships x64 and ARM64 binaries, +/// and both can launch x86 build tools; x86 rattler-build processes are not +/// supported as cross-architecture launchers. pub(crate) fn windows_machine_transition( process_platform: Platform, build_platform: Platform, ) -> Option { match (process_platform, build_platform) { + (Platform::Win64, Platform::Win32) | (Platform::WinArm64, Platform::Win32) => { + Some(WindowsMachine::X86) + } (Platform::Win64, Platform::WinArm64) => Some(WindowsMachine::Arm64), (Platform::WinArm64, Platform::Win64) => Some(WindowsMachine::Amd64), _ => None, } } +/// Detects the native Windows machine architecture without affecting launch +/// selection. This is only used to reproduce the `PROCESSOR_ARCHITEW6432` +/// value that Windows exposes to x86 WOW64 processes. +#[cfg(windows)] +pub(crate) fn native_windows_machine() -> Option { + use windows_sys::Win32::System::{ + SystemInformation::IMAGE_FILE_MACHINE_UNKNOWN, + Threading::{GetCurrentProcess, IsWow64Process2}, + }; + + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + // `IsWow64Process2` is available on every Windows version that supports + // `start /machine`. A failure leaves the inherited WOW64 marker untouched. + if unsafe { + IsWow64Process2( + GetCurrentProcess(), + &mut process_machine, + &mut native_machine, + ) + } == 0 + { + return None; + } + + WindowsMachine::from_image_file_machine(native_machine) +} + +#[cfg(not(windows))] +pub(crate) fn native_windows_machine() -> Option { + None +} + /// Defines platform-native wrapper execution. pub(crate) trait NativeShellRunner: Send + Sync { /// Returns the shell syntax used for the generated native wrapper script. @@ -153,7 +213,7 @@ pub(crate) fn quote_arg(shell: &ShellEnum, arg: &str) -> String { #[cfg(test)] mod tests { - use super::{native_runner, quote_arg, windows_machine_transition}; + use super::{WindowsMachine, native_runner, quote_arg, windows_machine_transition}; use crate::{ExecutionContext, RuntimeEnv}; use indexmap::IndexMap; use rattler_conda_types::Platform; @@ -233,7 +293,7 @@ mod tests { } #[test] - fn cmd_switches_only_between_x64_and_arm64() { + fn cmd_switches_between_supported_windows_architectures() { let script = std::path::Path::new("work/conda_build.bat"); let runner = native_runner(Platform::Win64); @@ -267,6 +327,20 @@ mod tests { let x64_command = runner.command_to_run_script(script, &arm_to_x64); assert!(x64_command.args[3].contains("/machine amd64")); + let x64_to_x86 = ExecutionContext::shared( + RuntimeEnv::for_test(Platform::Win64), + "prefix", + Platform::Win32, + Platform::Win32, + ); + let x86_command = runner.command_to_run_script(script, &x64_to_x86); + assert!(x86_command.args[3].contains("/machine x86")); + assert!( + x86_command.args[3].contains(r"%SystemRoot%\SysWOW64\cmd.exe"), + "x86 must launch the SysWOW64 command interpreter: {}", + x86_command.args[3] + ); + let same_arch = ExecutionContext::shared( RuntimeEnv::for_test(Platform::Win64), "prefix", @@ -279,6 +353,31 @@ mod tests { ); assert_eq!( windows_machine_transition(Platform::Win64, Platform::Win32), + Some(WindowsMachine::X86) + ); + assert_eq!( + windows_machine_transition(Platform::Win32, Platform::Win64), + None + ); + assert_eq!( + windows_machine_transition(Platform::Win32, Platform::WinArm64), + None + ); + assert_eq!(WindowsMachine::X86.wow64_processor_architecture(), None); + assert_eq!( + WindowsMachine::Amd64.wow64_processor_architecture(), + Some("AMD64") + ); + assert_eq!( + WindowsMachine::Arm64.wow64_processor_architecture(), + Some("ARM64") + ); + assert_eq!( + windows_machine_transition(Platform::Win32, Platform::Win32), + None + ); + assert_eq!( + windows_machine_transition(Platform::Linux64, Platform::Win32), None ); } diff --git a/test-data/recipes/windows-architecture-execution-x86/build.bat b/test-data/recipes/windows-architecture-execution-x86/build.bat new file mode 100644 index 000000000..4b42a2882 --- /dev/null +++ b/test-data/recipes/windows-architecture-execution-x86/build.bat @@ -0,0 +1,12 @@ +@echo off + +echo PROCESSOR_ARCHITECTURE=%PROCESSOR_ARCHITECTURE% +echo PROCESSOR_ARCHITEW6432=%PROCESSOR_ARCHITEW6432% +if /I not "%PROCESSOR_ARCHITECTURE%" == "x86" exit /b 64 +if "%PROCESSOR_ARCHITEW6432%" == "" exit /b 65 + +powershell.exe -NoProfile -Command "$a=[System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture; echo ProcessArchitecture=$a" +if errorlevel 1 exit /b %errorlevel% + +rem This deliberate failure verifies that the outer cmd.exe forwards child status. +exit /b 37 diff --git a/test-data/recipes/windows-architecture-execution-x86/recipe.yaml b/test-data/recipes/windows-architecture-execution-x86/recipe.yaml new file mode 100644 index 000000000..c933e913a --- /dev/null +++ b/test-data/recipes/windows-architecture-execution-x86/recipe.yaml @@ -0,0 +1,6 @@ +package: + name: windows-architecture-execution-x86 + version: 0.1.0 + +build: + script: build.bat diff --git a/test-data/recipes/windows-architecture-execution/build.bat b/test-data/recipes/windows-architecture-execution/build.bat index fc6b4bf07..8c550d56e 100644 --- a/test-data/recipes/windows-architecture-execution/build.bat +++ b/test-data/recipes/windows-architecture-execution/build.bat @@ -1,10 +1,11 @@ @echo off echo PROCESSOR_ARCHITECTURE=%PROCESSOR_ARCHITECTURE% +echo PROCESSOR_ARCHITEW6432=%PROCESSOR_ARCHITEW6432% if /I not "%PROCESSOR_ARCHITECTURE%" == "ARM64" exit /b 64 if not "%PROCESSOR_ARCHITEW6432%" == "" exit /b 65 -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0verify-arm64.ps1" +powershell.exe -NoProfile -Command "$a=[System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture; echo ProcessArchitecture=$a" if errorlevel 1 exit /b %errorlevel% rem This deliberate failure verifies that the outer cmd.exe forwards child status. diff --git a/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 b/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 deleted file mode 100644 index f2aa8ee78..000000000 --- a/test-data/recipes/windows-architecture-execution/verify-arm64.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -Write-Output "ProcessArchitecture=$($architecture.ToString().ToUpperInvariant())" - -# This reports the architecture of this PowerShell process, independently from -# PROCESSOR_ARCHITECTURE. It follows conda-build's Windows ARM integration test. -if ($architecture -ne [System.Runtime.InteropServices.Architecture]::Arm64) { - throw "Expected an ARM64 process, got $architecture." -} From 713d083fb05063f0e7a6412cdb0a2b0493c49ced Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:21:58 +0200 Subject: [PATCH 08/12] fix(ci): stage architecture test binary --- .../workflows/windows-architecture-experiment.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml index 1038c2e42..1fb252fa0 100644 --- a/.github/workflows/windows-architecture-experiment.yml +++ b/.github/workflows/windows-architecture-experiment.yml @@ -27,11 +27,22 @@ jobs: cache: true - name: Build release binary run: pixi run build-release + - name: Stage x64 binary + shell: pwsh + run: | + $binary = Get-ChildItem target-pixi -Recurse -Filter rattler-build.exe | + Where-Object { $_.DirectoryName -match "\\release$" } | + Select-Object -First 1 + if ($null -eq $binary) { + throw "Could not find the x64 release binary." + } + New-Item -ItemType Directory -Path staging | Out-Null + Copy-Item $binary.FullName staging/rattler-build.exe - name: Upload x64 binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rattler-build-x64 - path: target/release/rattler-build.exe + path: staging/rattler-build.exe if-no-files-found: error run-on-arm64: From abef8c28e79c49992eb758dab5d79d93945b3d09 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:45:27 +0200 Subject: [PATCH 09/12] fix(ci): preserve expected architecture test status --- .github/workflows/windows-architecture-experiment.yml | 4 ++++ py-rattler-build/rust/Cargo.lock | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml index 1fb252fa0..9da7a57a5 100644 --- a/.github/workflows/windows-architecture-experiment.yml +++ b/.github/workflows/windows-architecture-experiment.yml @@ -106,3 +106,7 @@ jobs: -Platform win-32 ` -ExpectedArchitecture x86 ` -ExpectedWow64Architecture ARM64 + + # The expected rattler-build failures leave LASTEXITCODE at 1. + # The assertions above already validated their reported status. + exit 0 diff --git a/py-rattler-build/rust/Cargo.lock b/py-rattler-build/rust/Cargo.lock index 9aa387793..236070480 100644 --- a/py-rattler-build/rust/Cargo.lock +++ b/py-rattler-build/rust/Cargo.lock @@ -4957,6 +4957,7 @@ dependencies = [ "tokio-util", "tracing", "which", + "windows-sys 0.61.2", ] [[package]] From 81681ad4f706b9c5fbb53b51bd98166b54204d91 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:12:08 +0200 Subject: [PATCH 10/12] chore: remove Windows architecture experiment [no ci] --- .../windows-architecture-experiment.yml | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 .github/workflows/windows-architecture-experiment.yml diff --git a/.github/workflows/windows-architecture-experiment.yml b/.github/workflows/windows-architecture-experiment.yml deleted file mode 100644 index 9da7a57a5..000000000 --- a/.github/workflows/windows-architecture-experiment.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Windows architecture experiment - -# Temporary validation for x64 rattler-build on Windows ARM. Remove this -# workflow after it has run successfully on a Windows ARM runner. -on: - pull_request: - paths: - - ".github/workflows/windows-architecture-experiment.yml" - - "test-data/recipes/windows-architecture-execution/**" - - "test-data/recipes/windows-architecture-execution-x86/**" - workflow_dispatch: - -permissions: - contents: read - -jobs: - build-x64: - name: Build x64 rattler-build - runs-on: windows-2022 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - submodules: recursive - persist-credentials: false - - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 - with: - cache: true - - name: Build release binary - run: pixi run build-release - - name: Stage x64 binary - shell: pwsh - run: | - $binary = Get-ChildItem target-pixi -Recurse -Filter rattler-build.exe | - Where-Object { $_.DirectoryName -match "\\release$" } | - Select-Object -First 1 - if ($null -eq $binary) { - throw "Could not find the x64 release binary." - } - New-Item -ItemType Directory -Path staging | Out-Null - Copy-Item $binary.FullName staging/rattler-build.exe - - name: Upload x64 binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: rattler-build-x64 - path: staging/rattler-build.exe - if-no-files-found: error - - run-on-arm64: - name: Run x64 build on Windows ARM - needs: build-x64 - runs-on: windows-11-arm - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Download x64 binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: rattler-build-x64 - path: bin - - name: Verify switched child processes and forwarded failure status - shell: pwsh - run: | - function Assert-CrossArchitecture { - param( - [string] $Recipe, - [string] $Platform, - [string] $ExpectedArchitecture, - [string] $ExpectedWow64Architecture - ) - - $output = & "$env:GITHUB_WORKSPACE\bin\rattler-build.exe" build ` - --recipe $Recipe ` - --build-platform $Platform ` - --host-platform $Platform ` - --target-platform $Platform ` - --output-dir "output-$Platform" 2>&1 - $rattlerBuildExitCode = $LASTEXITCODE - $outputText = $output | Out-String - Write-Host $outputText - - if ($rattlerBuildExitCode -eq 0) { - throw "Expected the recipe's intentional exit code 37 to fail the build." - } - if ($outputText -notmatch "PROCESSOR_ARCHITECTURE=$ExpectedArchitecture") { - throw "Expected the activated environment to report $ExpectedArchitecture." - } - if ($outputText -notmatch "PROCESSOR_ARCHITEW6432=$ExpectedWow64Architecture") { - throw "Expected the activated environment to report PROCESSOR_ARCHITEW6432=$ExpectedWow64Architecture." - } - if ($outputText -notmatch "ProcessArchitecture=$ExpectedArchitecture") { - throw "Expected the launched PowerShell process to be $ExpectedArchitecture." - } - if ($outputText -notmatch "Script failed with status 37") { - throw "Expected rattler-build to report the child exit code 37." - } - } - - Assert-CrossArchitecture ` - -Recipe test-data/recipes/windows-architecture-execution ` - -Platform win-arm64 ` - -ExpectedArchitecture ARM64 ` - -ExpectedWow64Architecture "" - Assert-CrossArchitecture ` - -Recipe test-data/recipes/windows-architecture-execution-x86 ` - -Platform win-32 ` - -ExpectedArchitecture x86 ` - -ExpectedWow64Architecture ARM64 - - # The expected rattler-build failures leave LASTEXITCODE at 1. - # The assertions above already validated their reported status. - exit 0 From a7432ca36b2030417eb4361de632fc307d4a5996 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:35:21 +0200 Subject: [PATCH 11/12] test(ci): add Windows ARM architecture E2E --- .github/workflows/rust.yml | 42 ++++++++++ .../test_windows_architecture_execution.py | 83 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 test/end-to-end/test_windows_architecture_execution.py diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c384ac458..18ba63038 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -110,10 +110,52 @@ jobs: PREFIX_DEV_READ_ONLY_TOKEN: ${{ secrets.PREFIX_DEV_READ_ONLY_TOKEN }} S3_ACCESS_KEY_ID: ${{ secrets.S3_UPLOAD_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.S3_UPLOAD_ACCESS_KEY_SECRET }} + - name: Stage x64 release binary + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $binary = Get-ChildItem target-pixi -Recurse -Filter rattler-build.exe | + Where-Object { $_.DirectoryName -match "\\release$" } | + Select-Object -First 1 + if ($null -eq $binary) { + throw "Could not find the x64 release binary." + } + New-Item -ItemType Directory -Path staging | Out-Null + Copy-Item $binary.FullName staging/rattler-build.exe + - name: Upload x64 release binary + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.0 + with: + name: rattler-build-windows-x64-e2e + path: staging/rattler-build.exe + if-no-files-found: error - name: Show sccache stats if: always() run: pixi run sccache --show-stats + windows-arm-e2e-test: + name: End-to-end (windows-11-arm) + needs: e2e-test + runs-on: windows-11-arm + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + persist-credentials: false + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 + with: + cache: true + cache-write: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + - name: Download x64 release binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rattler-build-windows-x64-e2e + path: bin + - name: Run Windows architecture E2E test + run: pixi run --platform win-64 --frozen pytest test/end-to-end/test_windows_architecture_execution.py -v + env: + RATTLER_BUILD_PATH: ${{ github.workspace }}\bin\rattler-build.exe + rebuild-test: name: Rebuild Test if: contains(github.event.pull_request.labels.*.name, 'needs-rebuild-tests') diff --git a/test/end-to-end/test_windows_architecture_execution.py b/test/end-to-end/test_windows_architecture_execution.py new file mode 100644 index 000000000..afa3bb2f3 --- /dev/null +++ b/test/end-to-end/test_windows_architecture_execution.py @@ -0,0 +1,83 @@ +import os +from pathlib import Path + +import pytest +from helpers import RattlerBuild + + +def is_windows_arm64_host() -> bool: + """Return whether Windows is natively running on ARM64. + + The E2E runner deliberately uses an emulated x64 rattler-build executable, + so PROCESSOR_ARCHITEW6432 identifies the actual host in that case. + """ + return os.name == "nt" and "ARM64" in { + os.environ.get("PROCESSOR_ARCHITECTURE", "").upper(), + os.environ.get("PROCESSOR_ARCHITEW6432", "").upper(), + } + + +pytestmark = pytest.mark.skipif( + not is_windows_arm64_host(), reason="requires a Windows ARM64 host" +) + + +@pytest.mark.parametrize( + ( + "recipe", + "platform", + "architecture", + "wow64_architecture", + "process_architecture", + ), + [ + ( + "windows-architecture-execution", + "win-arm64", + "ARM64", + "", + "Arm64", + ), + ( + "windows-architecture-execution-x86", + "win-32", + "x86", + "ARM64", + "X86", + ), + ], +) +def test_windows_architecture_execution( + rattler_build: RattlerBuild, + recipes: Path, + tmp_path: Path, + recipe: str, + platform: str, + architecture: str, + wow64_architecture: str, + process_architecture: str, +): + """An emulated x64 rattler-build launches scripts at the requested architecture.""" + result = rattler_build( + "build", + "--recipe", + recipes / recipe, + "--build-platform", + platform, + "--host-platform", + platform, + "--target-platform", + platform, + "--output-dir", + tmp_path / platform, + capture_output=True, + text=True, + ) + output = result.stdout + result.stderr + + # The recipes intentionally exit 37 after reporting their architecture. + assert result.returncode != 0 + assert f"PROCESSOR_ARCHITECTURE={architecture}" in output + assert f"PROCESSOR_ARCHITEW6432={wow64_architecture}" in output + assert f"ProcessArchitecture={process_architecture}" in output + assert "Script failed with status 37" in output From 6084f58d0e2a2a5b1496753318ee23aa0000f752 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:56:25 +0200 Subject: [PATCH 12/12] fix(ci): run ARM E2E after matrix failures --- .github/workflows/rust.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 18ba63038..159f0a02e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -136,6 +136,10 @@ jobs: windows-arm-e2e-test: name: End-to-end (windows-11-arm) needs: e2e-test + # e2e-test is a matrix job. Run this when another platform fails, provided + # the workflow itself was not cancelled, so an unrelated failure does not + # skip validation of the successfully uploaded Windows x64 binary. + if: ${{ always() && !cancelled() }} runs-on: windows-11-arm steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0