AdvancedLesson 12 of 16

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?

⚙️ 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.

🚨
Exit Code Contract With the Pipeline

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.

🔑
Never Hardcode Credentials — Use Pipeline Secrets

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

PowerShell in Azure DevOps YAML Pipeline
trigger: main
Build stage: pwsh script
Deploy stage: Az module
Validate: smoke test script

⌨️ Commands / Syntax

yaml
# 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
yaml
# 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

  1. Write a validate-build.ps1 script with $ErrorActionPreference = 'Stop' that checks if a required file exists and exits 1 if not.
  2. Test the exit code behavior: $LASTEXITCODE after running the script with a missing file should be 1.
  3. 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.
  4. 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.
  5. Emit a pipeline variable using the ##vso[task.setvariable] logging command from inside a PowerShell script block.
🎮
Try It Yourself

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.

🎯 Interview Questions

Beginner

How do you run a PowerShell script in an Azure DevOps YAML pipeline?

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.

Why must pipeline scripts never hardcode credentials?

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

A pipeline step succeeds but the deployment clearly failed based on the log output. How do you fix the pipeline script?

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.

Your PowerShell deployment script works on Windows DevOps agents but fails on Linux agents. How do you diagnose?

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.