From 3cb0e16b9160383014d7bcbdfd403ab3c7799999 Mon Sep 17 00:00:00 2001 From: Jakub Jares Date: Tue, 18 Aug 2026 16:54:37 +0200 Subject: [PATCH 1/3] Use powershell -File in the Windows .cmd wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrappers spliced %* into a -command string that PowerShell then parsed as source code. -projects "a.csproj;b.csproj" arrived as projects=[a.csproj] and PowerShell tried to execute b.csproj as a second statement, swallowing the rest of the line. Paths with spaces split on the space. Exit codes collapsed to 1. With -File everything after the script path reaches the target script as a literal argument. Under -File no string binds to a [bool] parameter, not even 0 or 1, so -warnAsError $false through a wrapper would fail. Relax warnAsError, nodeReuse and msbuildMultiThreaded in build.ps1 and msbuild.ps1, and normalize the value in tools.ps1 instead, so existing callers keep working unchanged in both the .cmd path and the direct powershell path. An unrecognized value is an error, not a silently flipped flag. Switch parameters still cannot be negated through a wrapper, -ci:$false does not work under -File. Omit the switch or call build.ps1 directly. 🤖 --- Build.cmd | 2 +- Documentation/ArcadeSdk.md | 21 +++++++++++++++++++++ Restore.cmd | 2 +- Test.cmd | 2 +- eng/common/CIBuild.cmd | 2 +- eng/common/build.cmd | 2 +- eng/common/build.ps1 | 6 +++--- eng/common/dotnet-install.cmd | 2 +- eng/common/dotnet.cmd | 2 +- eng/common/init-tools-native.cmd | 2 +- eng/common/msbuild.ps1 | 6 +++--- eng/common/tools.ps1 | 28 +++++++++++++++++++++++++--- 12 files changed, 60 insertions(+), 17 deletions(-) diff --git a/Build.cmd b/Build.cmd index 675fdf83f6a..d4b1de84fc7 100644 --- a/Build.cmd +++ b/Build.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0eng\common\Build.ps1""" -restore -build %*" +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -restore -build %* diff --git a/Documentation/ArcadeSdk.md b/Documentation/ArcadeSdk.md index 20278df7cbb..a8a225d07c3 100644 --- a/Documentation/ArcadeSdk.md +++ b/Documentation/ArcadeSdk.md @@ -119,6 +119,24 @@ Since the default scripts pass along additional arguments, you could restore, bu You should feel free to create more repo specific scripts as appropriate to meet common dev scenarios for your repo. +#### Passing arguments on Windows + +The `.cmd` wrappers invoke PowerShell with `-File`, so everything after the script path reaches the +target script as a literal argument. Arguments are not re-parsed as PowerShell source, which means +`;` and spaces are safe: + +``` +build.cmd -projects "src\A\A.csproj;src\B\B.csproj" /p:Foo=Bar +build.cmd -projects "C:\my dir\A.csproj" +``` + +Two consequences of arguments arriving as literal strings: + +- Boolean parameters (`-warnAsError`, `-nodeReuse`, `-msbuildMultiThreaded`) accept `$true`, `true`, + `1`, `$false`, `false` and `0`. Anything else is an error rather than a silently flipped value. +- Switch parameters cannot be negated. `-ci:$false` does not work through a `.cmd` wrapper; omit the + switch instead, or call `eng\common\build.ps1` directly. + ### /eng/common/* The Arcade SDK requires bootstrapper scripts to be present in the repo. @@ -137,6 +155,9 @@ By default, Arcade builds solutions in the root of the repo. Overriding the def Example: `build.cmd -projects MyProject.proj` + A semi-colon delimited list works too, quoted so that `cmd` passes it as one argument: + `build.cmd -projects "src\A\A.csproj;src\B\B.csproj"` + See [source code](https://github.com/dotnet/arcade/blob/440b2dae3a206b28f6aba727b7818873358fcc0a/eng/common/build.ps1#L53) - Provide a list of projects or solutions in `eng/Build.props`. diff --git a/Restore.cmd b/Restore.cmd index f9b8e80f15e..e7184d3ada3 100644 --- a/Restore.cmd +++ b/Restore.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0eng\common\Build.ps1""" -restore %*" \ No newline at end of file +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -restore %* \ No newline at end of file diff --git a/Test.cmd b/Test.cmd index 802b44d7726..6b6ba0856fc 100644 --- a/Test.cmd +++ b/Test.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0eng\common\Build.ps1""" -test %*" \ No newline at end of file +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -test %* \ No newline at end of file diff --git a/eng/common/CIBuild.cmd b/eng/common/CIBuild.cmd index ac1f72bf94e..3e67d53cb66 100644 --- a/eng/common/CIBuild.cmd +++ b/eng/common/CIBuild.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0Build.ps1""" -restore -build -test -sign -pack -publish -ci %*" +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0Build.ps1" -restore -build -test -sign -pack -publish -ci %* diff --git a/eng/common/build.cmd b/eng/common/build.cmd index 99daf368aba..c64ee69a7d9 100644 --- a/eng/common/build.cmd +++ b/eng/common/build.cmd @@ -1,3 +1,3 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0build.ps1""" %*" +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* exit /b %ErrorLevel% diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index fee2f839919..0b228fa70bb 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -5,10 +5,10 @@ Param( [string] $projects, [string][Alias('v')]$verbosity = "minimal", [string] $msbuildEngine = $null, - [bool] $warnAsError = $true, + $warnAsError = $true, [string] $warnNotAsError = '', - [bool] $nodeReuse = $true, - [bool][Alias('mt')]$msbuildMultiThreaded = $false, + $nodeReuse = $true, + [Alias('mt')]$msbuildMultiThreaded = $false, [switch] $buildCheck = $false, [switch][Alias('r')]$restore, [switch] $deployDeps, diff --git a/eng/common/dotnet-install.cmd b/eng/common/dotnet-install.cmd index b1c2642e76f..aa6bac761da 100644 --- a/eng/common/dotnet-install.cmd +++ b/eng/common/dotnet-install.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0dotnet-install.ps1""" %*" \ No newline at end of file +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0dotnet-install.ps1" %* \ No newline at end of file diff --git a/eng/common/dotnet.cmd b/eng/common/dotnet.cmd index 527fa4bb38f..e7ee8f5832a 100644 --- a/eng/common/dotnet.cmd +++ b/eng/common/dotnet.cmd @@ -3,5 +3,5 @@ :: This script is used to install the .NET SDK. :: It will also invoke the SDK with any provided arguments. -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0dotnet.ps1""" %*" +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0dotnet.ps1" %* exit /b %ErrorLevel% diff --git a/eng/common/init-tools-native.cmd b/eng/common/init-tools-native.cmd index 438cd548c45..e742c8e16c6 100644 --- a/eng/common/init-tools-native.cmd +++ b/eng/common/init-tools-native.cmd @@ -1,3 +1,3 @@ @echo off -powershell -NoProfile -NoLogo -ExecutionPolicy ByPass -command "& """%~dp0init-tools-native.ps1""" %*" +powershell -NoProfile -NoLogo -ExecutionPolicy ByPass -File "%~dp0init-tools-native.ps1" %* exit /b %ErrorLevel% \ No newline at end of file diff --git a/eng/common/msbuild.ps1 b/eng/common/msbuild.ps1 index b6dfb570ea5..555e8d72d11 100644 --- a/eng/common/msbuild.ps1 +++ b/eng/common/msbuild.ps1 @@ -1,9 +1,9 @@ [CmdletBinding(PositionalBinding=$false)] Param( [string] $verbosity = 'minimal', - [bool] $warnAsError = $true, - [bool] $nodeReuse = $true, - [bool][Alias('mt')]$msbuildMultiThreaded = $false, + $warnAsError = $true, + $nodeReuse = $true, + [Alias('mt')]$msbuildMultiThreaded = $false, [switch] $ci, [switch] $prepareMachine, [switch] $excludePrereleaseVS, diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index e84033dad90..8712bb528e5 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -1,6 +1,28 @@ # Initialize variables if they aren't already defined. # These may be defined as parameters of the importing script, or set after importing this script. +# Wrapper scripts (build.cmd, CIBuild.cmd, ...) invoke PowerShell with -File, which passes every +# argument to the script as a literal string. '-warnAsError $false' therefore arrives as the text +# '$false', which does not bind to [bool]. Accept the spellings a caller can reasonably produce and +# fail on anything else, so that a typo is an error rather than a silently flipped value. +function ParseBooleanArgument([string] $name, $value) { + if ($value -is [bool]) { + return $value + } + + $text = "$value".Trim() + + if ($text -in '$true', 'true', '1') { + return $true + } + + if ($text -in '$false', 'false', '0') { + return $false + } + + throw "Invalid value '$value' for -$name. Expected one of: `$true, true, 1, `$false, false, 0." +} + # CI mode - set to true on CI server for PR validation build or official build. [bool]$ci = if (Test-Path variable:ci) { $ci } else { $false } @@ -29,14 +51,14 @@ [string]$verbosity = if (Test-Path variable:verbosity) { $verbosity } else { 'minimal' } # Set to true to reuse msbuild nodes. Recommended to not reuse on CI. -[bool]$nodeReuse = if (Test-Path variable:nodeReuse) { $nodeReuse } else { !$ci } +[bool]$nodeReuse = if ((Test-Path variable:nodeReuse) -and ($null -ne $nodeReuse)) { ParseBooleanArgument 'nodeReuse' $nodeReuse } else { !$ci } # Set to true to build with MSBuild's multi-threaded mode (-mt). Opt-in for now, so off unless it was # explicitly requested. It's intended to become the default for local builds once it has proven out. -[bool]$msbuildMultiThreaded = if (Test-Path variable:msbuildMultiThreaded) { $msbuildMultiThreaded } else { $false } +[bool]$msbuildMultiThreaded = if ((Test-Path variable:msbuildMultiThreaded) -and ($null -ne $msbuildMultiThreaded)) { ParseBooleanArgument 'msbuildMultiThreaded' $msbuildMultiThreaded } else { $false } # Configures warning treatment in msbuild. -[bool]$warnAsError = if (Test-Path variable:warnAsError) { $warnAsError } else { $true } +[bool]$warnAsError = if ((Test-Path variable:warnAsError) -and ($null -ne $warnAsError)) { ParseBooleanArgument 'warnAsError' $warnAsError } else { $true } # Specifies semi-colon delimited list of warning codes that should not be treated as errors. # Defaults to NuGet Audit warning codes NU1901-NU1904. From 68b4d8f37d9c88a98dbd2c25b6df31540c991e5c Mon Sep 17 00:00:00 2001 From: Jakub Jares Date: Tue, 18 Aug 2026 16:59:27 +0200 Subject: [PATCH 2/3] Document the [bool] param change for repos with a custom eng/build.ps1 A repo's own eng/build.ps1 is not owned by Arcade, so darc will not fix it. Repos copying this pattern into their own wrappers hit the same binding failure, and the ordering makes it non-obvious: the param block binds before tools.ps1 is dot-sourced, so the constraint has to come off at the param. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/ArcadeSdk.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Documentation/ArcadeSdk.md b/Documentation/ArcadeSdk.md index a8a225d07c3..39eb25f5f96 100644 --- a/Documentation/ArcadeSdk.md +++ b/Documentation/ArcadeSdk.md @@ -137,6 +137,14 @@ Two consequences of arguments arriving as literal strings: - Switch parameters cannot be negated. `-ci:$false` does not work through a `.cmd` wrapper; omit the switch instead, or call `eng\common\build.ps1` directly. +If your repo has its own `eng/build.ps1` and you switch your own `.cmd` wrappers to `-File`, remove +the `[bool]` type constraint from any parameter that can be passed on the command line. A `[bool]` +parameter cannot bind a string, and under `-File` every argument is a string, so `-warnAsError $false` +fails with `Cannot convert value "System.String" to type "System.Boolean"`. No value works, `0` +included. The parameter block binds before `eng/common/tools.ps1` is dot-sourced, so this cannot be +fixed inside `tools.ps1`. Drop the constraint at the parameter, and let `tools.ps1` normalize the +value afterwards, the way `eng/common/build.ps1` does. + ### /eng/common/* The Arcade SDK requires bootstrapper scripts to be present in the repo. From 9b75f12d71f7c3f543b48224b439d3d205eb28aa Mon Sep 17 00:00:00 2001 From: Jakub Jares Date: Tue, 18 Aug 2026 17:12:09 +0200 Subject: [PATCH 3/3] Match the script path casing to the file on disk The file is eng/common/build.ps1, but four wrappers spelled it Build.ps1. Same normalization microsoft/vstest#16363 did. Makes the wrappers match the repo and makes a grep for build.ps1 find these lines. --- Build.cmd | 2 +- Restore.cmd | 2 +- Test.cmd | 2 +- eng/common/CIBuild.cmd | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Build.cmd b/Build.cmd index d4b1de84fc7..29c8a52538c 100644 --- a/Build.cmd +++ b/Build.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -restore -build %* +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\build.ps1" -restore -build %* diff --git a/Restore.cmd b/Restore.cmd index e7184d3ada3..62594161156 100644 --- a/Restore.cmd +++ b/Restore.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -restore %* \ No newline at end of file +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\build.ps1" -restore %* \ No newline at end of file diff --git a/Test.cmd b/Test.cmd index 6b6ba0856fc..6a4c038c298 100644 --- a/Test.cmd +++ b/Test.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\Build.ps1" -test %* \ No newline at end of file +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0eng\common\build.ps1" -test %* \ No newline at end of file diff --git a/eng/common/CIBuild.cmd b/eng/common/CIBuild.cmd index 3e67d53cb66..a9d264c3e7a 100644 --- a/eng/common/CIBuild.cmd +++ b/eng/common/CIBuild.cmd @@ -1,2 +1,2 @@ @echo off -powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0Build.ps1" -restore -build -test -sign -pack -publish -ci %* +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" -restore -build -test -sign -pack -publish -ci %*