Chapter 19Lesson 02~220 minutes

PowerShell in CI/CD Runners

Run PowerShell predictably inside non-interactive CI/CD agents by making runtime, dependencies, inputs, logging, artifacts, and exit-code contracts explicit while keeping provider YAML thin.

CI/CDpwshArtifactsReproducibility

Learning objectives

  • Explain clean and ephemeral CI runner behavior.
  • Use pwsh deliberately and verify runtime requirements.
  • Normalize CI environment metadata at the script boundary.
  • Design CI-friendly logs, artifacts, and exit behavior.
  • Keep provider-specific pipeline syntax thin and repository logic portable.
  • Build a local CI simulation without a paid service.

1. A CI runner is a fresh non-interactive execution environment

A CI/CD runner is the machine, container, or agent process that executes pipeline steps. It may be short-lived, recreated for every job, and missing everything your laptop has accumulated over time. That is a feature: a clean runner exposes undocumented dependencies.

Runner propertyWhy it matters
Non-interactivePrompts and GUI sign-in flows can hang or fail.
Clean workspaceDo not depend on files outside the checked-out repository.
EphemeralAnything not retained as an artifact/cache disappears after the job.
Provider-managed or self-hostedInstalled tools, identity, network access, and trust differ.
ParallelTwo jobs must not accidentally share mutable global state.

2. Use pwsh deliberately; treat Windows PowerShell as compatibility

For this course, pwsh means modern PowerShell 7.x. A provider may offer a native “PowerShell” step, but an explicit command is often clearer in portable scripts. Windows PowerShell 5.1 remains relevant when a Windows-only legacy module or host integration has not migrated.

$PSVersionTable | Select-Object PSEdition,PSVersion,Platform

if ($PSVersionTable.PSEdition -ne 'Core') {
    throw 'This CI path requires PowerShell 7 (pwsh).'
}

if ($PSVersionTable.PSVersion -lt [version]'7.6.0') {
    throw "Expected PowerShell 7.6.x; found $($PSVersionTable.PSVersion)"
}

Do not rely on whatever version happens to be preinstalled forever. Pin or verify the runtime in your runner image/setup process, and apply the same principle to Pester, PSScriptAnalyzer, Az, AWS.Tools, or any other module dependency.

3. Inputs arrive through files, parameters, and environment variables

CI providers expose metadata through environment variables, checked-out files, command-line arguments, or provider APIs. Read them at the boundary, validate them, and convert them into your own internal configuration object. Keep provider-specific variable names out of the core logic.

function Get-CiContext {
    [pscustomobject]@{
        Workspace = (Get-Location).Path
        Commit    = $env:CI_COMMIT_SHA ?? $env:GITHUB_SHA ?? $env:BUILD_VCS_NUMBER
        Branch    = $env:CI_COMMIT_REF_NAME ?? $env:GITHUB_REF_NAME ?? $env:BRANCH_NAME
        IsCI      = [bool]($env:CI -or $env:GITHUB_ACTIONS -or $env:JENKINS_URL)
    }
}

$context = Get-CiContext
$context | Format-List

Null-coalescing here is only an adapter convenience. A production team should document which providers it supports and normalize each provider intentionally.

4. A CI step succeeds or fails through its process contract

CI engines ultimately observe a process exit status. A script that catches every error, prints a red message, and exits zero can create a false-green pipeline. Conversely, warnings should not automatically become fatal unless your quality policy says so.

try {
    ./ci/Invoke-Quality.ps1 -ErrorAction Stop
    exit 0
}
catch {
    Write-Error "Quality stage failed: $($_.Exception.Message)"
    exit 1
}
Reusable-function rule: prefer throw or structured failure inside reusable functions. Reserve top-level exit for the process boundary where the runner needs a final status code.

5. Write logs for machines and humans without leaking secrets

A good CI log answers: what phase ran, which source revision it used, what failed, and where the retained report lives. It should not print bearer tokens, passwords, private keys, or full credential objects. Use correlation/build IDs and structured result objects internally, then render concise human summaries.

function Write-CiEvent {
    param(
        [string]$Phase,
        [string]$Message,
        [ValidateSet('Info','Warning','Error')]
        [string]$Level = 'Info'
    )

    [pscustomobject]@{
        TimestampUtc = [datetime]::UtcNow.ToString('o')
        Level        = $Level
        Phase        = $Phase
        Message      = $Message
    } | ConvertTo-Json -Compress
}

Write-CiEvent -Phase 'test' -Message 'Starting unit tests'
# Never: Write-Host "TOKEN=$env:DEPLOY_TOKEN"

6. Restore dependencies explicitly and reproducibly

A repository should make its required tool versions discoverable. That might be a container image digest, a setup action version, a PSResourceGet lock/bootstrap script, or a documented manifest. The central idea is reproducibility: two runners should not silently use different major versions.

$required = @(
    @{ Name = 'Pester'; Version = [version]'6.0.0' },
    @{ Name = 'PSScriptAnalyzer'; Version = [version]'1.24.0' }
)

foreach ($dependency in $required) {
    $found = Get-Module -ListAvailable $dependency.Name |
        Where-Object Version -EQ $dependency.Version |
        Select-Object -First 1

    if (-not $found) {
        throw "Missing pinned dependency: $($dependency.Name) $($dependency.Version)"
    }
}

This check intentionally does not install anything during the lesson. Chapter 15 covered package installation; CI teams decide whether dependencies are baked into an image or restored from an approved repository before the quality step begins.

7. Provider examples should stay thin

Provider YAML or pipeline syntax should launch the same repository-owned script. Keep business logic in PowerShell so it can run locally and under multiple CI systems.

GitHub Actions

- name: Verify
  shell: pwsh
  run: ./ci.ps1

GitLab CI/CD

verify:
  script:
    - pwsh -NoLogo -NoProfile -File ./ci.ps1
  artifacts:
    when: always
    paths:
      - reports/

Jenkins (POSIX-style agent example)

stage('Verify') {
  steps {
    sh 'pwsh -NoLogo -NoProfile -File ./ci.ps1'
  }
}

Jenkins Windows agents would use a Windows-appropriate step instead of sh. The point is the contract: the provider launches ci.ps1; the repository script owns the quality workflow.

8. Artifacts are retained outputs, not console decoration

An artifact is a file intentionally preserved from a job: test XML, coverage data, package archives, checksums, manifests, or deployment plans. Create them under a predictable repository-relative directory so the provider can upload them without knowing your internal implementation.

$reportRoot = Join-Path $PWD 'reports'
New-Item -ItemType Directory -Path $reportRoot -Force | Out-Null

[pscustomobject]@{
    GeneratedUtc = [datetime]::UtcNow.ToString('o')
    PowerShell   = $PSVersionTable.PSVersion.ToString()
    Host         = $env:RUNNER_NAME ?? $env:CI_RUNNER_DESCRIPTION ?? $env:NODE_NAME
} | ConvertTo-Json -Depth 4 |
    Set-Content -LiteralPath (Join-Path $reportRoot 'environment.json')

# Provider-neutral step output contract; a thin CI adapter can map these
# values to GitHub/GitLab/Jenkins-specific output mechanisms.
[pscustomobject]@{
    Version = '0.1.0-local'
    ReportDirectory = 'reports'
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $reportRoot 'step-outputs.json')

9. Lab: simulate a CI runner locally

The lab uses a temporary workspace. It checks capabilities, runs a tiny static verification stage, creates reports, packages an artifact, and returns a single process-style success/failure decision without requiring a paid CI service.

$workspace = Join-Path ([IO.Path]::GetTempPath()) ('ps-ch19-ci-' + [guid]::NewGuid())
$reports = Join-Path $workspace 'reports'
$artifactDir = Join-Path $workspace 'artifacts'
New-Item -ItemType Directory -Path $reports,$artifactDir -Force | Out-Null

$events = [System.Collections.Generic.List[object]]::new()
function Add-Event([string]$phase,[string]$message,[bool]$success) {
    $events.Add([pscustomobject]@{
        TimestampUtc = [datetime]::UtcNow.ToString('o')
        Phase        = $phase
        Success      = $success
        Message      = $message
    })
}

try {
    Add-Event 'runtime' "PowerShell $($PSVersionTable.PSVersion)" $true

    $sample = Join-Path $workspace 'Get-BuildGreeting.ps1'
    @'
function Get-BuildGreeting {
    param([string]$Name = 'build')
    "hello $Name"
}
'@ | Set-Content -LiteralPath $sample

    $testFile = Join-Path $workspace 'Get-BuildGreeting.Tests.ps1'
    @'
BeforeAll { . "$PSScriptRoot/Get-BuildGreeting.ps1" }
Describe 'Get-BuildGreeting' {
    It 'returns the requested greeting' {
        (Get-BuildGreeting -Name 'CI') | Should-Be 'hello CI'
    }
}
'@ | Set-Content -LiteralPath $testFile

    # Cheap syntax/parse gate that works without optional modules.
    $tokens = $null
    $errors = $null
    [System.Management.Automation.Language.Parser]::ParseFile($sample,[ref]$tokens,[ref]$errors) | Out-Null
    if ($errors.Count) { throw ($errors.Message -join '; ') }
    Add-Event 'parse' 'PowerShell parser found no syntax errors' $true

    $pester = Get-Module -ListAvailable Pester | Sort-Object Version -Descending | Select-Object -First 1
    if ($pester -and $pester.Version.Major -ge 6) {
        Import-Module Pester -RequiredVersion $pester.Version -Force
        $testResult = Invoke-Pester -Path $testFile -PassThru
        Add-Event 'test' "Pester failed tests: $($testResult.FailedCount)" ($testResult.FailedCount -eq 0)
        $testResult | Select-Object Result,TotalCount,PassedCount,FailedCount,SkippedCount |
            ConvertTo-Json | Set-Content (Join-Path $reports 'tests.json')
        if ($testResult.FailedCount -gt 0) { throw 'Pester test stage failed' }
    } else {
        . $sample
        $actual = Get-BuildGreeting -Name 'CI'
        if ($actual -ne 'hello CI') { throw "Fallback test failed: $actual" }
        Add-Event 'test' 'Fallback local assertion passed; Pester not installed' $true
    }

    # Optional quality tools: run only when preinstalled.
    if (Get-Command Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue) {
        $issues = @(Invoke-ScriptAnalyzer -Path $sample)
        Add-Event 'analyzer' "PSScriptAnalyzer issues: $($issues.Count)" ($issues.Count -eq 0)
        $issues | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $reports 'analyzer.json')
    } else {
        Add-Event 'analyzer' 'PSScriptAnalyzer not installed; capability recorded' $true
    }

    Copy-Item -LiteralPath $sample -Destination $artifactDir
    Compress-Archive -Path (Join-Path $artifactDir '*') -DestinationPath (Join-Path $workspace 'package.zip')
    Get-FileHash -Algorithm SHA256 (Join-Path $workspace 'package.zip') |
        Select-Object Algorithm,Hash,Path |
        ConvertTo-Json | Set-Content (Join-Path $reports 'package-hash.json')

    $events | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $reports 'ci-events.json')
    $events | Format-Table
}
finally {
    "Workspace retained for inspection: $workspace"
}

Expected observations

You get a deterministic reports/ directory and a ZIP artifact. Optional analyzers are capability-detected instead of installed implicitly.

Verification checklist

  • No interactive prompt is required.
  • The parser gate records success or throws on syntax errors.
  • The optional analyzer path is explicit and does not change the machine.
  • The packaged artifact has a SHA-256 record.

Cleanup

Remove-Item -LiteralPath $workspace -Recurse -Force

10. Common CI mistakes

  • Depending on an interactive profile or user-specific module path.
  • Installing “latest” dependencies in every run with no version policy.
  • Printing secrets while debugging.
  • Keeping quality logic only inside provider YAML, making local reproduction difficult.
  • Producing reports outside the directory the provider retains.
  • Catching failures and exiting zero.

11. Knowledge check

Question 1. Why are clean runners useful?

Question 2. Where should most quality logic live?

Question 3. What ultimately tells a CI engine that a step failed?

Question 4. Why retain reports as artifacts?

Question 5. Why verify or pin PowerShell/module versions?

12. Summary and bridge

CI rewards explicitness: explicit runtime, dependencies, workspace, inputs, logs, reports, and exit codes. Lesson 3 applies the same discipline to Docker, Kubernetes, Helm, and other native DevOps CLIs, where context selection and machine-readable output become critical safety boundaries.

13. Authoritative references

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.