Predictable Exit Codes in PowerShell: Make CI Know Exactly What Happened
When your pipeline runs a PowerShell script, the fastest way to turn logs into decisions is a predictable exit code. Map known outcomes to numeric codes, set them in one place, log the context, and let CI do the rest. In this post you will build a simple, repeatable pattern that makes your scripts unambiguous and your pipelines safer.
Why Predictable Exit Codes Matter
CI systems reason in numbers. A non-zero exit means failure, but which failure? You can make that explicit:
- 0 = success
- 1 = general/unhandled failure
- 2 = invalid/unsupported args
- 3 = missing resource
Benefits you get immediately:
- Cleaner CI logic: Gate deploys only on certain codes (e.g., skip deploy when input is missing, alert on unhandled failures).
- Faster debugging: Code-to-cause mapping shrinks triage time.
- Safer pipelines: Fail closed by default; only 0 passes.
Implementing a Robust Pattern in PowerShell
Single Source of Truth for Codes
Centralize your codes so they are easy to reuse across scripts. You can use either a hashtable or an enum. Enums are nice when you want type safety:
enum ExitCode {
Ok = 0
Fail = 1
Args = 2
Missing = 3
}
If you prefer a hashtable for quick scripts:
$codes = [ordered]@{ Ok = 0; Fail = 1; Args = 2; Missing = 3 }
Try/Catch/Finally with Context Logging
Use a try/catch/finally block to map exceptions to codes, log meaningful context, and exit exactly once. Also convert non-terminating errors into terminating ones using $ErrorActionPreference = 'Stop'.
param(
[string]$Path = './data.json'
)
using namespace System
$ErrorActionPreference = 'Stop'
# Single source of truth (choose enum OR hashtable)
enum ExitCode { Ok = 0; Fail = 1; Args = 2; Missing = 3 }
# Helper: write compact machine-readable context before exiting
function Write-ExitContext {
param(
[ExitCode]$Code,
[string]$Reason,
[hashtable]$Extra = @{}
)
$payload = [pscustomobject]@{
time = (Get-Date).ToString('o')
code = [int]$Code
reason = $Reason
script = $PSCommandPath
path = $Path
env = @{ CI = $env:CI; BUILD_ID = $env:BUILD_BUILDID; GITHUB_RUN_ID = $env:GITHUB_RUN_ID }
extra = $Extra
}
# Emit JSON to stdout for easy indexing
$payload | ConvertTo-Json -Depth 5
}
# IMPORTANT: initialize to $null and check for $null, not truthiness of 0
[ExitCode]$code = $null
try {
if (-not (Test-Path -Path $Path -PathType Leaf)) {
throw [IO.FileNotFoundException]::new("Missing: $Path")
}
$json = Get-Content -Path $Path -Raw | ConvertFrom-Json -Depth 5
Write-Host ("OK: {0}" -f $Path)
$code = [ExitCode]::Ok
}
catch [IO.FileNotFoundException] {
Write-Warning $_.Exception.Message
$code = [ExitCode]::Missing
}
catch [Management.Automation.ParameterBindingException] {
Write-Warning ("Args: {0}" -f $_.Exception.Message)
$code = [ExitCode]::Args
}
catch {
Write-Error ("Unhandled: {0}" -f $_.Exception.Message)
$code = [ExitCode]::Fail
}
finally {
if ($null -eq $code) { $code = [ExitCode]::Fail }
Write-ExitContext -Code $code -Reason 'completed' | Write-Host
exit ([int]$code)
}
Notice the null check in finally. Do not use if (-not $code) because 0 (success) is falsey in PowerShell and would be overwritten as a failure. Always use $null -eq $code to detect uninitialized state.
When Not to Call exit
- Only your entrypoint script should call
exit. Library modules and functions should return an[ExitCode]or throw exceptions. The entrypoint maps those to a final code and exits once. exitends the PowerShell host process. Don’t call it inside reusable cmdlets or background jobs unless that is truly intended.
Common Pitfalls
- $LASTEXITCODE vs $?
$LASTEXITCODEis the exit code from the last native command.$?is just success of the last operation. When your script finishes withexit, CI will read the process exit code; don’t rely on$?for pipeline logic. - ErrorActionPreference: Non-terminating cmdlet errors won’t hit
catchunless you set$ErrorActionPreference = 'Stop'or use-ErrorAction Stop. - Code range: On Linux/macOS shells, exit codes are typically 0–255. Keep codes small and documented.
- Secrets in logs: Mask tokens before logging context; avoid dumping entire environments. Log only what you need to debug.
Wiring Exit Codes into CI/CD
GitHub Actions
GitHub Actions will fail a step automatically when your PowerShell process exits non-zero. You can also branch on failure() or use subsequent steps for cleanup.
name: Validate JSON
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate data.json
shell: pwsh
run: ./scripts/validate.ps1 -Path './data.json'
- name: Upload diagnostics on failure
if: ${{ failure() }}
run: |
echo "Previous step failed; collecting context..."
# Tail logs or upload artifacts here
Azure Pipelines
steps:
- task: PowerShell@2
inputs:
pwsh: true
filePath: scripts/validate.ps1
arguments: -Path $(Build.SourcesDirectory)/data.json
continueOnError: false
# Optional: branch on specific exit codes
- powershell: |
Write-Host "ExitCode was: $(Agent.JobStatus)" # status, not code
condition: failed()
Jenkins (Declarative)
pipeline {
agent any
stages {
stage('Validate') {
steps {
script {
def rc = powershell(returnStatus: true, script: '.\\scripts\\validate.ps1 -Path data.json')
echo "ExitCode: ${rc}"
if (rc == 2) {
currentBuild.result = 'FAILURE'
error('Invalid arguments')
} else if (rc == 3) {
// treat as 'unstable' to notify but not block downstream
currentBuild.result = 'UNSTABLE'
} else if (rc != 0) {
error("Unhandled failure: ${rc}")
}
}
}
}
}
}
Making Logs Actionable
Emit a small, structured payload right before you exit so your CI or log shipper can index it. The helper above prints JSON to stdout. You can also write to a file if needed:
$context = Write-ExitContext -Code ([ExitCode]::Args) -Reason 'invalid input' -Extra @{ file = $Path; step = 'validate' }
$context | Out-File -FilePath (Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'exit.json') -Encoding utf8
exit ([int][ExitCode]::Args)
Quick Local Smoke Test
Before pushing, verify codes locally:
# Success
pwsh -NoProfile -File .\scripts\validate.ps1 -Path .\data.json
"Exit: $LASTEXITCODE" # Expect 0
# Missing
pwsh -NoProfile -File .\scripts\validate.ps1 -Path .\nope.json
"Exit: $LASTEXITCODE" # Expect 3
# Args error (simulate)
pwsh -NoProfile -c "./scripts/validate.ps1 -Unknown Switch"; echo "Exit: $LASTEXITCODE" # Expect 2
That’s it: a simple pattern—map error classes to numeric codes, set and exit from a single place, and log context before you leave—gives you predictable exits, cleaner CI, faster debugging, and safer pipelines.
Further reading: Make your outcomes visible to CI. PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/