TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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.
  • exit ends the PowerShell host process. Don’t call it inside reusable cmdlets or background jobs unless that is truly intended.

Common Pitfalls

  • $LASTEXITCODE vs $? $LASTEXITCODE is the exit code from the last native command. $? is just success of the last operation. When your script finishes with exit, CI will read the process exit code; don’t rely on $? for pipeline logic.
  • ErrorActionPreference: Non-terminating cmdlet errors won’t hit catch unless 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/

← All Posts Home →