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.
| Check | Command | Expected result (example) |
|---|---|---|
| PowerShell version | $PSVersionTable.PSVersion | 7.4.x or 5.1.x |
| Pester installed | Get-Module Pester -ListAvailable | Shows 5.5.0+ |
| ScriptAnalyzer | Get-Module PSScriptAnalyzer | Any stable version |
| NuGet provider | Get-PackageProvider NuGet | Lists a version |
| Target root exists | Test-Path C:\Modules\MyCompany.Tools | True 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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Script blocked at start | ExecutionPolicy restricted | Run 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 bootstrapped | Run Get-PackageProvider -Name NuGet -ForceBootstrap. |
| WhatIf shows actions but nothing happens in real run | Missing SupportsShouldProcess or not calling ShouldProcess | Ensure [CmdletBinding(SupportsShouldProcess=$true)] and wrap actions with $PSCmdlet.ShouldProcess. |
| Path errors on Linux/macOS | Hard-coded Windows paths | Use Join-Path, avoid backslashes, and parameterize roots. |
| Locked file during deployment | Another process holding the file | Use Current/Previous folder switches to avoid in-place overwrite; retry after releasing the lock. |
| Pester fails to import function | Wrong import path | Dot-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
$PSVersionTableand 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 -CIand confirm 0 failures. - Package
- Execute
build.ps1to generateout/MyCompany.Tools-<version>.zip. - Verify
VERSION.txtexists in the package. - Pre-deploy
- Run
deploy.ps1 -WhatIfand confirm only planned actions are listed. - Ensure no processes are locking files under the target root.
- Deploy
- Run
deploy.ps1 -Confirm:$false. - Observe
CurrentandPreviousfolders under the target root. - Verify
- Dot-source
Set-ConfigFile.ps1fromCurrentand run a smoke test. - Check file content on disk as expected.
- Rollback (if needed)
- Rename
Currentto a timestamped folder andPreviousback toCurrent. - 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.