Automate PowerShell Linting with PSScriptAnalyzer: Custom Rules, CI Fail-Fast, and Baselines
You don’t have to wait for a runtime error or a code review to catch style drift, risky patterns, or preventable bugs in your PowerShell. PSScriptAnalyzer gives you fast, consistent feedback locally and in CI so issues surface before code runs. In this post, you’ll set up automated linting with Invoke-ScriptAnalyzer, tune rules for your team, enforce fail-fast CI, and keep a baseline so you only fail builds on new violations.
What you get: earlier feedback, cleaner code, safer merges, consistent style.
1) Run PSScriptAnalyzer locally
Start by installing PSScriptAnalyzer and running it over your repo. This gives instant feedback and sets you up for repeatability in CI.
Install and first run
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
Get-InstalledModule PSScriptAnalyzer | Select-Object Name, Version
# Lint current folder recursively
Invoke-ScriptAnalyzer -Path . -RecurseTo make failures obvious and machine-friendly, add a small script that prints violations in a readable table and exits non-zero when problems are found. You can tweak rules inline or point to a settings file.
$path = './src'
if (-not (Test-Path -Path $path)) { $path = Get-Location }
$settings = @{
Rules = @{
PSUseConsistentIndentation = @{ Enable = $true }
PSAvoidUsingWriteHost = @{ Enable = $true }
}
}
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settings
$bad = $results | Where-Object { $_.Severity -in 'Error','Warning' }
if ($bad) {
$bad | Sort-Object Severity, RuleName, ScriptName, Line |
Select-Object RuleName, Severity, ScriptName, Line, Message |
Format-Table -AutoSize
throw ("PSScriptAnalyzer found {0} issues." -f $bad.Count)
} else {
Write-Host 'No issues found.'
}Tip: PSAvoidUsingWriteHost will flag Write-Host in the example above—intentionally! Replace with Write-Information or Write-Verbose in your production scripts to align with best practices.
Make it repeatable with a settings file
A PSScriptAnalyzerSettings.psd1 file locks in team conventions. Store it at the repo root and version it.
@{
# Limit analysis surface or explicitly include only what you care about
# IncludeRules = @('PSUseConsistentIndentation','PSAvoidUsingWriteHost','PSAvoidUsingInvokeExpression')
# ExcludeRules = @('PSAvoidUsingWriteHost')
Rules = @{
PSUseConsistentIndentation = @{
Enable = $true
IndentationSize = 4
PipelineIndentation = 'Increase' # None | Increase | IncreaseIndentationAfterEveryPipeline
Kind = 'space' # space | tab
}
PSAvoidUsingWriteHost = @{ Enable = $true }
}
Severity = @{
PSAvoidUsingInvokeExpression = 'Error'
PSAvoidGlobalVars = 'Warning'
}
}Then run:
Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1- Locking rules in a file keeps style consistent across machines.
- Overriding
Severityhelps you fail fast on truly risky patterns while easing in style-only fixes.
2) Fail fast in CI, but keep a baseline
Flipping on strict linting can be painful in older repos. Use a baseline to allow existing issues while failing builds on any new violations. This lets you gradually pay down technical debt without blocking progress.
Create a baseline from the current state
# create-baseline.ps1
$path = './src'
$settingsPath = './PSScriptAnalyzerSettings.psd1'
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settingsPath
$baseline = $results | ForEach-Object {
[PSCustomObject]@{
Key = '{0}|{1}|{2}|{3}' -f $_.RuleName, $_.ScriptName, $_.Line, ($_.Message -replace '\s+',' ')
}
}
$baseline |
Sort-Object Key | Get-Unique |
ConvertTo-Json -Depth 3 |
Set-Content -NoNewline ./pssa-baseline.jsonCommit pssa-baseline.json to your repo.
Filter out baseline violations in CI and fail on new ones
# lint.ps1
$ErrorActionPreference = 'Stop'
$path = './src'
$settingsPath = './PSScriptAnalyzerSettings.psd1'
$baselinePath = './pssa-baseline.json'
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settingsPath
$baseline = @()
if (Test-Path $baselinePath) {
$baseline = (Get-Content $baselinePath | ConvertFrom-Json) | ForEach-Object { $_.Key }
}
$new = $results | Where-Object {
$key = '{0}|{1}|{2}|{3}' -f $_.RuleName, $_.ScriptName, $_.Line, ($_.Message -replace '\s+',' ')
-not $baseline.Contains($key)
}
if ($new) {
$new | Sort-Object Severity, RuleName, ScriptName, Line |
Select-Object RuleName, Severity, ScriptName, Line, Message |
Format-Table -AutoSize
throw ("PSScriptAnalyzer found {0} new issues." -f $new.Count)
} else {
Write-Information 'No new issues found.' -InformationAction Continue
}Workflow:
- Generate a baseline once per repo or directory.
- CI filters out those known issues, failing only on new problems.
- As you fix baseline items, remove their entries over time.
3) Wire it into CI/CD
GitHub Actions
# .github/workflows/powershell-lint.yml
name: PowerShell Lint
on:
pull_request:
push:
branches: [ main ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PSScriptAnalyzer
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
- name: Lint with PSScriptAnalyzer (fail on new)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
./lint.ps1
- name: Annotate results for GitHub UI
if: failure()
shell: pwsh
run: |
$path = './src'
$settingsPath = './PSScriptAnalyzerSettings.psd1'
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settingsPath
foreach ($r in $results) {
$level = if ($r.Severity -eq 'Error') { 'error' } else { 'warning' }
Write-Output ("::{0} file={1},line={2},col={3}::{4} ({5})" -f $level, $r.ScriptName, $r.Line, $r.Column, $r.Message, $r.RuleName)
}The annotation step emits GitHub Actions workflow commands so violations appear inline on PRs. The previous step ensures the job fails for any new issues.
Azure Pipelines (classic YAML)
# azure-pipelines.yml (snippet)
- task: PowerShell@2
displayName: Install PSScriptAnalyzer
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
- task: PowerShell@2
displayName: Lint (fail on new)
inputs:
pwsh: true
targetType: filePath
filePath: lint.ps14) Make it easy for developers
Pre-commit hooks
Run the linter before code lands. This saves reviewers time and catches mistakes at the source.
# .git/hooks/pre-commit (mark executable)
#!/usr/bin/env pwsh
$ErrorActionPreference = 'Stop'
try {
if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) {
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
}
./lint.ps1
} catch {
Write-Error 'Commit blocked: fix PSScriptAnalyzer issues.'
exit 1
}Use containers for consistent results
If your CI runs in containers, you can lint there too for bit-for-bit consistent environments.
# Dockerfile (optional)
FROM mcr.microsoft.com/powershell:7.4-alpine
RUN pwsh -NoLogo -NoProfile -Command "Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -Scope AllUsers -Force"
WORKDIR /src
COPY . .
CMD ["pwsh","-NoLogo","-NoProfile","-Command","Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"]Practical tips and guardrails
- Pick a small set of high-signal rules first. Good starters:
PSAvoidUsingInvokeExpression,PSAvoidGlobalVars,PSAvoidUsingWriteHost,PSUseConsistentIndentation,PSPlaceOpenBrace. - Treat security-relevant rules as errors and style rules as warnings while you migrate.
- Integrate with your editor (VS Code) via the PowerShell extension so you get squiggles as you type.
- Keep the settings file and lint scripts under version control to ensure every machine and pipeline behaves the same.
- When you intentionally diverge, suppress locally with a justification comment rather than disabling a rule repo-wide.
Outcome
With automated linting, you surface style and bug risks before code runs, shorten feedback loops, and ship safer merges. Running Invoke-ScriptAnalyzer locally and in CI, enforcing fail-fast behavior on new violations, and keeping a pragmatic baseline gives you the best of both worlds: consistent quality without blocking legacy code.
Ship better scripts with repeatable linting. For deeper patterns and advanced techniques, see the PowerShell Advanced Cookbook → link.
#PowerShell #PSScriptAnalyzer #Scripting #CodeQuality #DevOps #PowerShellCookbook #Automation