Skip to content

Use powershell -File in the Windows .cmd wrappers - #17364

Open
nohwnd wants to merge 3 commits into
dotnet:mainfrom
nohwnd:nohwnd-cmd-wrappers-powershell-file
Open

Use powershell -File in the Windows .cmd wrappers#17364
nohwnd wants to merge 3 commits into
dotnet:mainfrom
nohwnd:nohwnd-cmd-wrappers-powershell-file

Conversation

@nohwnd

@nohwnd nohwnd commented Aug 18, 2026

Copy link
Copy Markdown
Member

Every .cmd wrapper splices %* into a -command string that PowerShell then parses as source code:

powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0build.ps1""" %*"

So ; in an argument becomes a statement separator, and a space splits the argument in two. Semi-colon delimited -projects is our own documented usage (build.ps1 -help: "Semi-colon delimited list of sln/proj's to build"), and on Windows .csproj is file-associated with Visual Studio, so every entry after the first can open an IDE.

Measured on Windows PowerShell 5.1 with the exact wrapper form:

input -command -File
-projects "a.csproj;b.csproj" /p:Foo=Bar projects=[a.csproj], then PowerShell runs b.csproj as a command and swallows /p:Foo=Bar into that statement projects=[a.csproj;b.csproj], properties=[/p:Foo=Bar]
-projects "C:\my dir\a.csproj" projects=[C:\my], properties=[dir\a.csproj] projects=[C:\my dir\a.csproj]
script exits with a non-zero code collapses to 1 forwarded as is

The exit code one matters for eng/common/dotnet.cmd, which forwards dotnet.exe results through ExitWithExitCode $LASTEXITCODE, and for CIBuild.cmd. Real measurement against the current dotnet.ps1, dotnet exec on a missing dll:

dotnet.exe directly = -2147450751
old -command form   = 1
new -File form      = -2147450751

With -File everything after the script path reaches the target script as a literal argument, so all three go away.

The semicolon form may well have no users today, and that is part of why it is worth fixing now. What surfaced it was an agent reading the documented syntax, using it as documented, and opening a large number of Visual Studio instances, one per project after the first. Documented syntax that quietly does something destructive instead of failing is a trap rather than a feature. Low usage cuts in favour of the change, not against it: the blast radius of fixing it is small.

Why not keep -command and validate instead

A reasonable alternative is a guard in the .cmd itself, before PowerShell is invoked, rejecting any command line containing ;. That does work, I measured it. I did not take it, for four reasons.

It bans legitimate usage. /p:NoWarn="NU1605;CS0168" and /p:DefineConstants="A;B" are everyday MSBuild, and -warnNotAsError is documented in tools.ps1 as a semi-colon delimited list of warning codes. A batch-level guard cannot tell a semicolon I meant from a semicolon that will detonate, so it has to reject all of them.

It leaves spaces broken. -projects "C:\my dir\a.csproj" still splits into two arguments.

It leaves exit codes collapsed to 1, which is the dotnet.cmd problem above.

It covers one character out of a set. ; is not special here, the whole command line is being parsed as source. Measured with /p:Msg="a&b":

-command  props=[/p:Msg=a]     and then tries to run b as a command
-File     props=[/p:Msg=a&b]

| and parentheses behave the same way. And every consuming repo would need the same guard pasted into its own wrappers, forever, with any repo that forgets falling back to the original silent behaviour. -File fixes the class at the source, the guard patches the one instance we tripped over.

Boolean parameters

This is the part that needed care. Under -File every argument arrives as a string, and no string binds to a [bool] parameter. Not $false, not false, not 0 or 1. They all fail with

Cannot convert value "System.String" to type "System.Boolean".

Repos call eng\common\cibuild.cmd -warnAsError $false today, so a wrappers-only change would break them, and there is no replacement value to migrate to. Instead of breaking them I relaxed warnAsError, nodeReuse and msbuildMultiThreaded in build.ps1 and msbuild.ps1, and normalize the value in tools.ps1 where all three are already coerced to [bool]. $true, true, 1, $false, false and 0 are accepted, anything else throws. I deliberately did not make an unrecognized value fall back to a default, a typo like -warnAsError ture should fail rather than silently flip the flag.

So consuming repos need no changes. Both call paths keep working:

  • cibuild.cmd -warnAsError $false arrives as the literal string $false and normalizes to False
  • build.ps1 -warnAsError $false from a - powershell: step is still a real boolean and passes through untouched

Nothing in this repo needed updating either. The -warnAsError $false lines in azure-pipelines-pr.yml are on - powershell: steps that call build.ps1 directly, and no .cmd call site here passes a boolean.

Known limitation

Switch parameters cannot be negated through a wrapper. -ci:$false works under -command and fails under -File, and there is no workaround other than omitting the switch or calling eng\common\build.ps1 directly. Nothing in Arcade does this, but a consuming repo might, so it is documented in ArcadeSdk.md.

Verified locally

  • Restore.cmd -warnAsError $false -nodeReuse false succeeds, exit 0
  • Build.cmd -projects "C:\my dir\a.csproj;..." receives the path with the space intact
  • all six accepted boolean spellings bind through eng\common\build.cmd, -warnAsError ture and an empty value fail with exit 1
  • init-tools-native.cmd -DownloadRetries 3 -RetryWaitTimeInSeconds 2 -PathPromotion still binds [int] and [switch] parameters
  • dotnet.cmd exit code check above
  • /p:Msg="a&b" passthrough, and the ; guard alternative above

Four wrappers spelled the script Build.ps1 while the file on disk is build.ps1. Corrected on the lines I was already touching, same normalization vstest did.

Same fix in vstest: microsoft/vstest#16363. That one changed only vstest's own wrappers and left eng/common to be fixed here.

🤖

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.

🤖
Copilot AI lite review requested due to automatic review settings August 18, 2026 14:55
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates Windows .cmd wrapper scripts to invoke PowerShell with -File instead of embedding arguments into -command, preventing argument re-parsing (e.g., ; statement separators, spaces splitting) and preserving downstream exit codes. To maintain compatibility for consuming repos that pass boolean values through wrappers, the PR relaxes selected boolean parameters in PowerShell entrypoints and normalizes them centrally.

Changes:

  • Switch Windows .cmd wrappers from powershell ... -command "& ... %*" to powershell ... -File "script.ps1" %* for safer argument forwarding and correct exit-code propagation.
  • Relax warnAsError, nodeReuse, and msbuildMultiThreaded parameter typing and add centralized boolean-string normalization in eng/common/tools.ps1.
  • Document Windows wrapper argument behavior and limitations in Documentation/ArcadeSdk.md.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
Test.cmd Uses -File when invoking eng\common\Build.ps1 for test runs.
Restore.cmd Uses -File when invoking eng\common\Build.ps1 for restore runs.
Build.cmd Uses -File when invoking eng\common\Build.ps1 for restore/build runs.
eng/common/build.cmd Uses -File when invoking build.ps1 and preserves %ErrorLevel%.
eng/common/CIBuild.cmd Uses -File when invoking Build.ps1 with CI build flags.
eng/common/dotnet.cmd Uses -File for dotnet.ps1 to preserve argument/exit-code behavior.
eng/common/dotnet-install.cmd Uses -File for dotnet-install.ps1.
eng/common/init-tools-native.cmd Uses -File for init-tools-native.ps1 while preserving %ErrorLevel%.
eng/common/build.ps1 Loosens specific boolean parameter types so wrapper-passed strings can be normalized later.
eng/common/msbuild.ps1 Loosens specific boolean parameter types so wrapper-passed strings can be normalized later.
eng/common/tools.ps1 Adds ParseBooleanArgument and normalizes select boolean-like arguments from wrappers.
Documentation/ArcadeSdk.md Documents Windows wrapper argument passing, boolean spellings, and switch-negation limitation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Build.cmd Outdated
Comment thread Test.cmd Outdated
Comment thread Restore.cmd Outdated
Comment thread eng/common/CIBuild.cmd Outdated
Copilot AI review requested due to automatic review settings August 18, 2026 15:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

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.
Copilot AI review requested due to automatic review settings August 18, 2026 15:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants