diff --git a/Directory.Build.props b/Directory.Build.props
index 6ea9e7f0bd2..5babeeda2a4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -18,6 +18,8 @@
True
false
+ $(Platform.ToLowerInvariant())
+ arm64
win-$(TargetArchitecture)
diff --git a/Restore.cmd b/Restore.cmd
index f615deaac21..d0bffa68b00 100644
--- a/Restore.cmd
+++ b/Restore.cmd
@@ -1,2 +1,3 @@
@echo off
-powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0eng\common\Build.ps1""" -NativeToolsOnMachine -restore %*"
\ No newline at end of file
+powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\build.cmd.ps1" -RestoreOnly %*
+exit /b %ErrorLevel%
\ No newline at end of file
diff --git a/build.cmd b/build.cmd
index 9852a061a26..c25a3fbebe1 100755
--- a/build.cmd
+++ b/build.cmd
@@ -1,3 +1,3 @@
@echo off
-powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0eng\common\Build.ps1""" -NativeToolsOnMachine -restore -build -bl %*"
+powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\build.cmd.ps1" %*
exit /b %ErrorLevel%
diff --git a/docs/building.md b/docs/building.md
index 25f26541301..1c38b2c7a19 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -13,6 +13,53 @@ Follow the prerequisites listed at [Developer Guide](developer-guide.md).
Note that this does **not** build using your machine-wide installed version of the dotnet sdk. It builds using the repo-local .NET SDK specified in the global.json in the repository root.
+### Windows x64 and ARM64 builds
+
+Run `.\build.cmd` or `.\Restore.cmd` normally on either machine architecture. A fresh SDK
+installation uses the OS architecture, even from an emulated shell. Existing installations
+retain the architecture of their actual `dotnet.exe`; an x64 SDK running under emulation on
+ARM64 is not replaced or stripped of its x64 runtimes.
+If a checkout containing an ARM64 SDK is reused on x64, restore preserves that SDK and
+bootstraps an executable x64 SDK in its `x64` subdirectory instead.
+
+The target is independent of the SDK architecture. Builds default applications and tests to
+the OS architecture. Use `.\build.cmd -platform arm64`, `-platform x64`, or `-platform x86`
+to select a target explicitly. Source generators and analyzers retain the solution's
+Any CPU mappings. Cross-compiling does not make ARM64 executables runnable on an x64 machine.
+
+Restore installs the SDK architecture's runtime alongside the SDK. Other architectures go
+into the active installation's `arm64`, `x64`, or `x86` subdirectories. The runtime cache checks
+the architecture of `dotnet.exe`, `hostfxr.dll`, `hostpolicy.dll`, and `coreclr.dll`, and the
+presence of `System.Private.CoreLib.dll`, not just a version directory.
+Incomplete entries are preserved and reinstalled rather than silently skipped.
+
+This separation matters even for a fresh checkout. Previously, the runtime manifest explicitly
+requested x64, and the shared installer only isolated x86. An ARM64 SDK could therefore receive
+a newer x64 `hostfxr.dll` in its own installation. The ARM64 host cannot load that resolver.
+The default runtime request now follows the SDK architecture; explicit cross-target runtimes
+are installed separately.
+
+For a separate SDK installation, set `DOTNET_GLOBAL_INSTALL_DIR` before running the build or
+restore scripts. `global.json` prefers the SDK muxer's own installation before falling back
+to `.dotnet`, so an external x64 SDK does not load ARM64 SDK binaries (or vice versa).
+Command-line builds, `start-vs.cmd`, and `start-code.cmd` honor this root and expose
+architecture-specific runtime roots for apphost executables, including tests and their child
+processes. These roots select the runtime independently of the SDK used to build the executable.
+
+Older mixed-architecture installations are repaired relative to their **SDK muxer**, not the
+OS. Incompatible version directories are preserved under the installation's `legacy-runtimes`
+directory. Repair checks both `hostfxr.dll` and `coreclr.dll`, including version directories
+missing those files, and refuses to move any files
+if that would leave the muxer without a compatible resolver.
+
+The changes use repository-owned scripts, `eng\configure-toolset.ps1`, and `eng\Tools.props`;
+the synchronized `eng\common` files are unchanged. PR build jobs run these regression checks:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\eng\tests\BuildScript.Tests.ps1
+powershell -NoProfile -ExecutionPolicy Bypass -File .\eng\tests\DotNetArchitecture.Tests.ps1
+```
+
### Building from Visual Studio
1. .NET 6.0 and above branches need VisualStudio 2022 to build.
diff --git a/eng/Tools.props b/eng/Tools.props
new file mode 100644
index 00000000000..c14d8d15ce0
--- /dev/null
+++ b/eng/Tools.props
@@ -0,0 +1,20 @@
+
+
+
+
+ true
+ <_WinFormsRuntimeRoot>$(DotNetRoot)
+ <_WinFormsRuntimeRoot Condition="'$(DOTNET_GLOBAL_INSTALL_DIR)' != ''">$(DOTNET_GLOBAL_INSTALL_DIR)
+ <_WinFormsRuntimeRootArgument>$(_WinFormsRuntimeRoot)
+ <_WinFormsRuntimeRootArgument Condition="$(_WinFormsRuntimeRoot.EndsWith('\'))">$(_WinFormsRuntimeRoot)\
+ <_WinFormsRuntimeSourceOptions Condition="'$(DotNetRuntimeSourceFeed)' != ''">-RuntimeSourceFeed "$(DotNetRuntimeSourceFeed)"
+ <_WinFormsRuntimeSourceOptions Condition="'$(DotNetRuntimeSourceFeed)' != '' and '$(DotNetRuntimeSourceFeedKey)' != ''">$(_WinFormsRuntimeSourceOptions) -RuntimeSourceFeedKey "$(DotNetRuntimeSourceFeedKey)"
+ <_WinFormsTargetArchitectureOptions Condition="'$(TargetArchitecture)' != ''">-TargetArchitecture "$(TargetArchitecture)"
+
+
+
+
+
diff --git a/eng/build.cmd.ps1 b/eng/build.cmd.ps1
new file mode 100644
index 00000000000..9d8a594ecf7
--- /dev/null
+++ b/eng/build.cmd.ps1
@@ -0,0 +1,131 @@
+$ErrorActionPreference = 'Stop'
+$BuildArgs = @($args)
+
+try {
+ . "$PSScriptRoot\dotnet-architecture.ps1"
+ $hostArchitecture = Get-WindowsBuildArchitecture
+ $buildScriptPath = "$PSScriptRoot\common\build.ps1"
+ $buildParameters = (Get-Command $buildScriptPath).Parameters.Values
+
+ function Get-ArcadeParameter([string] $argument) {
+ if ($argument -notmatch '^-(?[A-Za-z]+)(:.*)?$') {
+ return
+ }
+
+ $name = $Matches.Name
+ $parameters = @($buildParameters | Where-Object { $_.Name -eq $name -or $_.Aliases -contains $name })
+ if ($parameters.Count -eq 0) {
+ $parameters = @($buildParameters | Where-Object {
+ $names = @($_.Name) + @($_.Aliases)
+ @($names | Where-Object { $_.StartsWith($name, [StringComparison]::OrdinalIgnoreCase) }).Count -gt 0
+ })
+ }
+
+ if ($parameters.Count -eq 1) {
+ return $parameters[0]
+ }
+ }
+
+ $forwardArgs = [System.Collections.Generic.List[string]]::new()
+ $platform = $null
+ $propertyPlatform = $null
+ $hasPropertyPlatform = $false
+ $hasTargetArchitecture = $false
+ $hasRestore = $false
+ $hasBuild = $false
+ $hasBinaryLog = $false
+ $restoreOnly = $false
+
+ for ($i = 0; $i -lt $BuildArgs.Length; $i++) {
+ $arg = $BuildArgs[$i]
+ if ($arg -eq '-RestoreOnly') {
+ $restoreOnly = $true
+ continue
+ }
+
+ if ($arg -match '^[-/](p|property):(.+)$') {
+ $propertyList = $Matches[2]
+ foreach ($property in [regex]::Matches($propertyList, '(?:^|;)\s*(?[^=;]+)=(?(?:"[^"]*"|[^;"])*)')) {
+ switch ($property.Groups['Name'].Value.Trim()) {
+ 'TargetArchitecture' { $hasTargetArchitecture = $true }
+ 'Platform' {
+ $propertyPlatform = $property.Groups['Value'].Value.Trim().Trim('"')
+ $hasPropertyPlatform = $true
+ }
+ }
+ }
+ }
+
+ $parameter = Get-ArcadeParameter $arg
+ if ($parameter.Name -eq 'restore') { $hasRestore = $true }
+ if ($parameter.Name -eq 'build') { $hasBuild = $true }
+ if ($parameter.Name -in @('binaryLog', 'binaryLogName', 'excludeCIBinarylog')) { $hasBinaryLog = $true }
+ if ($parameter.Name -eq 'platform') {
+ $parts = $arg.Split(':', 2)
+ if ($parts.Length -eq 2) { $platform = $parts[1] }
+ elseif ($i + 1 -lt $BuildArgs.Length) { $platform = $BuildArgs[$i + 1] }
+ }
+
+ $forwardArgs.Add($arg)
+ }
+
+ if (!$hasTargetArchitecture) {
+ # Arcade emits -platform before its remaining MSBuild properties.
+ $targetArchitecture = if ($hasPropertyPlatform) { $propertyPlatform } else { $platform }
+ if ($targetArchitecture -match '^(arm64|x64|x86)$') {
+ $forwardArgs.Add("/p:TargetArchitecture=$($targetArchitecture.ToLowerInvariant())")
+ }
+ else {
+ $forwardArgs.Add("/p:TargetArchitecture=$hostArchitecture")
+ }
+ }
+
+ if (!$hasRestore) { $forwardArgs.Add('-restore') }
+ if (!$restoreOnly -and !$hasBuild) { $forwardArgs.Add('-build') }
+ if (!$restoreOnly -and !$hasBinaryLog) { $forwardArgs.Add('-bl') }
+
+ # -File cannot bind Boolean parameters in Windows PowerShell. Encode a command
+ # with quoted values and literal Boolean arguments instead of losing their types.
+ $buildScript = $buildScriptPath.Replace("'", "''")
+ $command = "`$ErrorActionPreference = 'Stop'; `$ProgressPreference = 'SilentlyContinue'; & '$buildScript' -NativeToolsOnMachine"
+ for ($i = 0; $i -lt $forwardArgs.Count; $i++) {
+ $arg = $forwardArgs[$i]
+ $parameter = Get-ArcadeParameter $arg
+ $parts = $arg.Split(':', 2)
+ if ($parameter.ParameterType -eq [bool] -or
+ ($parameter.ParameterType -eq [switch] -and $parts.Length -eq 2)) {
+ $value = if ($parts.Length -eq 2) { $parts[1] } else {
+ $i++
+ if ($i -ge $forwardArgs.Count) { throw "Missing Boolean value for -$($parameter.Name)." }
+ $forwardArgs[$i]
+ }
+ $separator = if ($parameter.ParameterType -eq [switch]) { ':' } else { ' ' }
+ switch ($value.TrimStart('$').ToLowerInvariant()) {
+ { $_ -in @('true', '1') } { $command += " -$($parameter.Name)$separator`$true" }
+ { $_ -in @('false', '0') } { $command += " -$($parameter.Name)$separator`$false" }
+ default { throw "Invalid Boolean value for -$($parameter.Name): $value" }
+ }
+ }
+ elseif ($parameter) {
+ $command += " -$($parameter.Name)"
+ if ($parts.Length -eq 2) {
+ $command += " '$($parts[1].Replace("'", "''"))'"
+ }
+ }
+ elseif ($arg -match '^-[A-Za-z]+$') {
+ $command += " $arg"
+ }
+ else {
+ $command += " '$($arg.Replace("'", "''"))'"
+ }
+ }
+
+ $command += ' 6>&1; exit $LASTEXITCODE'
+ $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
+ & powershell -ExecutionPolicy ByPass -NoProfile -OutputFormat Text -EncodedCommand $encodedCommand
+ exit $LASTEXITCODE
+}
+catch {
+ Write-Error $_ -ErrorAction Continue
+ exit 1
+}
diff --git a/eng/configure-toolset.ps1 b/eng/configure-toolset.ps1
index a6048f7e634..a9fc68f15ce 100644
--- a/eng/configure-toolset.ps1
+++ b/eng/configure-toolset.ps1
@@ -3,3 +3,35 @@ $script:DoNotDisplayNativeToolsInstallationWarnings = $true
# Add CMake to path.
$env:PATH = "$PSScriptRoot\..\.tools\bin;$env:PATH"
+
+if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
+ . "$PSScriptRoot\dotnet-architecture.ps1"
+ $_WinFormsDotNetRoot = if ($env:DOTNET_GLOBAL_INSTALL_DIR) { $env:DOTNET_GLOBAL_INSTALL_DIR } else { Join-Path $RepoRoot '.dotnet' }
+ $_WinFormsSdkRoot = Get-DotNetSdkDirectory $_WinFormsDotNetRoot
+ if ($_WinFormsSdkRoot -ne $_WinFormsDotNetRoot) {
+ Write-Host "Using compatible SDK installation '$_WinFormsSdkRoot'; preserving '$_WinFormsDotNetRoot'."
+ $env:DOTNET_GLOBAL_INSTALL_DIR = $_WinFormsSdkRoot
+ $_WinFormsDotNetRoot = $_WinFormsSdkRoot
+ }
+
+ $_WinFormsSdkArchitecture = Get-DotNetInstallationArchitecture $_WinFormsDotNetRoot
+ foreach ($_WinFormsArchitecture in @('x86', 'x64', 'arm64')) {
+ $_WinFormsRuntimeRoot = Get-DotNetRuntimeDirectory $_WinFormsDotNetRoot $_WinFormsSdkArchitecture $_WinFormsArchitecture
+ [Environment]::SetEnvironmentVariable("DOTNET_ROOT_$($_WinFormsArchitecture.ToUpperInvariant())", $_WinFormsRuntimeRoot)
+ }
+
+ if (Test-Path -LiteralPath (Join-Path $_WinFormsDotNetRoot "sdk\$($GlobalJson.tools.dotnet)")) {
+ Repair-DotNetInstallation $_WinFormsDotNetRoot
+ }
+
+ # Keep an existing SDK's execution architecture, including x64 under emulation.
+ # For a fresh installation, select the OS architecture explicitly.
+ function InstallDotNetSdk([string] $dotnetRoot, [string] $version, [string] $architecture = '', [switch] $noPath) {
+ if (!$architecture) {
+ $architecture = Get-DotNetInstallationArchitecture $dotnetRoot
+ }
+
+ InstallDotNet $dotnetRoot $version $architecture '' $false $runtimeSourceFeed $runtimeSourceFeedKey -noPath:$noPath
+ Repair-DotNetInstallation $dotnetRoot
+ }
+}
diff --git a/eng/dotnet-architecture.ps1 b/eng/dotnet-architecture.ps1
new file mode 100644
index 00000000000..63f6089ac77
--- /dev/null
+++ b/eng/dotnet-architecture.ps1
@@ -0,0 +1,206 @@
+param([string] $ResolveSdkRoot, [switch] $ResolveRuntimeRoots)
+
+function Get-DotNetExecutableArchitecture([string] $path) {
+ $stream = [System.IO.File]::OpenRead($path)
+ try {
+ $reader = [System.IO.BinaryReader]::new($stream)
+ if ($reader.ReadUInt16() -ne 0x5A4D) {
+ throw "Invalid PE file: $path"
+ }
+
+ $stream.Position = 0x3C
+ $stream.Position = $reader.ReadInt32()
+ if ($reader.ReadUInt32() -ne 0x00004550) {
+ throw "Invalid PE header: $path"
+ }
+
+ switch ($reader.ReadUInt16()) {
+ 0xAA64 { return 'arm64' }
+ 0x8664 { return 'x64' }
+ 0x014C { return 'x86' }
+ default { throw "Unsupported .NET executable architecture: $path" }
+ }
+ }
+ finally {
+ $stream.Dispose()
+ }
+}
+
+function Get-WindowsBuildArchitecture {
+ $architecture = $env:PROCESSOR_ARCHITECTURE
+ if ($env:PROCESSOR_ARCHITEW6432) {
+ $architecture = $env:PROCESSOR_ARCHITEW6432
+ }
+
+ switch ($architecture) {
+ 'ARM64' { return 'arm64' }
+ 'AMD64' { return 'x64' }
+ 'x86' { return 'x86' }
+ default { throw "Unsupported Windows build architecture: $architecture" }
+ }
+}
+
+function Get-DotNetInstallationArchitecture([string] $dotnetRoot) {
+ $muxer = Join-Path $dotnetRoot 'dotnet.exe'
+ if (Test-Path -LiteralPath $muxer) {
+ return Get-DotNetExecutableArchitecture $muxer
+ }
+
+ return Get-WindowsBuildArchitecture
+}
+
+function Get-DotNetSdkDirectory([string] $dotnetRoot) {
+ $dotnetRoot = [IO.Path]::GetFullPath($dotnetRoot)
+ $osArchitecture = Get-WindowsBuildArchitecture
+ $sdkArchitecture = Get-DotNetInstallationArchitecture $dotnetRoot
+ if ($osArchitecture -eq 'arm64' -or $sdkArchitecture -eq $osArchitecture -or $sdkArchitecture -eq 'x86') {
+ return $dotnetRoot
+ }
+
+ # An ARM64 SDK cannot execute on x64. Keep it intact and bootstrap the native
+ # SDK separately, also covering a checkout reused on a different machine.
+ $nativeRoot = Join-Path $dotnetRoot $osArchitecture
+ if ((Get-DotNetInstallationArchitecture $nativeRoot) -ne $osArchitecture) {
+ throw "The SDK in '$nativeRoot' cannot execute on $osArchitecture. Choose a different DOTNET_GLOBAL_INSTALL_DIR."
+ }
+
+ return $nativeRoot
+}
+
+function Get-DotNetRuntimeDirectory([string] $dotnetRoot, [string] $sdkArchitecture, [string] $runtimeArchitecture) {
+ if ($runtimeArchitecture -notin @('x64', 'arm64', 'x86')) {
+ throw "Unsupported .NET runtime architecture: $runtimeArchitecture"
+ }
+
+ if ($runtimeArchitecture -eq $sdkArchitecture) {
+ return $dotnetRoot
+ }
+
+ return Join-Path $dotnetRoot $runtimeArchitecture
+}
+
+function Move-DotNetRuntimeVersion([string] $dotnetRoot, [string] $component, [System.IO.DirectoryInfo] $versionDir) {
+ $backupDir = Join-Path $dotnetRoot "legacy-runtimes\$component"
+ New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
+ $destination = Join-Path $backupDir "$($versionDir.Name)-$([Guid]::NewGuid().ToString('N'))"
+ Write-Host "Preserving incompatible or incomplete .NET files from '$($versionDir.FullName)' in '$destination'."
+ Move-Item -LiteralPath $versionDir.FullName -Destination $destination
+}
+
+function Repair-DotNetInstallation([string] $dotnetRoot) {
+ $muxer = Join-Path $dotnetRoot 'dotnet.exe'
+ if (!(Test-Path -LiteralPath $muxer)) {
+ return
+ }
+
+ $architecture = Get-DotNetExecutableArchitecture $muxer
+ $incompatibleVersions = @()
+ $compatibleResolvers = 0
+ $incompatibleResolvers = 0
+
+ foreach ($component in @('host\fxr', 'shared\Microsoft.NETCore.App')) {
+ $componentDir = Join-Path $dotnetRoot $component
+ if (!(Test-Path -LiteralPath $componentDir)) {
+ continue
+ }
+
+ $binaryName = if ($component -eq 'host\fxr') { 'hostfxr.dll' } else { 'coreclr.dll' }
+ foreach ($versionDir in Get-ChildItem -LiteralPath $componentDir -Directory) {
+ $binaryPath = Join-Path $versionDir.FullName $binaryName
+ if ((Test-Path -LiteralPath $binaryPath -PathType Leaf) -and
+ (Get-DotNetExecutableArchitecture $binaryPath) -eq $architecture) {
+ if ($component -eq 'host\fxr') { $compatibleResolvers++ }
+ }
+ else {
+ if ($component -eq 'host\fxr') { $incompatibleResolvers++ }
+ $incompatibleVersions += @{ Component = $component; Directory = $versionDir }
+ }
+ }
+ }
+
+ # Preflight the whole installation before moving anything, including shared runtimes.
+ if ($incompatibleResolvers -gt 0 -and $compatibleResolvers -eq 0) {
+ throw "No hostfxr compatible with the $architecture SDK in '$dotnetRoot'. No files were moved. Restore into a new DOTNET_GLOBAL_INSTALL_DIR or reinstall this SDK."
+ }
+
+ foreach ($version in $incompatibleVersions) {
+ Move-DotNetRuntimeVersion $dotnetRoot $version.Component $version.Directory
+ }
+}
+
+function Get-DotNetRuntimeInstallationProblem([string] $dotnetRoot, [string] $version, [string] $architecture) {
+ $runtimeDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version"
+ foreach ($binary in @(
+ (Join-Path $dotnetRoot 'dotnet.exe'),
+ (Join-Path $dotnetRoot "host\fxr\$version\hostfxr.dll"),
+ (Join-Path $runtimeDir 'coreclr.dll'),
+ (Join-Path $runtimeDir 'hostpolicy.dll'))) {
+ if (!(Test-Path -LiteralPath $binary -PathType Leaf) -or
+ (Get-DotNetExecutableArchitecture $binary) -ne $architecture) {
+ return $binary
+ }
+ }
+
+ $coreLib = Join-Path $runtimeDir 'System.Private.CoreLib.dll'
+ if (!(Test-Path -LiteralPath $coreLib -PathType Leaf)) {
+ return $coreLib
+ }
+}
+
+function Install-WinFormsDotNetRuntime(
+ [string] $dotnetRoot,
+ [string] $sdkArchitecture,
+ [string] $runtimeArchitecture,
+ [string] $version,
+ [string] $runtimeSourceFeed,
+ [string] $runtimeSourceFeedKey) {
+
+ $installDir = Get-DotNetRuntimeDirectory $dotnetRoot $sdkArchitecture $runtimeArchitecture
+ $muxer = Join-Path $installDir 'dotnet.exe'
+ if ((Test-Path -LiteralPath $muxer) -and (Get-DotNetExecutableArchitecture $muxer) -ne $runtimeArchitecture) {
+ throw "The .NET host in '$installDir' is not $runtimeArchitecture. No files were moved."
+ }
+
+ $runtimeDir = Join-Path $installDir "shared\Microsoft.NETCore.App\$version"
+ $resolverDir = Join-Path $installDir "host\fxr\$version"
+ $resolver = Join-Path $resolverDir 'hostfxr.dll'
+ if (Get-DotNetRuntimeInstallationProblem $installDir $version $runtimeArchitecture) {
+ $compatibleResolver = (Test-Path -LiteralPath $resolver -PathType Leaf) -and
+ (Get-DotNetExecutableArchitecture $resolver) -eq $runtimeArchitecture
+ # Arcade's cache check only tests the directory's existence. Move an incomplete
+ # entry aside so the shared installer downloads it rather than skipping it.
+ if (Test-Path -LiteralPath $runtimeDir) {
+ Move-DotNetRuntimeVersion $installDir 'shared\Microsoft.NETCore.App' (Get-Item -LiteralPath $runtimeDir)
+ }
+
+ # The extractor also skips existing versioned directories, even if empty.
+ if (!$compatibleResolver -and (Test-Path -LiteralPath $resolverDir)) {
+ Move-DotNetRuntimeVersion $installDir 'host\fxr' (Get-Item -LiteralPath $resolverDir)
+ }
+
+ InstallDotNet $installDir $version $runtimeArchitecture 'dotnet' $true $runtimeSourceFeed $runtimeSourceFeedKey -noPath
+ $problem = Get-DotNetRuntimeInstallationProblem $installDir $version $runtimeArchitecture
+ if ($problem) {
+ throw "The $runtimeArchitecture runtime $version installation is incomplete or incompatible: $problem. Previous files were preserved under '$installDir\legacy-runtimes'."
+ }
+ }
+ else {
+ Write-Host "Runtime dotnet/$runtimeArchitecture $version already installed in '$installDir'."
+ }
+
+ Repair-DotNetInstallation $installDir
+}
+
+if ($ResolveSdkRoot) {
+ $sdkRoot = Get-DotNetSdkDirectory $ResolveSdkRoot
+ if ($ResolveRuntimeRoots) {
+ $sdkArchitecture = Get-DotNetInstallationArchitecture $sdkRoot
+ foreach ($architecture in @('x86', 'x64', 'arm64')) {
+ $runtimeRoot = Get-DotNetRuntimeDirectory $sdkRoot $sdkArchitecture $architecture
+ "DOTNET_ROOT_$($architecture.ToUpperInvariant())=$runtimeRoot"
+ }
+ }
+ else {
+ $sdkRoot
+ }
+}
diff --git a/eng/install-runtimes.ps1 b/eng/install-runtimes.ps1
new file mode 100644
index 00000000000..26d96506aff
--- /dev/null
+++ b/eng/install-runtimes.ps1
@@ -0,0 +1,52 @@
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $SdkRoot,
+ [Parameter(Mandatory = $true)]
+ [string] $RuntimeVersion,
+ [string] $TargetArchitecture,
+ [string] $RuntimeSourceFeed,
+ [string] $RuntimeSourceFeedKey
+)
+
+$ErrorActionPreference = 'Stop'
+
+try {
+ . "$PSScriptRoot\common\tools.ps1"
+ . "$PSScriptRoot\dotnet-architecture.ps1"
+
+ $sdkArchitecture = Get-DotNetExecutableArchitecture (Join-Path $SdkRoot 'dotnet.exe')
+ $requests = @()
+ foreach ($runtime in $GlobalJson.tools.runtimes.PSObject.Properties) {
+ $parts = $runtime.Name.Split('/')
+ if ($parts[0] -ne 'dotnet' -or $parts.Length -gt 2) {
+ throw "Unsupported WinForms runtime specification: $($runtime.Name)"
+ }
+
+ $architecture = if ($parts.Length -eq 2) { $parts[1].ToLowerInvariant() } else { $sdkArchitecture }
+ foreach ($value in $runtime.Value) {
+ $version = if ($value -eq '$(MicrosoftNETCoreAppRefPackageVersion)') { $RuntimeVersion } else { $value }
+ if ($version -notmatch '^\d+\.\d+\.\d+([+-][0-9A-Za-z.-]+)?$') {
+ throw "Unresolved or invalid runtime version: $value"
+ }
+
+ $requests += @{ Architecture = $architecture; Version = $version }
+ if ($parts.Length -eq 1 -and $TargetArchitecture) {
+ $requests += @{ Architecture = $TargetArchitecture.ToLowerInvariant(); Version = $version }
+ }
+ }
+ }
+
+ $installed = @{}
+ foreach ($request in $requests) {
+ $key = "$($request.Architecture)/$($request.Version)"
+ if (!$installed.ContainsKey($key)) {
+ Install-WinFormsDotNetRuntime $SdkRoot $sdkArchitecture $request.Architecture $request.Version $RuntimeSourceFeed $RuntimeSourceFeedKey
+ $installed[$key] = $true
+ }
+ }
+}
+catch {
+ Write-Error $_
+ exit 1
+}
diff --git a/eng/pipelines/build-PR.yml b/eng/pipelines/build-PR.yml
index 76805cc952d..4cb410031f3 100644
--- a/eng/pipelines/build-PR.yml
+++ b/eng/pipelines/build-PR.yml
@@ -64,6 +64,11 @@ jobs:
- script: C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ngen.exe queue pause
displayName: Pause NGEN x64
+ - powershell: |
+ & .\eng\tests\BuildScript.Tests.ps1
+ & .\eng\tests\DotNetArchitecture.Tests.ps1
+ displayName: Verify SDK and runtime architecture isolation
+
# Build and rename binlog
# The /p:Coverage argument is passed here since some build properties change to accommodate running with
# coverage. This is part of the workarounds for https://github.com/tonerdo/coverlet/issues/362 and
diff --git a/eng/tests/BuildScript.Tests.ps1 b/eng/tests/BuildScript.Tests.ps1
new file mode 100644
index 00000000000..9aeffcfe1a7
--- /dev/null
+++ b/eng/tests/BuildScript.Tests.ps1
@@ -0,0 +1,131 @@
+$ErrorActionPreference = 'Stop'
+
+function Assert-True([bool] $condition, [string] $message) {
+ if (!$condition) {
+ throw $message
+ }
+}
+
+function Invoke-TestBuild([string[]] $arguments, [int] $expectedExitCode = 0) {
+ $ErrorActionPreference = 'Continue'
+ $output = @(& "$testRoot\eng\build.cmd.ps1" @arguments 2>&1)
+ Assert-True ($LASTEXITCODE -eq $expectedExitCode) "Unexpected exit code $LASTEXITCODE for: $arguments`n$output"
+ if ($expectedExitCode -eq 0) {
+ return Get-Content -LiteralPath $env:WINFORMS_TEST_ARGUMENTS -Raw | ConvertFrom-Json
+ }
+}
+
+$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "WinForms-build-tests-$([Guid]::NewGuid().ToString('N'))"
+$savedEnvironment = @{}
+foreach ($name in @('PROCESSOR_ARCHITECTURE', 'PROCESSOR_ARCHITEW6432', 'DOTNET_GLOBAL_INSTALL_DIR', 'WINFORMS_TEST_ARGUMENTS', 'WINFORMS_TEST_EXIT_CODE')) {
+ $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
+}
+
+try {
+ New-Item -ItemType Directory -Path "$testRoot\eng\common" -Force | Out-Null
+ Copy-Item -LiteralPath "$PSScriptRoot\..\build.cmd.ps1" -Destination "$testRoot\eng\build.cmd.ps1"
+ Copy-Item -LiteralPath "$PSScriptRoot\..\dotnet-architecture.ps1" -Destination "$testRoot\eng\dotnet-architecture.ps1"
+ $tokens = $null
+ $parseErrors = $null
+ $arcadeScript = [System.Management.Automation.Language.Parser]::ParseFile(
+ "$PSScriptRoot\..\common\build.ps1", [ref] $tokens, [ref] $parseErrors)
+ Assert-True ($parseErrors.Count -eq 0) 'Could not read Arcade build parameters.'
+ $fixture = '[CmdletBinding(PositionalBinding=$false)]' + "`n" + $arcadeScript.ParamBlock.Extent.Text + @'
+
+$bound = @{}
+foreach ($parameter in $PSBoundParameters.GetEnumerator()) {
+ $bound[$parameter.Key] = if ($parameter.Value -is [switch]) { [bool] $parameter.Value } else { $parameter.Value }
+}
+ConvertTo-Json -InputObject $bound -Compress | Set-Content -LiteralPath $env:WINFORMS_TEST_ARGUMENTS
+exit ([int] $env:WINFORMS_TEST_EXIT_CODE)
+'@
+ $fixture | Set-Content -LiteralPath "$testRoot\eng\common\build.ps1"
+
+ $env:WINFORMS_TEST_ARGUMENTS = "$testRoot\arguments.json"
+ $env:WINFORMS_TEST_EXIT_CODE = '0'
+ $env:DOTNET_GLOBAL_INSTALL_DIR = "$testRoot\SDK with spaces"
+ $env:PROCESSOR_ARCHITECTURE = 'ARM64'
+ $env:PROCESSOR_ARCHITEW6432 = $null
+
+ $actual = Invoke-TestBuild @()
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') "ARM64 host did not default to ARM64: $($actual | ConvertTo-Json -Compress)"
+ Assert-True ($actual.restore -and $actual.build -and $actual.binaryLog) 'Default build actions missing.'
+
+ $actual = Invoke-TestBuild @('-platform', 'ARM64')
+ Assert-True ($actual.platform -eq 'ARM64') 'ARM64 solution platform was discarded.'
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') 'ARM64 platform did not set target architecture.'
+
+ foreach ($platform in @('x64', 'x86')) {
+ $actual = Invoke-TestBuild @('-platform', $platform)
+ Assert-True ($actual.properties -contains "/p:TargetArchitecture=$platform") "Explicit $platform target was ignored."
+ }
+
+ $actual = Invoke-TestBuild @('/p:Platform=arm64', '/p:TargetArchitecture=x64')
+ Assert-True ($actual.properties -contains '/p:Platform=arm64') 'MSBuild platform was discarded.'
+ Assert-True ($actual.properties -notcontains '/p:TargetArchitecture=arm64') 'Explicit target architecture was overwritten.'
+
+ $actual = Invoke-TestBuild @('-r', '-b', '-nobl', '-projects', 'project with spaces.csproj')
+ Assert-True ($actual.restore -and $actual.build -and $actual.excludeCIBinarylog -and !$actual.binaryLog) 'Action aliases were duplicated.'
+ Assert-True ($actual.projects -eq 'project with spaces.csproj') 'Argument containing spaces was split.'
+
+ $actual = Invoke-TestBuild @('-nodeReuse', 'false', '-warnAsError:$true', '-mt', '1', '/p:Example=O''Brien;$notCode')
+ Assert-True ($actual.nodeReuse -eq $false -and $actual.warnAsError -eq $true -and $actual.msbuildMultiThreaded -eq $true) 'Boolean arguments lost their types.'
+ Assert-True ($actual.properties -contains '/p:Example=O''Brien;$notCode') 'Quoted property value was evaluated as code.'
+
+ $actual = Invoke-TestBuild @('-RestoreOnly')
+ Assert-True ($actual.restore -and !$actual.build -and !$actual.binaryLog) 'Restore-only requested a build.'
+
+ $env:PROCESSOR_ARCHITECTURE = 'AMD64'
+ $env:PROCESSOR_ARCHITEW6432 = 'ARM64'
+ $actual = Invoke-TestBuild @()
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') 'Emulated shell did not detect the ARM64 OS.'
+
+ $env:PROCESSOR_ARCHITEW6432 = $null
+ $actual = Invoke-TestBuild @()
+ Assert-True ($actual.properties -notcontains '/p:TargetArchitecture=arm64') 'x64 host was treated as ARM64.'
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=x64') 'x64 host did not default to x64.'
+ $actual = Invoke-TestBuild @('-platform', 'arm64')
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') 'Cross-build target was ignored.'
+
+ foreach ($property in @(
+ '/property:TargetArchitecture=arm64',
+ '/PROPERTY:Configuration=Debug;TargetArchitecture=arm64',
+ '/p:TargetArchitecture=arm64;Platform=x64')) {
+ $actual = Invoke-TestBuild @($property)
+ Assert-True ($actual.properties -contains $property -and $actual.properties.Count -eq 1) 'Explicit target architecture was overridden.'
+ }
+
+ foreach ($property in @('/property:Platform=ARM64', '/p:Configuration=Debug;Platform=arm64')) {
+ $actual = Invoke-TestBuild @($property, '-platform', 'x64')
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') 'MSBuild Platform did not override the Arcade platform.'
+ }
+
+ $actual = Invoke-TestBuild @('-platform', 'x86', '/p:Platform=x64', '/property:Platform=ARM64')
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64') 'Last MSBuild Platform did not win.'
+ $actual = Invoke-TestBuild @('/p:Example="text;TargetArchitecture=arm64"')
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=x64') 'Quoted property contents were treated as architecture arguments.'
+
+ $actual = Invoke-TestBuild @('-plat', 'arm64', '-rest', '-nobl')
+ Assert-True ($actual.properties -contains '/p:TargetArchitecture=arm64' -and $actual.restore) 'Unambiguous Arcade abbreviations were not honored.'
+ $actual = Invoke-TestBuild @('-plat:ARM64', '-nodeR:false', '-restore:$false')
+ Assert-True ($actual.platform -eq 'ARM64' -and $actual.nodeReuse -eq $false -and $actual.restore -eq $false) 'Inline parameter values were not preserved.'
+
+ Invoke-TestBuild @('-configuration', 'Debug', '-c', 'Release') 1
+ Invoke-TestBuild @('-restore', '-rest') 1
+
+ # A failed compile must propagate its exit code without retrying or masking it.
+ $env:WINFORMS_TEST_EXIT_CODE = '7'
+ Invoke-TestBuild @() 7
+ $env:WINFORMS_TEST_EXIT_CODE = '0'
+ $null = Invoke-TestBuild @()
+
+ Write-Host 'Build script regression checks passed.'
+}
+finally {
+ foreach ($name in $savedEnvironment.Keys) {
+ [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name])
+ }
+ if (Test-Path -LiteralPath $testRoot) {
+ Remove-Item -LiteralPath $testRoot -Recurse -Force
+ }
+}
diff --git a/eng/tests/DotNetArchitecture.Tests.ps1 b/eng/tests/DotNetArchitecture.Tests.ps1
new file mode 100644
index 00000000000..b701942ea97
--- /dev/null
+++ b/eng/tests/DotNetArchitecture.Tests.ps1
@@ -0,0 +1,300 @@
+$ErrorActionPreference = 'Stop'
+. "$PSScriptRoot\..\dotnet-architecture.ps1"
+
+function Assert-True([bool] $condition, [string] $message) {
+ if (!$condition) { throw $message }
+}
+
+function Assert-Throws([scriptblock] $action, [string] $messagePattern) {
+ $errorMessage = $null
+ try { & $action }
+ catch { $errorMessage = $_.Exception.Message }
+ Assert-True ($errorMessage -like $messagePattern) "Expected '$messagePattern', received '$errorMessage'."
+}
+
+function Write-TestPE([string] $path, [string] $architecture) {
+ $machine = switch ($architecture) {
+ 'arm64' { [UInt16] 0xAA64 }
+ 'x64' { [UInt16] 0x8664 }
+ 'x86' { [UInt16] 0x014C }
+ default { throw "Invalid fixture architecture: $architecture" }
+ }
+ New-Item -ItemType Directory -Path (Split-Path $path -Parent) -Force | Out-Null
+ $bytes = [byte[]]::new(128)
+ $bytes[0] = 0x4D
+ $bytes[1] = 0x5A
+ $bytes[0x3C] = 0x40
+ $bytes[0x40] = 0x50
+ $bytes[0x41] = 0x45
+ [BitConverter]::GetBytes($machine).CopyTo($bytes, 0x44)
+ [IO.File]::WriteAllBytes($path, $bytes)
+}
+
+# The real installer exits early for an existing runtime directory. Require its caller
+# to distinguish complete caches from partial or wrong-architecture entries first.
+function InstallDotNet($root, $version, $architecture, $runtime, $skipNonVersionedFiles, $runtimeSourceFeed, $runtimeSourceFeedKey, [switch] $noPath) {
+ $installCalls.Add(@{ Root = $root; Architecture = $architecture; Runtime = $runtime; Version = $version })
+ Assert-True (!(Test-Path "$root\shared\Microsoft.NETCore.App\$version")) 'Installer would skip a cached runtime directory.'
+ if (!(Test-Path "$root\dotnet.exe")) {
+ Write-TestPE "$root\dotnet.exe" $architecture
+ }
+
+ # Like the real extractor, do not overwrite an existing versioned resolver folder.
+ if (!(Test-Path "$root\host\fxr\$version") -and $omitRuntimeFile -ne 'hostfxr.dll') {
+ Write-TestPE "$root\host\fxr\$version\hostfxr.dll" $architecture
+ }
+
+ foreach ($binary in @('coreclr.dll', 'hostpolicy.dll')) {
+ if ($omitRuntimeFile -ne $binary) {
+ $binaryArchitecture = if ($wrongRuntimeFile -eq $binary) { 'x86' } else { $architecture }
+ Write-TestPE "$root\shared\Microsoft.NETCore.App\$version\$binary" $binaryArchitecture
+ }
+ }
+
+ if ($omitRuntimeFile -ne 'System.Private.CoreLib.dll') {
+ Set-Content -LiteralPath "$root\shared\Microsoft.NETCore.App\$version\System.Private.CoreLib.dll" -Value 'managed fixture'
+ }
+ if (!$runtime) {
+ New-Item -ItemType Directory -Path "$root\sdk\$version" -Force | Out-Null
+ }
+}
+
+$testRoot = Join-Path ([IO.Path]::GetTempPath()) "WinForms-architecture-tests-$([Guid]::NewGuid().ToString('N'))"
+$omitRuntimeFile = $null
+$wrongRuntimeFile = $null
+$savedEnvironment = @{}
+foreach ($name in @('PROCESSOR_ARCHITECTURE', 'PROCESSOR_ARCHITEW6432', 'DOTNET_GLOBAL_INSTALL_DIR', 'DOTNET_ROOT', 'DOTNET_ROOT_X86', 'DOTNET_ROOT_X64', 'DOTNET_ROOT_ARM64', 'PATH')) {
+ $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
+}
+
+try {
+ foreach ($os in @('AMD64', 'ARM64')) {
+ $env:PROCESSOR_ARCHITECTURE = $os
+ $env:PROCESSOR_ARCHITEW6432 = $null
+ $expected = if ($os -eq 'ARM64') { 'arm64' } else { 'x64' }
+ Assert-True ((Get-DotNetInstallationArchitecture "$testRoot\fresh-$os") -eq $expected) 'Fresh SDK did not select the OS architecture.'
+
+ foreach ($sdk in @('x64', 'arm64')) {
+ $root = "$testRoot\$os-$sdk"
+ Write-TestPE "$root\dotnet.exe" $sdk
+ Assert-True ((Get-DotNetInstallationArchitecture $root) -eq $sdk) 'Existing SDK architecture was replaced by the OS architecture.'
+ $expectedSdkRoot = if ($os -eq 'AMD64' -and $sdk -eq 'arm64') { "$root\x64" } else { $root }
+ Assert-True ((Get-DotNetSdkDirectory $root) -eq $expectedSdkRoot) 'SDK selection did not preserve a compatible host or isolate an incompatible host.'
+ Assert-True (Test-Path "$root\dotnet.exe") 'SDK selection removed the old muxer.'
+ $launcherRoots = @(& "$PSScriptRoot\..\dotnet-architecture.ps1" -ResolveSdkRoot $root -ResolveRuntimeRoots)
+ $launcherSdk = Get-DotNetInstallationArchitecture $expectedSdkRoot
+ foreach ($target in @('x64', 'arm64', 'x86')) {
+ $expectedRoot = if ($sdk -eq $target) { $root } else { "$root\$target" }
+ Assert-True ((Get-DotNetRuntimeDirectory $root $sdk $target) -eq $expectedRoot) 'Runtime routing mixed SDK and target architectures.'
+ $expectedLauncherRoot = Get-DotNetRuntimeDirectory $expectedSdkRoot $launcherSdk $target
+ Assert-True ($launcherRoots -contains "DOTNET_ROOT_$($target.ToUpperInvariant())=$expectedLauncherRoot") 'IDE runtime roots depended on whether restore had already installed the target runtime.'
+ }
+ }
+ }
+
+ $env:PROCESSOR_ARCHITECTURE = 'AMD64'
+ $env:PROCESSOR_ARCHITEW6432 = 'ARM64'
+ Assert-True ((Get-WindowsBuildArchitecture) -eq 'arm64') 'An emulated shell hid the ARM64 OS architecture.'
+
+ foreach ($sdk in @('x64', 'arm64')) {
+ $other = if ($sdk -eq 'x64') { 'arm64' } else { 'x64' }
+ $root = "$testRoot\repair-$sdk"
+ Write-TestPE "$root\dotnet.exe" $sdk
+ Write-TestPE "$root\host\fxr\1.0.0\hostfxr.dll" $sdk
+ Write-TestPE "$root\shared\Microsoft.NETCore.App\1.0.0\coreclr.dll" $sdk
+ Write-TestPE "$root\host\fxr\2.0.0\hostfxr.dll" $other
+ Write-TestPE "$root\shared\Microsoft.NETCore.App\2.0.0\coreclr.dll" $other
+ Repair-DotNetInstallation $root
+ Assert-True ((Test-Path "$root\host\fxr\1.0.0\hostfxr.dll") -and (Test-Path "$root\shared\Microsoft.NETCore.App\1.0.0\coreclr.dll")) 'Compatible files were moved.'
+ Assert-True (!(Test-Path "$root\host\fxr\2.0.0") -and !(Test-Path "$root\shared\Microsoft.NETCore.App\2.0.0")) 'Foreign files remain in the live installation.'
+ Assert-True (@(Get-ChildItem "$root\legacy-runtimes" -Recurse -File).Count -eq 2) 'Foreign files were not preserved.'
+ Repair-DotNetInstallation $root
+ Assert-True (@(Get-ChildItem "$root\legacy-runtimes" -Recurse -File).Count -eq 2) 'Repair is not idempotent.'
+
+ New-Item -ItemType Directory -Path "$root\host\fxr\3.0.0", "$root\shared\Microsoft.NETCore.App\3.0.0" -Force | Out-Null
+ Repair-DotNetInstallation $root
+ Assert-True (!(Test-Path "$root\host\fxr\3.0.0") -and !(Test-Path "$root\shared\Microsoft.NETCore.App\3.0.0")) 'Incomplete version directories still block runtime discovery.'
+ Assert-True (@(Get-ChildItem "$root\legacy-runtimes\host\fxr" -Directory).Count -eq 2) 'Incomplete resolver folder was not preserved.'
+ Repair-DotNetInstallation $root
+ Assert-True (@(Get-ChildItem "$root\legacy-runtimes\host\fxr" -Directory).Count -eq 2) 'Incomplete resolver repair is not idempotent.'
+
+ $unsafeRoot = "$testRoot\no-compatible-resolver-$sdk"
+ Write-TestPE "$unsafeRoot\dotnet.exe" $sdk
+ Write-TestPE "$unsafeRoot\host\fxr\1.0.0\hostfxr.dll" $other
+ Write-TestPE "$unsafeRoot\shared\Microsoft.NETCore.App\1.0.0\coreclr.dll" $other
+ Assert-Throws { Repair-DotNetInstallation $unsafeRoot } 'No hostfxr compatible*'
+ Assert-True ((Test-Path "$unsafeRoot\host\fxr\1.0.0\hostfxr.dll") -and (Test-Path "$unsafeRoot\shared\Microsoft.NETCore.App\1.0.0\coreclr.dll")) 'Failed preflight moved files.'
+
+ $missingRoot = "$testRoot\missing-resolver-$sdk"
+ Write-TestPE "$missingRoot\dotnet.exe" $sdk
+ New-Item -ItemType Directory -Path "$missingRoot\host\fxr\3.0.0" -Force | Out-Null
+ Write-TestPE "$missingRoot\shared\Microsoft.NETCore.App\3.0.0\coreclr.dll" $other
+ Assert-Throws { Repair-DotNetInstallation $missingRoot } 'No hostfxr compatible*'
+ Assert-True ((Test-Path "$missingRoot\host\fxr\3.0.0") -and (Test-Path "$missingRoot\shared\Microsoft.NETCore.App\3.0.0\coreclr.dll")) 'Missing-resolver preflight moved files.'
+
+ foreach ($target in @('x64', 'arm64', 'x86')) {
+ $runtimeRoot = "$testRoot\runtime-$sdk-$target"
+ Write-TestPE "$runtimeRoot\dotnet.exe" $sdk
+ Write-TestPE "$runtimeRoot\host\fxr\1.0.0\hostfxr.dll" $sdk
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ Install-WinFormsDotNetRuntime $runtimeRoot $sdk $target '2.0.0' '' ''
+ $expectedRoot = Get-DotNetRuntimeDirectory $runtimeRoot $sdk $target
+ Assert-True ($installCalls.Count -eq 1 -and $installCalls[0].Root -eq $expectedRoot -and $installCalls[0].Architecture -eq $target) 'Runtime installer received the wrong directory or architecture.'
+ Assert-True ((Get-DotNetExecutableArchitecture "$runtimeRoot\dotnet.exe") -eq $sdk) 'Cross-target runtime replaced the SDK muxer.'
+ Install-WinFormsDotNetRuntime $runtimeRoot $sdk $target '2.0.0' '' ''
+ Assert-True ($installCalls.Count -eq 1) 'Complete runtime cache was not reused.'
+
+ Write-TestPE "$expectedRoot\shared\Microsoft.NETCore.App\3.0.0\coreclr.dll" $other
+ Install-WinFormsDotNetRuntime $runtimeRoot $sdk $target '3.0.0' '' ''
+ Assert-True ((Get-DotNetExecutableArchitecture "$expectedRoot\shared\Microsoft.NETCore.App\3.0.0\coreclr.dll") -eq $target) 'Partial or foreign runtime cache was not repaired.'
+ }
+
+ foreach ($case in @(
+ @{ Name = 'missing-hostpolicy'; File = 'shared\Microsoft.NETCore.App\2.0.0\hostpolicy.dll' },
+ @{ Name = 'missing-corelib'; File = 'shared\Microsoft.NETCore.App\2.0.0\System.Private.CoreLib.dll' },
+ @{ Name = 'foreign-hostpolicy'; File = 'shared\Microsoft.NETCore.App\2.0.0\hostpolicy.dll'; Architecture = $other },
+ @{ Name = 'missing-hostfxr'; File = 'host\fxr\2.0.0\hostfxr.dll' },
+ @{ Name = 'foreign-hostfxr'; File = 'host\fxr\2.0.0\hostfxr.dll'; Architecture = $other })) {
+ $cacheRoot = "$testRoot\$sdk-$($case.Name)"
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ Install-WinFormsDotNetRuntime $cacheRoot $sdk $sdk '2.0.0' '' ''
+ $file = Join-Path $cacheRoot $case.File
+ if ($case.Architecture) { Write-TestPE $file $case.Architecture }
+ else { Remove-Item -LiteralPath $file }
+
+ Install-WinFormsDotNetRuntime $cacheRoot $sdk $sdk '2.0.0' '' ''
+ Assert-True ($installCalls.Count -eq 2 -and (Test-Path -LiteralPath $file)) "Incomplete cache was reused: $($case.Name)"
+ if ($case.Architecture) {
+ Assert-True ((Get-DotNetExecutableArchitecture $file) -eq $sdk) "Foreign payload was not replaced: $($case.Name)"
+ }
+
+ $resolverBackups = "$cacheRoot\legacy-runtimes\host\fxr"
+ Assert-True ((Test-Path $resolverBackups) -eq $case.File.StartsWith('host\')) 'A healthy resolver was moved, or an incomplete resolver was not preserved.'
+ Install-WinFormsDotNetRuntime $cacheRoot $sdk $sdk '2.0.0' '' ''
+ Assert-True ($installCalls.Count -eq 2) 'Repaired runtime was not reused.'
+ }
+ }
+
+ foreach ($omittedFile in @('hostfxr.dll', 'System.Private.CoreLib.dll')) {
+ $omitRuntimeFile = $omittedFile
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ Assert-Throws {
+ Install-WinFormsDotNetRuntime "$testRoot\incomplete-install-$omittedFile" 'x64' 'x64' '2.0.0' '' ''
+ } "*incomplete or incompatible: *$omittedFile*legacy-runtimes*"
+ }
+ $omitRuntimeFile = $null
+ $wrongRuntimeFile = 'hostpolicy.dll'
+ Assert-Throws {
+ Install-WinFormsDotNetRuntime "$testRoot\foreign-install" 'x64' 'x64' '2.0.0' '' ''
+ } '*incomplete or incompatible: *hostpolicy.dll*'
+ $wrongRuntimeFile = $null
+
+ # Exercise the repository's Arcade extension, including native fresh bootstrap and
+ # preserving an x64 SDK while the OS (and requested target) is ARM64.
+ $RepoRoot = $testRoot
+ $GlobalJson = @{ tools = @{ dotnet = '4.0.0' } }
+ $runtimeSourceFeed = ''
+ $runtimeSourceFeedKey = ''
+ foreach ($os in @('AMD64', 'ARM64')) {
+ $env:PROCESSOR_ARCHITECTURE = $os
+ $env:PROCESSOR_ARCHITEW6432 = $null
+ $env:DOTNET_GLOBAL_INSTALL_DIR = "$testRoot\bootstrap-$os"
+ $env:DOTNET_ROOT_ARM64 = 'stale runtime root'
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ $genericRoot = $env:DOTNET_ROOT
+ . "$PSScriptRoot\..\configure-toolset.ps1"
+ $rootsBeforeInstall = @($env:DOTNET_ROOT_X86, $env:DOTNET_ROOT_X64, $env:DOTNET_ROOT_ARM64)
+ InstallDotNetSdk $env:DOTNET_GLOBAL_INSTALL_DIR '4.0.0'
+ $expected = if ($os -eq 'ARM64') { 'arm64' } else { 'x64' }
+ Assert-True ($installCalls[0].Architecture -eq $expected) 'Fresh SDK bootstrap used the shell instead of the OS architecture.'
+ Assert-True ($env:DOTNET_ROOT -eq $genericRoot) 'Toolset initialization retargeted the generic runtime root.'
+ $index = 0
+ foreach ($architecture in @('x86', 'x64', 'arm64')) {
+ $runtimeRoot = Get-DotNetRuntimeDirectory $env:DOTNET_GLOBAL_INSTALL_DIR $expected $architecture
+ Assert-True ($rootsBeforeInstall[$index++] -eq $runtimeRoot) 'Pre-install runtime roots do not match the bootstrapped SDK.'
+ }
+ }
+
+ $env:PROCESSOR_ARCHITECTURE = 'ARM64'
+ $env:DOTNET_GLOBAL_INSTALL_DIR = "$testRoot\emulated-sdk"
+ Write-TestPE "$env:DOTNET_GLOBAL_INSTALL_DIR\dotnet.exe" 'x64'
+ Write-TestPE "$env:DOTNET_GLOBAL_INSTALL_DIR\host\fxr\1.0.0\hostfxr.dll" 'x64'
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ . "$PSScriptRoot\..\configure-toolset.ps1"
+ InstallDotNetSdk $env:DOTNET_GLOBAL_INSTALL_DIR '4.0.0'
+ Assert-True ($installCalls[0].Architecture -eq 'x64') 'SDK upgrade replaced an emulated x64 installation.'
+ Assert-True ($env:DOTNET_ROOT_X64 -eq $env:DOTNET_GLOBAL_INSTALL_DIR -and $env:DOTNET_ROOT_ARM64 -eq "$env:DOTNET_GLOBAL_INSTALL_DIR\arm64") 'Emulated SDK did not export independent apphost runtime roots.'
+
+ $env:PROCESSOR_ARCHITECTURE = 'AMD64'
+ $originalRoot = "$testRoot\arm64-sdk-on-x64"
+ $env:DOTNET_GLOBAL_INSTALL_DIR = $originalRoot
+ Write-TestPE "$originalRoot\dotnet.exe" 'arm64'
+ Write-TestPE "$originalRoot\host\fxr\1.0.0\hostfxr.dll" 'arm64'
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ . "$PSScriptRoot\..\configure-toolset.ps1"
+ Assert-True ($env:DOTNET_GLOBAL_INSTALL_DIR -eq "$originalRoot\x64") 'An unexecutable SDK was not isolated before bootstrap.'
+ InstallDotNetSdk $env:DOTNET_GLOBAL_INSTALL_DIR '4.0.0'
+ Assert-True ($installCalls[0].Architecture -eq 'x64' -and (Get-DotNetExecutableArchitecture "$originalRoot\dotnet.exe") -eq 'arm64') 'Native bootstrap replaced the preserved ARM64 SDK.'
+
+ $installerRepo = "$testRoot\installer"
+ New-Item -ItemType Directory -Path "$installerRepo\eng\common" -Force | Out-Null
+ Copy-Item "$PSScriptRoot\..\install-runtimes.ps1" "$installerRepo\eng\install-runtimes.ps1"
+ Copy-Item "$PSScriptRoot\..\dotnet-architecture.ps1" "$installerRepo\eng\dotnet-architecture.ps1"
+ '$GlobalJson = Get-Content "$PSScriptRoot\..\..\global.json" -Raw | ConvertFrom-Json' |
+ Set-Content "$installerRepo\eng\common\tools.ps1"
+ Copy-Item "$PSScriptRoot\..\..\global.json" "$installerRepo\global.json"
+ foreach ($sdk in @('x64', 'arm64')) {
+ $env:PROCESSOR_ARCHITECTURE = if ($sdk -eq 'arm64') { 'ARM64' } else { 'AMD64' }
+ $sdkRoot = "$installerRepo\sdk $sdk"
+ $env:DOTNET_GLOBAL_INSTALL_DIR = $sdkRoot
+ $installCalls = [System.Collections.Generic.List[object]]::new()
+ . "$PSScriptRoot\..\configure-toolset.ps1"
+ InstallDotNetSdk $sdkRoot '4.0.0'
+ $installCalls.Clear()
+ & "$installerRepo\eng\install-runtimes.ps1" -SdkRoot $sdkRoot -RuntimeVersion '5.0.0' -TargetArchitecture $sdk
+ Assert-True ($installCalls.Count -eq 2) 'Fresh native restore did not install SDK-native and x86 runtimes.'
+ $target = if ($sdk -eq 'x64') { 'arm64' } else { 'x64' }
+ Assert-True (!(Test-Path "$sdkRoot\$target")) 'Fresh native restore installed an unrequested foreign runtime.'
+ Assert-True ((Get-DotNetExecutableArchitecture "$sdkRoot\dotnet.exe") -eq $sdk -and
+ (Get-DotNetExecutableArchitecture "$sdkRoot\host\fxr\5.0.0\hostfxr.dll") -eq $sdk) 'Fresh restore mixed SDK and resolver architectures.'
+
+ & "$installerRepo\eng\install-runtimes.ps1" -SdkRoot $sdkRoot -RuntimeVersion '5.0.0' -TargetArchitecture $target
+ Assert-True ($installCalls.Count -eq 3) 'Runtime manifest did not install SDK, cross-target, and x86 runtimes.'
+ foreach ($architecture in @($sdk, $target, 'x86')) {
+ $expectedRoot = Get-DotNetRuntimeDirectory $sdkRoot $sdk $architecture
+ Assert-True (@($installCalls | Where-Object { $_.Root -eq $expectedRoot -and $_.Architecture -eq $architecture -and $_.Version -eq '5.0.0' }).Count -eq 1) 'Manifest installation used the wrong root, version, or architecture.'
+ }
+ & "$installerRepo\eng\install-runtimes.ps1" -SdkRoot $sdkRoot -RuntimeVersion '5.0.0' -TargetArchitecture $sdk
+ Assert-True ($installCalls.Count -eq 3) 'Duplicate manifest requests bypassed the complete runtime cache.'
+ }
+
+ $repoRoot = [IO.Path]::GetFullPath("$PSScriptRoot\..\..\")
+ foreach ($launcher in @('start-vs.cmd', 'start-code.cmd')) {
+ $commands = [regex]::Matches((Get-Content (Join-Path $repoRoot $launcher) -Raw), "'(?powershell [^']+)'")
+ Assert-True ($commands.Count -eq 2) "Could not find SDK/runtime queries in $launcher."
+ foreach ($match in $commands) {
+ $command = $match.Groups['Command'].Value.Replace('%~dp0', $repoRoot).Replace('%DOTNET_ROOT%', "$sdkRoot\")
+ $resolved = @(& $env:ComSpec /d /c $command)
+ Assert-True ($LASTEXITCODE -eq 0) "Trailing-backslash SDK query failed in $launcher."
+ if ($command -like '*-ResolveRuntimeRoots') {
+ Assert-True ($resolved -contains "DOTNET_ROOT_ARM64=$sdkRoot") "Runtime query corrupted a trailing-backslash root in $launcher."
+ }
+ else {
+ Assert-True ($resolved.Count -eq 1 -and $resolved[0] -eq $sdkRoot) "SDK query corrupted a trailing-backslash root in $launcher."
+ }
+ }
+ }
+
+ Assert-Throws { Get-DotNetRuntimeDirectory $testRoot 'x64' 'invalid' } 'Unsupported .NET runtime architecture*'
+ [IO.File]::WriteAllBytes("$testRoot\invalid.exe", [byte[]]::new(128))
+ Assert-Throws { Get-DotNetExecutableArchitecture "$testRoot\invalid.exe" } 'Invalid PE file*'
+ Write-Host 'Bidirectional SDK and runtime architecture checks passed.'
+}
+finally {
+ foreach ($name in $savedEnvironment.Keys) {
+ [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name])
+ }
+ if (Test-Path -LiteralPath $testRoot) {
+ Remove-Item -LiteralPath $testRoot -Recurse -Force
+ }
+}
diff --git a/global.json b/global.json
index 6bcdd3f22fa..5bff8e11369 100644
--- a/global.json
+++ b/global.json
@@ -4,15 +4,15 @@
"allowPrerelease": true,
"rollForward": "latestFeature",
"paths": [
- ".dotnet",
- "$host$"
+ "$host$",
+ ".dotnet"
],
"errorMessage": "The required .NET SDK wasn't found. Please run ./eng/common/dotnet.cmd/sh to install it."
},
"tools": {
"dotnet": "12.0.100-alpha.1.26480.102",
"runtimes": {
- "dotnet/x64": [
+ "dotnet": [
"$(MicrosoftNETCoreAppRefPackageVersion)"
],
"dotnet/x86": [
diff --git a/src/test/integration/ScratchProject/ScratchProject.csproj b/src/test/integration/ScratchProject/ScratchProject.csproj
index 7273829aade..56cc7438b77 100644
--- a/src/test/integration/ScratchProject/ScratchProject.csproj
+++ b/src/test/integration/ScratchProject/ScratchProject.csproj
@@ -19,7 +19,8 @@
false
false
- win-x64
+ win-$(TargetArchitecture)
+ win-x64
diff --git a/start-code.cmd b/start-code.cmd
index 08af10160fb..3f5e4232e3c 100644
--- a/start-code.cmd
+++ b/start-code.cmd
@@ -13,7 +13,13 @@ IF ["%vscode%"] == [""] (
:: This tells .NET Core to use the same dotnet.exe that build scripts use
SET DOTNET_ROOT=%~dp0.dotnet
-SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86
+IF DEFINED DOTNET_GLOBAL_INSTALL_DIR SET "DOTNET_ROOT=%DOTNET_GLOBAL_INSTALL_DIR%"
+SET "__DotNetSdkRoot="
+FOR /f "delims=" %%r IN ('powershell -NoProfile -ExecutionPolicy ByPass -File "%~dp0eng\dotnet-architecture.ps1" -ResolveSdkRoot "%DOTNET_ROOT%\."') DO SET "__DotNetSdkRoot=%%r"
+IF NOT DEFINED __DotNetSdkRoot EXIT /b 1
+SET "DOTNET_ROOT=%__DotNetSdkRoot%"
+SET "DOTNET_GLOBAL_INSTALL_DIR=%DOTNET_ROOT%"
+SET "__DotNetSdkRoot="
:: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use
SET PATH=%DOTNET_ROOT%;%PATH%
@@ -23,4 +29,7 @@ IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" (
exit /b 1
)
+FOR /f "delims=" %%r IN ('powershell -NoProfile -ExecutionPolicy ByPass -File "%~dp0eng\dotnet-architecture.ps1" -ResolveSdkRoot "%DOTNET_ROOT%\." -ResolveRuntimeRoots') DO SET "%%r"
+SET "DOTNET_ROOT(x86)=%DOTNET_ROOT_X86%"
+
"%vscode%" "."
\ No newline at end of file
diff --git a/start-vs.cmd b/start-vs.cmd
index 0c92b4692fc..1df3004c00d 100644
--- a/start-vs.cmd
+++ b/start-vs.cmd
@@ -5,18 +5,28 @@ setlocal enabledelayedexpansion
:: This tells .NET Core to use the same dotnet.exe that build scripts use
set DOTNET_ROOT=%~dp0.dotnet
-set DOTNET_ROOT(x86)=%~dp0.dotnet\x86
+if defined DOTNET_GLOBAL_INSTALL_DIR set "DOTNET_ROOT=%DOTNET_GLOBAL_INSTALL_DIR%"
+set "__DotNetSdkRoot="
+for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy ByPass -File "%~dp0eng\dotnet-architecture.ps1" -ResolveSdkRoot "%DOTNET_ROOT%\."') do set "__DotNetSdkRoot=%%r"
+if not defined __DotNetSdkRoot exit /b 1
+set "DOTNET_ROOT=%__DotNetSdkRoot%"
+set "DOTNET_GLOBAL_INSTALL_DIR=%DOTNET_ROOT%"
+set "__DotNetSdkRoot="
:: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use
set PATH=%DOTNET_ROOT%;%PATH%
call restore.cmd
+if errorlevel 1 exit /b %ErrorLevel%
if not exist "%DOTNET_ROOT%\dotnet.exe" (
echo [ERROR] .NET Core has not yet been installed. Run `%~dp0restore.cmd` to install tools
exit /b 1
)
+for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy ByPass -File "%~dp0eng\dotnet-architecture.ps1" -ResolveSdkRoot "%DOTNET_ROOT%\." -ResolveRuntimeRoots') do set "%%r"
+set "DOTNET_ROOT(x86)=%DOTNET_ROOT_X86%"
+
:: Prefer the VS in the developer command prompt if we're in one, followed by whatever shows up in the current search path.
set "DEVENV=%DevEnvDir%devenv.exe"