## 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 
 Use a short table to align expectations across machines and runners.

<div class="my-stack-md overflow-x-auto">
<table class="w-full min-w-[42rem] border-collapse text-left">
<thead><tr><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Check</th><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Command</th><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Expected result (example)</th></tr></thead>
<tbody><tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">PowerShell version</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">$PSVersionTable.PSVersion</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">7.4.x</code> or <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">5.1.x</code></td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Pester installed</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Get-Module Pester -ListAvailable</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Shows <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">5.5.0+</code></td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">ScriptAnalyzer</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Get-Module PSScriptAnalyzer</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Any stable version</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">NuGet provider</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Get-PackageProvider NuGet</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Lists a version</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Target root exists</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Test-Path C:\Modules\MyCompany.Tools</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant"><code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">True</code> or create</td></tr></tbody>
</table>
</div>
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
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

# 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

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

<div class="my-stack-md overflow-x-auto">
<table class="w-full min-w-[42rem] border-collapse text-left">
<thead><tr><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Symptom</th><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Likely cause</th><th scope="col" class="border border-outline-variant bg-surface-container-low px-4 py-3 text-left font-label-md font-semibold text-on-surface">Fix</th></tr></thead>
<tbody><tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Script blocked at start</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">ExecutionPolicy restricted</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Run <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">powershell.exe -ExecutionPolicy Bypass -File deploy.ps1</code> for trusted code paths or set a suitable policy via policy management.</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Could not find provider &#39;NuGet&#39;</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">NuGet provider not bootstrapped</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Run <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Get-PackageProvider -Name NuGet -ForceBootstrap</code>.</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">WhatIf shows actions but nothing happens in real run</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Missing <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">SupportsShouldProcess</code> or not calling <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">ShouldProcess</code></td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Ensure <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">[CmdletBinding(SupportsShouldProcess=$true)]</code> and wrap actions with <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">$PSCmdlet.ShouldProcess</code>.</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Path errors on Linux/macOS</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Hard-coded Windows paths</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Use <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Join-Path</code>, avoid backslashes, and parameterize roots.</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Locked file during deployment</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Another process holding the file</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Use Current/Previous folder switches to avoid in-place overwrite; retry after releasing the lock.</td></tr>
<tr><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Pester fails to import function</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Wrong import path</td><td class="border border-outline-variant px-4 py-3 align-top text-body-md text-on-surface-variant">Dot-source the correct path or convert to a module manifest and <code class="font-mono text-[0.9em] bg-surface-container px-1 py-0.5 rounded">Import-Module</code> it.</td></tr></tbody>
</table>
</div>

### 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
$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') 
 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.