When a Windows GitLab Runner continues after an earlier command has failed, or when a command that works locally is interpreted differently in CI, the build tool is often blamed too early. The actual boundary may be the selected shell, YAML parsing, PowerShell command resolution, or an unpropagated native process exit code. A dependable correction starts by identifying whether the runner invokes powershell or pwsh, then making every critical step observable and fail-fast.
This guide focuses narrowly on Windows shell-executor semantics and failure propagation. It does not repeat broad CI/CD governance advice about approvals and rollback, and it does not cover atomic DLL deployment. Its search intent is practical: explain why cmd syntax fails under PowerShell and how to ensure that native command failure stops a pipeline. All reproduction steps use disposable files. They do not connect to a production system, stop a service, remove a website, or perform a deployment.
Direct answer: identify the shell before defining failure behavior
Direct answer: A Windows runner script is not an abstract command prompt. GitLab Runner generates a script for the configured shell, so first record whether the job uses Windows PowerShell or PowerShell Core. Replace cmd-only syntax with explicit PowerShell cmdlets, or call cmd.exe /c deliberately, and check $LASTEXITCODE immediately after every critical native program.
GitLab's official shell documentation lists powershell and pwsh as separate execution contexts. The former selects PowerShell Desktop, while the latter selects PowerShell Core. Runner saves generated content and invokes the corresponding executable. Text in .gitlab-ci.yml therefore does not acquire cmd.exe semantics merely because the host is Windows.
The distinction is visible with a familiar command. In cmd.exe, copy /Y source destination is handled by a cmd built-in. In Windows PowerShell, copy commonly resolves to an alias for Copy-Item; /Y is not a valid Copy-Item parameter. The word is identical, but the parser, command target, and parameter grammar differ.
Failure has multiple channels as well. PowerShell cmdlet errors are influenced by $ErrorActionPreference and -ErrorAction. Native tools normally report status through their process exit code, exposed as $LASTEXITCODE. Runner may add checks at boundaries in the script it generates, but that does not prove that every native call hidden inside a repository-owned .ps1 file, function, string, or combined block will stop automatically. Repository scripts must enforce their own contracts.
A minimal diagnostic allowlist should record $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, the CommandType returned by Get-Command copy, and the resolved path and version of the build tool. Do not dump all environment variables into the job log. CI variables can contain protected values or internal addresses, and those values are not needed to establish shell semantics.
Why mixing cmd and PowerShell produces misleading errors
Direct answer: A shared command name does not imply shared syntax. PowerShell resolves aliases, functions, cmdlets, and applications before applying its own argument rules. If a shell mismatch does not terminate the step, later commands can replace the first useful error with an unrelated missing-file, restore, or build symptom.
The safe PowerShell version of the copy operation is explicit: Copy-Item -LiteralPath $source -Destination $destination -Force. When an old batch command must be retained, use cmd.exe /d /c copy /Y ... and inspect $LASTEXITCODE on the next line. The first form has PowerShell's structured error behavior. The second creates an intentional language boundary and therefore requires explicit native-process handling.
The same ambiguity applies to names such as del, dir, and set, and to operators whose availability changed across PowerShell editions. Aliases are convenient at an interactive prompt, but they obscure a CI script's language. Automation should prefer complete cmdlet names, named parameters, and -LiteralPath when wildcard expansion is not intended. External tools should be invoked with the call operator and argument arrays rather than an executable command assembled as one evaluated string.
The costly outcome is not merely that the first command fails. A failed restore can be followed by a build that reports missing references. A failed copy can be followed by a verification of the wrong directory. Most dangerously, a cleanup can complete before a publish command fails, and a later service-start step may still run against an empty destination. The last red message is then a downstream symptom, not the root cause.
Troubleshooting should locate the earliest parser error, command-resolution mismatch, or nonzero native exit code. It should also inspect whether expected CI variables and working-directory changes took effect, without printing protected values. Re-running the same job may change transient state, but it does not repair the shell contract.
GitLab's script documentation adds a separate concern: if multiple commands are combined into one command string, only the final command's success or failure can be reported in some cases. YAML command boundaries and PowerShell error behavior are different layers. A robust pipeline separates critical operations into distinct YAML items and also performs checks inside repository-owned scripts.
A reviewable fail-fast PowerShell pattern
Direct answer: Handle cmdlet errors and native exit codes separately, place destructive actions after every candidate check, and terminate with a nonzero code at the gate that detects failure. A green job should mean that the intended candidate passed, not merely that control reached the last line.
For cmdlets, $ErrorActionPreference = 'Stop' converts many non-terminating errors into terminating errors that try and catch can handle. That setting is useful, but it is not a universal native-process exit handler. After nuget.exe, msbuild.exe, a test executable, or another native program returns, save $LASTEXITCODE immediately and exit with it when it is nonzero.
A reusable wrapper should accept a step name, executable path, and argument array. It should not concatenate untrusted data into a command string and send that string to Invoke-Expression. Evaluated command text complicates quoting, weakens reviewability, and can create injection risks. The following integration excerpt requires pre-existing input and candidate directories and a source file; it is not the complete fixture. It targets Windows PowerShell 5.1, not every PowerShell 7 native-command preference configuration. The explicit -ErrorAction Continue on the diagnostic prevents Stop from throwing before exit $code can propagate the saved status:
$ErrorActionPreference = 'Stop'
function Invoke-NativeChecked {
param(
[Parameter(Mandatory)] [string] $Step,
[Parameter(Mandatory)] [string] $FilePath,
[Parameter()] [string[]] $ArgumentList = @()
)
& $FilePath @ArgumentList
$code = $LASTEXITCODE
if ($code -ne 0) {
Write-Error "Step failed: $Step; exit code: $code" -ErrorAction Continue
exit $code
}
}
Copy-Item -LiteralPath '.\input\artifact.txt' `
-Destination '.\candidate\artifact.txt' -Force
Invoke-NativeChecked -Step 'sandbox verification' `
-FilePath 'cmd.exe' -ArgumentList @('/d', '/c', 'exit', '0')
Order is part of the contract. Build into a fresh candidate directory, verify required files and configuration, and perform a non-destructive startup check before any service switch. Do not delete the current destination merely to make the build clean. Cleanup is not verification, and an early deletion converts a recoverable build failure into an availability risk.
A separate process can verify candidate hashes or manifests, but the check must compare values generated for the same candidate. It should not treat timestamps as content identity. If deployment is eventually required, that should be another job consuming an immutable, verified artifact. This article intentionally omits service restarts, IIS commands, and production paths because they do not belong in a safe reader reproduction.
YAML, special characters, and generated-script boundaries
Direct answer: A CI command passes through YAML parsing, Runner script generation, and PowerShell parsing. Quotes, colons, backticks, dollar signs, and multiline scalars can be interpreted at different stages. Keep inline YAML short, avoid executable strings built from dynamic data, and validate the configuration with CI Lint before running it.
GitLab's official documentation notes that commands containing a colon may need the entire command quoted as a YAML string. It also lists other special characters that require care. This is not cosmetic formatting. If YAML interprets text as a mapping instead of a scalar, Runner never receives the original command. After Runner generates the shell script, PowerShell still applies its own quoting and interpolation rules.
Complex control flow belongs in a version-controlled .ps1 file. Such a file can be reviewed, linted, and executed independently in a disposable sandbox. YAML should retain a small set of stable calls and parameters. Commit messages, branch titles, and other user-influenced text must be treated as data, not inserted into executable source. Even diagnostic output should be limited to an allowlist because textual metadata can contain content that should not be exposed in logs.
A small pipeline can use separate command items and independent Windows PowerShell 5.1 processes. Each child script must enable terminating cmdlet errors, check native commands internally, and use explicit success/failure exit codes. Checking the outer process cannot recover a failure that the child ignored. Calling a ps1 file directly with & may leave $LASTEXITCODE unchanged, so that value alone is not a universal script-success check:
windows-build:
tags: [windows]
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- '$PSVersionTable.PSVersion.ToString()'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\restore.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\build.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\verify-candidate.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
This shape still needs validation against the target GitLab version and execution on a trusted runner. It is a pattern, not proof about a specific installation. Rules should also be reviewed as a complete graph so the build and its dependent job cannot use contradictory branch conditions.
The shell executor adds a security boundary beyond syntax. GitLab documents that it runs locally on the runner host and offers limited isolation between jobs. It should execute trusted projects from trusted users on a host intended for that work. Correct quoting does not solve host privilege, cross-project file visibility, or protected-setting governance.
Verification and reproduction in a disposable directory
Direct answer: A safe reproduction should prove three behaviors: how PowerShell resolves copy, how an unchecked native nonzero exit leaves room for subsequent statements, and how an explicit gate returns that failure. Disposable text files are sufficient; no GitLab server, privileged service, or deployment target is required.
Execution status: Reader-reproducible steps; not executed in the scheduler environment. Script-execution approval remains outstanding. The revised fixture below received static review only; no execution or approval bypass is claimed. Prerequisites are Windows PowerShell 5.1, cmd.exe, a writable temporary directory, and script-execution permission granted through the organization's normal process. Save the full block as verify-windows-ci.ps1 and invoke it as a separate process; do not paste it into an active interactive session because its last statement exits. This is a shell-level reproduction, not a test of a specific Runner-generated script.
- Create
source.txtunder a disposable temporary directory. RunGet-Command copyand confirm that this PowerShell session resolves the name as an Alias. Attempting to attach cmd's/Yswitch to that alias should be rejected rather than accepted as cmd syntax. - Create one copy with
Copy-Item -LiteralPath ... -Force, then another withcmd.exe /d /c copy /Y .... Check$LASTEXITCODEimmediately after the cmd boundary. Both explicit approaches should create a file with the expected content. - Run
cmd.exe /d /c exit 7and save$LASTEXITCODE. In a control script without an explicit exit, create a continuation marker to demonstrate that later statements remain reachable. In the fail-fast variant, exit with the captured code and verify that the caller receives exactly 7. - Execute the complete fixture with
powershell.exe -NoProfile -NonInteractive -File .\verify-windows-ci.ps1. Any unexpected command type, missing file, content mismatch, or wrong exit code must fail the fixture.
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -ne 5 -or
$PSVersionTable.PSVersion.Minor -ne 1) { throw 'Requires Windows PowerShell 5.1' }
$root = Join-Path $env:TEMP ('windows-ci-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $root | Out-Null
$source = Join-Path $root 'source.txt'
$nativeCopy = Join-Path $root 'native-copy.txt'
$cmdCopy = Join-Path $root 'cmd-copy.txt'
Set-Content -LiteralPath $source -Value 'sandbox-only' -Encoding ASCII
$alias = Get-Command copy
if ($alias.CommandType -ne 'Alias' -or $alias.Definition -ne 'Copy-Item') {
throw 'Unexpected copy resolution'
}
$rejected = $false
try { Copy-Item /Y $source $nativeCopy } catch { $rejected = $true }
if (-not $rejected) { throw 'Expected cmd syntax rejection' }
Copy-Item -LiteralPath $source -Destination $nativeCopy -Force
Push-Location -LiteralPath $root
try {
& cmd.exe /d /c copy /Y source.txt cmd-copy.txt | Out-Null
$copyCode = $LASTEXITCODE
} finally { Pop-Location }
if ($copyCode -ne 0) { throw 'cmd copy failed' }
$sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
foreach ($file in @($nativeCopy, $cmdCopy)) {
if (-not (Test-Path -LiteralPath $file)) { throw 'Copy missing' }
if ((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -ne $sourceHash) {
throw 'Copy content mismatch'
}
}
& cmd.exe /d /c exit 7
$captured = $LASTEXITCODE
if ($captured -ne 7) { throw 'Native status mismatch' }
$continued = Join-Path $root 'continued.txt'
Set-Content -LiteralPath $continued -Value 'reached' -Encoding ASCII
if (-not (Test-Path -LiteralPath $continued)) { throw 'Control marker missing' }
$gateFile = Join-Path $root 'gate.ps1'
$gateMarker = Join-Path $root 'post-gate.txt'
@'
param([string] $Marker)
$ErrorActionPreference = 'Stop'
& cmd.exe /d /c exit 7
$code = $LASTEXITCODE
if ($code -ne 0) { exit $code }
Set-Content -LiteralPath $Marker -Value 'must-not-be-reached' -Encoding ASCII
exit 0
'@ | Set-Content -LiteralPath $gateFile -Encoding ASCII
$engine = Join-Path $PSHOME 'powershell.exe'
& $engine -NoProfile -NonInteractive -File $gateFile -Marker $gateMarker
$gateCode = $LASTEXITCODE
if ($gateCode -ne 7) { throw 'Gate did not return 7' }
if (Test-Path -LiteralPath $gateMarker) { throw 'Post-gate marker exists' }
Write-Output 'PASS alias=Copy-Item copies=equal native=7 control=present gate=7 postGate=absent'
exit 0
Each run creates a fresh GUID-named directory without deleting a previous run; it leaves its files for inspection. The following is expected output, not an execution record. The overall fixture must exit 0 while the deliberately failing gate child must exit 7. An assertion failure must make the overall fixture nonzero; an approval block is not a pass.
PASS alias=Copy-Item copies=equal native=7 control=present gate=7 postGate=absent
Pass and fail conditions must be observable rather than described as “looks correct.” The first check expects an Alias resolving to Copy-Item. The copy checks expect both destination bytes to match the source. The control script expects the marker to exist after the native failure. The gated script expects process status 7 and no post-gate marker. A run under pwsh must record that different edition and version; results from Windows PowerShell 5.1 should not be silently generalized.
This fixture separates observation from policy. Observing code 7 proves that the native program reported failure. Creating a marker proves that later statements were reachable in the unchecked control. Returning 7 proves that the explicit gate propagated the status. It does not prove what a particular GitLab Runner version generates, because no Runner integration test was performed.
Cache, toolchain, and deployment are separate gates
Direct answer: Correct shell behavior is necessary, but cache availability, toolchain alignment, and safe deployment are independent gates. Restore and build with one pinned toolchain into a candidate directory, verify that candidate, and treat cache as an optimization rather than a correctness dependency.
A package cache configured only for pull, with no job that ever pushes content, cannot produce a reusable cache. Conversely, a reported cache hit does not prove that every package file is complete. The pipeline must be able to restore correctly from an empty isolated cache. A hit may reduce elapsed time, but it must not change build meaning.
Partially populated package directories should be repaired through the package manager's supported restore behavior in an isolated location. A marker file alone is not sufficient evidence that an archive, checksum, metadata, and extracted contents agree. Do not copy an uncertain cache into a release candidate merely because a previous stage labeled restoration successful.
Toolchain selection must also be explicit. Restore should not auto-discover one installation while build is pinned to another. Record an allowlisted version at the start of the job and keep the version in runner configuration or repository-controlled setup. A legacy Web Application Project may depend on Visual Studio build targets and cannot be assumed to behave like an SDK-style project under dotnet publish.
Build and deployment should be separate jobs. The build produces an immutable candidate and verification report. Deployment accepts only that artifact. Rules and dependencies must agree across the graph. Even though this guide performs no deployment, this boundary prevents a failed build from entering a cleanup or service-switch sequence.
Diagnostic checklist and adoption criteria
Start with the earliest useful signal instead of immediately retrying a Windows runner job. A retry can change transient state, but it does not clarify which parser or failure channel controlled execution.
- Confirm the executor and whether
config.tomlselectspowershellorpwsh. - Record the PowerShell edition, version, and approved build-tool versions.
- Use
Get-Commandto distinguish an Alias, Cmdlet, Function, and Application. - Send cmd syntax explicitly to
cmd.exe /c; use complete cmdlet names for PowerShell logic. - Handle cmdlet failure with terminating errors and
try/catch; handle native failure through$LASTEXITCODE. - Split critical YAML operations into separate script items and move complex logic into reviewed
.ps1files. - Build and verify a candidate before any service switch; do not erase the current destination first.
- Prove restore and build correctness with an empty cache; let cache affect time only.
- Validate YAML with CI Lint and test on a trusted, isolated runner.
- Keep logs to an allowlist; never dump protected values, complete environments, or internal paths.
- Treat Runner-generated checks as defense in depth, not a replacement for repository-script assertions.
- Keep a recovery boundary so a failed candidate does not alter the currently usable artifact.
Adoption is successful when a deliberately failing native command produces the expected nonzero job status, no later candidate or deployment marker appears, and the same script can be run in a disposable directory with a documented PowerShell edition. It is not successful merely because one pipeline turns green after a retry.
The practical model is straightforward. YAML defines command boundaries. Runner selects and generates for a shell. PowerShell resolves commands and controls errors. Native tools report process exit codes. A release gate decides whether failure can cross into deployment. Once each layer has explicit inputs and pass conditions, the final error no longer hides the first cause, and a green Windows pipeline becomes meaningful evidence rather than an accident of reaching the last statement.
Official references
GitLab documents shell selection, YAML script boundaries, and shell-executor isolation. Microsoft documents ErrorActionPreference, including Stop and per-command overrides, and LASTEXITCODE, including the difference between directly invoked scripts and powershell.exe -File.