E-NO
PowerShell CI/CD 11 Min Read

PowerShell CI/CD automation with practical examples: practical implementation guide

calendar_today Published: 2026-07-28
update Last Updated: 2026-07-28
analytics SEO Efficiency: 100%
Technical guide illustration for PowerShell CI/CD automation with practical examples: practical implementation guide.

Intro

This guide is a practical, end-to-end walkthrough for teams that want to automate PowerShell build, test, and deployment with CI/CD. You will:

  • Inventory versions and dependencies so your pipeline is reproducible.
  • Implement a safe pilot that changes a configuration file with WhatIf and Confirm semantics.
  • Validate with PSScriptAnalyzer and Pester before packaging a versioned artifact.
  • Deploy with pre-flight checks, dry run, and post-deploy verification.
  • Handle common failures and perform a clean rollback.

The examples are intentionally minimal and do not require containers. You can adapt the scripts to your preferred CI/CD service; the focus stays on the PowerShell code paths operators rely on.

Version and Environment Inventory

Before you automate, capture the exact versions and settings your scripts and runners will use.

Quick checks

# PowerShell version and platform
$PSVersionTable

# Ensure PSGallery is trusted and tools are available
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force

# Verify tools
Get-Module Pester -ListAvailable | Select-Object Name, Version | Sort-Object Version -Descending | Select-Object -First 1
Get-Module PSScriptAnalyzer -ListAvailable | Select-Object Name, Version | Sort-Object Version -Descending | Select-Object -First 1

# NuGet provider availability (needed for some module ops)
Get-PackageProvider -Name NuGet -ForceBootstrap | Format-List Name, Version

# Confirm write access to your intended target root (example)
$targetRoot = 'C:\Modules\MyCompany.Tools'
Test-Path $targetRoot -PathType Container -IsValid  # Should be True or create it

Use a short table to align expectations across machines and runners.

CheckCommandExpected result (example)
PowerShell version$PSVersionTable.PSVersion7.4.x or 5.1.x
Pester installedGet-Module Pester -ListAvailableShows 5.5.0+
ScriptAnalyzerGet-Module PSScriptAnalyzerAny stable version
NuGet providerGet-PackageProvider NuGetLists a version
Target root existsTest-Path C:\Modules\MyCompany.ToolsTrue or create

If values differ by environment, record them so you can pin compatible versions and avoid drift.

Safe Configuration Path

Scope a narrow, inspectable pilot

Start with a pilot that is quick to reason about and simple to observe: a function that writes a configuration file, creates a backup, and supports WhatIf/Confirm. This teaches the safety patterns your larger scripts need.

The function (with WhatIf and rollback-friendly behavior)

# File: Module/Public/Set-ConfigFile.ps1
function Set-ConfigFile {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory = $true)]
        [string] $Path,
        [Parameter(Mandatory = $true)]
        [string] $Content,
        [switch] $CreateBackup
    )

    $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue) ?? $Path
    $dir = Split-Path -Parent $resolvedPath
    if (-not (Test-Path -LiteralPath $dir)) {
        if ($PSCmdlet.ShouldProcess($dir, 'Create directory')) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }
    }

    $backupPath = if ($CreateBackup) { "$resolvedPath.bak-$(Get-Date -Format yyyyMMddHHmmss)" } else { $null }

    if (Test-Path -LiteralPath $resolvedPath -PathType Leaf -ErrorAction SilentlyContinue -and $CreateBackup) {
        if ($PSCmdlet.ShouldProcess($resolvedPath, "Backup to $backupPath")) {
            Copy-Item -LiteralPath $resolvedPath -Destination $backupPath -Force
        }
    }

    if ($PSCmdlet.ShouldProcess($resolvedPath, 'Write content')) {
        Set-Content -LiteralPath $resolvedPath -Value $Content -Encoding UTF8 -NoNewline
    }

    [pscustomobject]@{
        Success    = $true
        Path       = $resolvedPath
        BackupPath = $backupPath
    }
}

Key points:

  • [CmdletBinding(SupportsShouldProcess = $true)] enables -WhatIf and -Confirm for safe runs.
  • Backups are versioned with a timestamp and do not overwrite the previous state.
  • The function returns a simple object that operators can log and inspect.

Unit tests with Pester

# File: Module/Tests/Set-ConfigFile.Tests.ps1
$here = Split-Path -Parent $PSCommandPath
$root = Split-Path -Parent $here
Import-Module "$root\Public\Set-ConfigFile.ps1" -Force

Describe 'Set-ConfigFile' {
    It 'Creates a file when none exists' {
        $tmp = Join-Path $env: TEMP 'cfg-test-1.txt'
        if (Test-Path $tmp) { Remove-Item $tmp -Force }
        $result = Set-ConfigFile -Path $tmp -Content 'abc' -WhatIf:$false
        Test-Path $tmp | Should -BeTrue
        (Get-Content -Raw $tmp) | Should -Be 'abc'
        $result.Success | Should -BeTrue
    }

    It 'Creates a backup when requested' {
        $tmp = Join-Path $env: TEMP 'cfg-test-2.txt'
        Set-Content -Path $tmp -Value 'v1'
        $result = Set-ConfigFile -Path $tmp -Content 'v2' -CreateBackup -WhatIf:$false
        Test-Path $result.BackupPath | Should -BeTrue
        (Get-Content -Raw $tmp) | Should -Be 'v2'
    }

    It 'Honors WhatIf for dry runs' {
        $tmp = Join-Path $env: TEMP 'cfg-test-3.txt'
        if (Test-Path $tmp) { Remove-Item $tmp -Force }
        $ = Set-ConfigFile -Path $tmp -Content 'dry' -WhatIf
        Test-Path $tmp | Should -BeFalse
    }
}

Static analysis

Invoke-ScriptAnalyzer -Path .\Module -Recurse -Severity Warning, Error

Resolve errors before proceeding. Warnings are a useful backlog but should be triaged.

Package a versioned artifact

Keep packaging simple and deterministic: copy the module/files into a versioned folder, add a VERSION.txt, and zip it.

# File: build.ps1 (run locally or in CI)
$ErrorActionPreference = 'Stop'
$version = Get-Date -Format 'yyyy.MM.dd.HHmm'
$out = Join-Path $PSScriptRoot 'out'
New-Item -ItemType Directory -Path $out -Force | Out-Null
$dst = Join-Path $out 'MyCompany.Tools'
Copy-Item -Path .\Module\* -Destination $dst -Recurse -Force
Set-Content -Path (Join-Path $dst 'VERSION.txt') -Value $version -NoNewline
$zip = Join-Path $out ("MyCompany.Tools-$version.zip")
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path $dst -DestinationPath $zip -Force
Write-Host "Packaged artifact: $zip"

Minimal CI example (Windows runner)

This is an example you can adapt. The focus is the PowerShell steps.

# .github/workflows/ci-powershell.yml
name: ci-powershell
on:
  push:
    branches: [ main ]
jobs:
  test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install tools
        shell: pwsh
        run: |
          Set-PSRepository PSGallery -InstallationPolicy Trusted
          Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force
          Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
      - name: Static analysis
        shell: pwsh
        run: Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning, Error
      - name: Unit tests
        shell: pwsh
        run: Invoke-Pester -CI -Output Detailed
      - name: Package artifact
        shell: pwsh
        run: .\build.ps1
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyCompany.Tools
          path: out/*.zip

Deployment with pre-checks, dry run, and rollback

Deployment script

This script deploys a versioned artifact into a target root with a Current and Previous folder pattern. Rollback is a rename + restore.

# File: deploy.ps1
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)]
    [string] $ArtifactZip,
    [Parameter(Mandatory = $true)]
    [string] $TargetRoot
)

$ErrorActionPreference = 'Stop'
$VerbosePreference = 'Continue'

function Invoke-PreChecks {
    param([string]$Zip, [string]$Root)
    Write-Verbose "Checking artifact at $Zip"
    if (-not (Test-Path -LiteralPath $Zip -PathType Leaf)) {
        throw "Artifact not found: $Zip"
    }
    Write-Verbose "Checking target root at $Root"
    if (-not (Test-Path -LiteralPath $Root)) {
        New-Item -ItemType Directory -Path $Root -Force | Out-Null
    }
    $space = ([System.IO.DriveInfo] (Split-Path -Qualifier $Root)).AvailableFreeSpace
    if ($space -lt 200MB) { throw "Insufficient disk space at $Root" }
}

function Expand-Artifact {
    param([string]$Zip, [string]$Root)
    $stamp = Get-Date -Format 'yyyyMMddHHmmss'
    $staging = Join-Path $Root "Staging-$stamp"
    New-Item -ItemType Directory -Path $staging -Force | Out-Null
    Write-Verbose "Expanding $Zip to $staging"
    Expand-Archive -Path $Zip -DestinationPath $staging -Force
    return $staging
}

function Switch-Version {
    param([string]$Root, [string]$Staging)
    $current = Join-Path $Root 'Current'
    $previous = Join-Path $Root 'Previous'

    if (Test-Path $previous) { Remove-Item $previous -Recurse -Force }
    if (Test-Path $current) {
        Rename-Item -Path $current -NewName 'Previous' -Force
    }
    Rename-Item -Path $Staging -NewName 'Current' -Force
}

Invoke-PreChecks -Zip $ArtifactZip -Root $TargetRoot

# Dry run support via -WhatIf
if ($PSCmdlet.ShouldProcess($TargetRoot, 'Deploy artifact')) {
    $staging = Expand-Artifact -Zip $ArtifactZip -Root $TargetRoot
    Switch-Version -Root $TargetRoot -Staging $staging
}

Write-Host 'Deployment complete. Current content:'
Get-ChildItem -Path (Join-Path $TargetRoot 'Current') -Recurse | Select-Object -First 5 | Format-List

Usage examples:

# Dry run
pwsh -File .\deploy.ps1 -ArtifactZip .\out\MyCompany.Tools-2024.05.01.1200.zip -TargetRoot C:\Modules\MyCompany.Tools -WhatIf

# Real deploy
pwsh -File .\deploy.ps1 -ArtifactZip .\out\MyCompany.Tools-2024.05.01.1200.zip -TargetRoot C:\Modules\MyCompany.Tools -Confirm:$false

Post-deployment import and smoke test

$moduleRoot = 'C:\Modules\MyCompany.Tools\Current'
$public = Join-Path $moduleRoot 'Public'
$functionPath = Join-Path $public 'Set-ConfigFile.ps1'
. $functionPath

$tmp = Join-Path $env: TEMP 'post-deploy-check.txt'
$result = Set-ConfigFile -Path $tmp -Content 'ok' -CreateBackup -WhatIf:$false
$result.Success -and (Get-Content -Raw $tmp) -eq 'ok'

You should see True and the file content should be exactly ok.

Verification and Diagnostics

Make success observable and failure diagnosable.

  • Static analysis: Invoke-ScriptAnalyzer must exit without Error-severity findings.
  • Tests: Invoke-Pester should report 0 Failed and a nonzero test count.
  • Packaging: The artifact zip should contain a MyCompany.Tools folder with VERSION.txt.
  • Pre-deploy: The dry run (-WhatIf) should print the planned actions without changing the target root.
  • Post-deploy: Import and run the function; expected file changes should exist on disk.

Example commands and checks:

# Check analysis and tests in CI or locally
Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error; if ($LASTEXITCODE -or $?) { }
Invoke-Pester -CI -Output Detailed

# Validate artifact structure
Expand-Archive -Path .\out\MyCompany.Tools-*.zip -DestinationPath .\out\unpacked -Force
Test-Path .\out\unpacked\MyCompany.Tools\Public\Set-ConfigFile.ps1 | Should -BeTrue

# Confirm dry run does not change the filesystem
$before = Get-ChildItem C:\Modules\MyCompany.Tools -Recurse -Force | Select-Object FullName, Length
pwsh -File .\deploy.ps1 -ArtifactZip .\out\MyCompany.Tools-*.zip -TargetRoot C:\Modules\MyCompany.Tools -WhatIf
$after = Get-ChildItem C:\Modules\MyCompany.Tools -Recurse -Force | Select-Object FullName, Length
Compare-Object $before $after | Should -BeNullOrEmpty

# Post-deploy smoke run
. C:\Modules\MyCompany.Tools\Current\Public\Set-ConfigFile.ps1
$res = Set-ConfigFile -Path "$env: TEMP\ops-check.txt" -Content 'ok' -CreateBackup -WhatIf:$false
$res.Success | Should -BeTrue

For diagnostics, enable verbose output and, when needed, a transcript.

$VerbosePreference = 'Continue'
Start-Transcript -Path "$env: TEMP\deploy-$(Get-Date -Format yyyyMMddHHmmss).log"
try {
  # run deploy
}
finally {
  Stop-Transcript
}

Failure Modes and Recovery

Expect these common issues and keep fixes at hand.

SymptomLikely causeFix
Script blocked at startExecutionPolicy restrictedRun powershell.exe -ExecutionPolicy Bypass -File deploy.ps1 for trusted code paths or set a suitable policy via policy management.
Could not find provider 'NuGet'NuGet provider not bootstrappedRun Get-PackageProvider -Name NuGet -ForceBootstrap.
WhatIf shows actions but nothing happens in real runMissing SupportsShouldProcess or not calling ShouldProcessEnsure [CmdletBinding(SupportsShouldProcess=$true)] and wrap actions with $PSCmdlet.ShouldProcess.
Path errors on Linux/macOSHard-coded Windows pathsUse Join-Path, avoid backslashes, and parameterize roots.
Locked file during deploymentAnother process holding the fileUse Current/Previous folder switches to avoid in-place overwrite; retry after releasing the lock.
Pester fails to import functionWrong import pathDot-source the correct path or convert to a module manifest and Import-Module it.

Rollback procedure

The deployment script preserves the previous version as Previous. To roll back:

$root = 'C:\Modules\MyCompany.Tools'
$current = Join-Path $root 'Current'
$previous = Join-Path $root 'Previous'

if (-not (Test-Path $previous)) { throw 'No Previous version to roll back to.' }

# Dry run first
if ($PSCmdlet) { $PSCmdlet.WhatIfPreference = $true }
Rename-Item -Path $current -NewName "Broken-$(Get-Date -Format yyyyMMddHHmmss)" -Force
Rename-Item -Path $previous -NewName 'Current' -Force

# Verify
Test-Path (Join-Path $root 'Current') | Should -BeTrue

If you prefer not to rename the broken folder, delete it later after confirming the rollback worked.

Recovery after a partial deploy

  • Stop further changes. Do not rerun deployment until you confirm state.
  • Use the Previous folder as the authoritative source for restore.
  • Re-run post-deploy smoke tests to confirm health.
  • Capture logs and diffs for root-cause analysis before the next attempt.

Operations Checklist

Use this runbook to keep operations consistent. Replace example paths with your own.

  • Inventory
  • Run $PSVersionTable and record the version.
  • Install or update Pester and PSScriptAnalyzer.
  • Confirm NuGet provider exists.
  • Verify target root path and free disk space.
  • Validate
  • Run Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning, Error.
  • Run Invoke-Pester -CI and confirm 0 failures.
  • Package
  • Execute build.ps1 to generate out/MyCompany.Tools-<version>.zip.
  • Verify VERSION.txt exists in the package.
  • Pre-deploy
  • Run deploy.ps1 -WhatIf and confirm only planned actions are listed.
  • Ensure no processes are locking files under the target root.
  • Deploy
  • Run deploy.ps1 -Confirm:$false.
  • Observe Current and Previous folders under the target root.
  • Verify
  • Dot-source Set-ConfigFile.ps1 from Current and run a smoke test.
  • Check file content on disk as expected.
  • Rollback (if needed)
  • Rename Current to a timestamped folder and Previous back to Current.
  • Re-run smoke test to confirm recovery.
  • Document
  • Record version, start/end timestamps, pass/fail, and any deviations.

Conclusion

A narrow, inspectable pilot is the fastest way to build confidence in your PowerShell CI/CD path. By separating validation from packaging, using WhatIf and Confirm to stage risk, and deploying with a reversible Current/Previous pattern, you get:

  • Safer rollouts that you can dry-run and verify.
  • Clear, repeatable steps that reduce rework and surprises.
  • Deterministic artifacts you can track and audit.

Next steps:

  • Convert your functions into a proper module with a module manifest and semantic versions.
  • Add code signing to raise trust and reduce execution policy friction.
  • Expand tests to include environment-specific smoke tests (IIS app config, scheduled task definitions, or service restarts, as applicable) using the same WhatIf-first pattern.
  • Standardize your artifact layout across teams so operators can deploy with the same commands everywhere.

With these patterns in place, you can scale from a single function to a portfolio of reliable PowerShell automations without sacrificing safety or speed.

Article Quality Score

Reader usefulness 100%
  • check_circle Reader-ready guide
  • check_circle Practical examples included
  • check_circle Clean SEO article URL