A pipeline step succeeds if and only if the script exits with code 0. In PowerShell, an unhandled exception sets exit code to 1 automatically IF the script runs with the correct error action. But thrown errors from catch blocks or Write-Error without Stop may still exit 0 in some contexts. The safe pattern: use $ErrorActionPreference = 'Stop' and add exit 1 in any catch block where you want to fail the pipeline step explicitly.
PowerShell in CI/CD Pipelines
Run PowerShell tasks in Azure DevOps YAML and GitHub Actions workflows, deploy infrastructure and applications from pipeline scripts, emit pipeline variables and structured output, and design robust pipeline scripts that fail fast and fail clearly.
🧒 Simple Explanation (ELI5)
A CI/CD pipeline is an assembly line for software. Each station does its job: compile code, run tests, deploy to staging, check health, promote to production. PowerShell scripts are the workers at each station. They know how to build things, push them to Azure, and check whether everything is working—the pipeline just tells them when to start and listens for their success or failure signal.
🔧 Why Do We Need It?
- Automation glue: PowerShell is the link between a YAML pipeline and Azure services—deploying ARM templates, configuring App Settings, running smoke tests.
- Cross-platform: PowerShell 7 (pwsh) runs on both Windows and Linux agents, meaning deployment scripts work regardless of agent OS.
- Existing skills: teams already writing PowerShell for operations can reuse those scripts inside pipelines without learning new tooling.
- Exit code contract: pipelines know if a script succeeded or failed via exit codes—your scripts must signal failure correctly, not silently succeed.
⚙️ Technical Explanation
In Azure DevOps, PowerShell runs via the PowerShell@2 task (Windows PS) or Bash task with shell: pwsh. You can also use an inline script block or a path to a .ps1 file.
In GitHub Actions, use shell: pwsh on any run: step to target PowerShell 7. The pipeline sets environment variables that scripts can read from $env:VARIABLE_NAME.
Scripts communicate back to the pipeline via: exit codes (exit 0 = success, exit 1 = failure), pipeline variables (##vso[task.setvariable] for Azure DevOps, echo "NAME=VALUE" >> $env:GITHUB_ENV for GitHub Actions), and output artifacts.
A critical rule: $ErrorActionPreference = 'Stop' at the top of every pipeline script. Uncaught errors that do not throw may let the script exit 0 while reporting failure to its own log—the pipeline will incorrectly mark the step as passed.
In Azure DevOps, store credentials as variable groups or library secrets. Access them in YAML: $(MY_SECRET) is resolved at runtime and never appears in logs. In scripts, read them as environment variables: $env:MY_SECRET. In GitHub Actions, use repository secrets accessed as ${{ secrets.MY_SECRET }}. Hard-coded credentials in YAML or .ps1 files are a critical security vulnerability that will appear in git history forever.
📊 Visual Representation
⌨️ Commands / Syntax
# azure-pipelines.yml — PowerShell in Azure DevOps
trigger:
branches:
include: [main]
variables:
ENVIRONMENT: staging
RG_NAME: rg-skilly-staging
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: ubuntu-latest
steps:
- task: PowerShell@2
displayName: "Validate and build"
inputs:
targetType: inline
pwsh: true # Use PowerShell 7 (pwsh)
script: |
$ErrorActionPreference = 'Stop'
Write-Host "Running on: $($PSVersionTable.PSVersion)"
# Run tests
$testResult = Invoke-Pester -PassThru
if ($testResult.FailedCount -gt 0) { exit 1 }
- stage: Deploy
dependsOn: Build
jobs:
- deployment: DeployToStaging
environment: staging
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzurePowerShell@5
displayName: "Deploy App Service"
inputs:
azureSubscription: "skilly-azure-serviceconnection"
ScriptType: InlineScript
azurePowerShellVersion: LatestVersion
pwsh: true
Inline: |
$ErrorActionPreference = 'Stop'
$rg = "$(RG_NAME)"
$app = "skilly-api-staging"
Write-Host "Deploying to $app in $rg"
Compress-Archive -Path "$(Build.ArtifactStagingDirectory)/*" -DestinationPath deploy.zip -Force
Publish-AzWebApp -ResourceGroupName $rg -Name $app -ArchivePath deploy.zip -Force
Write-Host "##vso[task.setvariable variable=deployedVersion;isOutput=true]$(Build.BuildNumber)"
- task: PowerShell@2
displayName: "Smoke test"
inputs:
pwsh: true
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$url = "https://skilly-api-staging.azurewebsites.net/health"
$maxRetries = 5
for ($i = 1; $i -le $maxRetries; $i++) {
try {
$resp = Invoke-RestMethod -Uri $url -TimeoutSec 10
if ($resp.status -eq "healthy") {
Write-Host "Health check passed on attempt $i"
exit 0
}
} catch {
Write-Warning "Attempt $i failed: $_"
}
Start-Sleep -Seconds 15
}
Write-Error "Health check failed after $maxRetries attempts"
exit 1
# GitHub Actions workflow with PowerShell
name: Deploy to Azure
on:
push:
branches: [main]
env:
ENVIRONMENT: staging
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy with PowerShell
shell: pwsh
env:
RG_NAME: ${{ vars.RG_NAME }}
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Deploying to $env:RG_NAME in $env:ENVIRONMENT"
.\scripts\deploy.ps1 -Environment $env:ENVIRONMENT -ResourceGroup $env:RG_NAME
💼 Example (Real-world Use Case)
A team's Azure DevOps pipeline has three stages: Build (PowerShell compiles and tests), Deploy (AzurePowerShell@5 task deploys the App Service using the Az module service connection), and Validate (PowerShell smoke test polls the health endpoint). The pipeline emits the deployed version number as an output variable that downstream monitoring configuration picks up to update dashboards. Total automated time from merge to production: 8 minutes.
🧪 Hands-on
- Write a
validate-build.ps1script with$ErrorActionPreference = 'Stop'that checks if a required file exists and exits 1 if not. - Test the exit code behavior:
$LASTEXITCODEafter running the script with a missing file should be 1. - Write a smoke-test script that calls a URL with Invoke-RestMethod and retries up to 5 times with a 10-second wait between attempts.
- Write a minimal Azure DevOps YAML pipeline that runs a two-step inline PowerShell task: print the PS version and verify a variable is set.
- Emit a pipeline variable using the
##vso[task.setvariable]logging command from inside a PowerShell script block.
Write a smoke-test.ps1 script that: accepts a -BaseUrl parameter, calls $BaseUrl/health, expects a JSON response with status = "healthy", retries up to 5 times with 15-second waits, and exits 0 on success or 1 after all retries fail. This is the standard post-deploy health check gate that every production pipeline needs.
🐛 Debugging Scenario
Problem: a pipeline stage shows "Task succeeded" but the application is not deployed correctly—the PowerShell script clearly had errors in the log.
- Cause: the script used
Write-Errorwithout setting$ErrorActionPreference = 'Stop'. Write-Error writes to the error stream but does not throw—the script continues and exits 0. - Investigate: look at the pipeline task output in detail mode. Check whether the script ended with
exit 0explicitly or just finished without an exception. Check$LASTEXITCODEfrom anyaz,dotnet, or external tool call—these are often the silent failures. - Fix: add
$ErrorActionPreference = 'Stop'at the top. ReplaceWrite-Errorwiththroworexit 1in failure paths. After every external command call, check$LASTEXITCODE -ne 0and throw explicitly:if ($LASTEXITCODE -ne 0) { throw "az deployment failed with exit code $LASTEXITCODE" }.
🎯 Interview Questions
Beginner
Use the PowerShell@2 task with pwsh: true for PowerShell 7, or the AzurePowerShell@5 task for scripts that need an authenticated Az module session. You can provide the script inline or point to a .ps1 file path.
Hardcoded credentials appear in git history and pipeline logs forever. Use Azure DevOps variable groups or GitHub Actions secrets for credentials, read them as environment variables at runtime, and rotate them from the vault without touching any scripts.
Scenario-based
Add $ErrorActionPreference = 'Stop' at the top. Replace Write-Error calls with throw. Add explicit $LASTEXITCODE checks after every external tool call. Audit the script's exit paths to confirm that every failure branch either throws or calls exit 1. Run the script locally with -Verbose to watch each exit path.
Check for Windows-only cmdlets: Get-WmiObject only works on Windows (use Get-CimInstance), path separators (use Join-Path instead of hardcoded backslashes), and Windows-specific modules. Test on a Linux agent locally with pwsh. Add $PSVersionTable at the top of the script to log the OS and PS version clearly. Target PowerShell 7 (pwsh: true) consistently across both agent types.
🌐 Real-world Usage
Enterprise platform teams run PowerShell in every stage of their Azure DevOps pipelines: build validation scripts check code quality gates, deployment scripts call the Az module via service connections to provision and configure infrastructure, post-deploy scripts run smoke tests and update monitoring configuration, and rollback scripts use the same Az module commands to revert slot swaps or restore previous container images.
📝 Summary
PowerShell in CI/CD runs as PowerShell@2 (Azure DevOps) or shell: pwsh (GitHub Actions). Always set $ErrorActionPreference = 'Stop'. Check $LASTEXITCODE after every external tool call. Never hardcode credentials—use pipeline secrets and environment variables. Emit variables to the pipeline with logging commands. Build smoke-test scripts with retry loops. The exit code contract is non-negotiable: 0 means pass, non-zero means fail.