Automate PowerShell Linting with PSScriptAnalyzer: Local and CI Workflows You Can Trust
You ship faster when a linter catches issues before review. PSScriptAnalyzer is the de facto static analyzer for PowerShell, and with a few lines of script you can run it locally, enforce it in CI, and tune rules to your team's style. In this guide, you'll wire up automated linting that fails on errors, logs warnings for coaching, and stays consistent across machines and pipelines.
Quick Start: Run PSScriptAnalyzer Locally
First, install the module and lint a folder. This minimal script uses an inline settings object to include a few common rules and treat findings as either Error or Warning severity.
# Install once per machine/profile
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop
$path = '.\src'
try {
$settings = @{
IncludeRules = @(
'PSUseApprovedVerbs',
'PSAvoidUsingWriteHost',
'PSUseConsistentWhitespace'
)
Severity = @('Error','Warning')
}
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $settings -ErrorAction Stop
if ($results) {
$summary = $results |
Group-Object Severity |
Sort-Object Name |
ForEach-Object { '{0}: {1}' -f $_.Name, $_.Count }
$summary | ForEach-Object { Write-Host $_ }
if ($results.Severity -contains 'Error') { exit 1 }
} else {
Write-Host 'No findings.'
}
} catch {
Write-Warning ('Analyzer failed: {0}' -f $_.Exception.Message)
exit 1
}
Note: The example logs summaries with Write-Host, which will be flagged by PSAvoidUsingWriteHost. If you enable that rule, prefer Write-Output or Write-Information:
$summary | ForEach-Object { Write-Output $_ }
Write-Output 'No findings.'
Practical tips:
- Pin a module version for reproducibility:
Install-Module PSScriptAnalyzer -RequiredVersion 1.21.0(choose the version youve validated). - Scope your scan with
-Pathand-Recurse; for large repos, consider scanning only changed files (see performance tips below).
CI Integration: Consistent Feedback on Every Commit
GitHub Actions
This workflow installs PSScriptAnalyzer, runs it against src, and fails the job when Error-level findings exist. It uses a shared PSScriptAnalyzerSettings.psd1 for consistency.
name: lint-powershell
on: [push, pull_request]
jobs:
pssa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PowerShell (pwsh)
uses: PowerShell/PowerShell@v1
with:
pwsh-version: 'latest'
- name: Install PSScriptAnalyzer
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop
- name: Lint PowerShell
shell: pwsh
run: |
$settingsPath = Join-Path $PWD 'PSScriptAnalyzerSettings.psd1'
$results = Invoke-ScriptAnalyzer -Path './src' -Recurse -Settings $settingsPath -ErrorAction Stop
if ($results) {
$results | Group-Object Severity | Sort-Object Name | ForEach-Object { "::notice ::$($_.Name): $($_.Count)" }
if ($results.Severity -contains 'Error') { Write-Error 'PSScriptAnalyzer reported errors'; exit 1 }
} else {
Write-Output 'No findings.'
}
Why notice/warning/error lines? GitHub surfaces these in the UI. You can also emit per-finding annotations by iterating $results and printing ::warning file=...,line=...,col=...::message strings.
Azure Pipelines
steps:
- checkout: self
- task: PowerShell@2
inputs:
pwsh: true
targetType: inline
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop
$settings = Join-Path $(System.DefaultWorkingDirectory) 'PSScriptAnalyzerSettings.psd1'
$results = Invoke-ScriptAnalyzer -Path './src' -Recurse -Settings $settings -ErrorAction Stop
if ($results) {
$results | Group-Object Severity | Sort-Object Name | ForEach-Object { Write-Host ("Summary: {0}: {1}" -f $_.Name, $_.Count) }
if ($results.Severity -contains 'Error') { throw 'PSScriptAnalyzer errors found' }
} else { Write-Host 'No findings.' }
GitLab CI
lint:pssa:
image: mcr.microsoft.com/powershell:latest
script:
- pwsh -Command "Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop"
- pwsh -File ./build/lint.ps1
artifacts:
when: always
reports: {}
Store your lint logic in a script (e.g., build/lint.ps1) so both local and CI runs use the exact same behavior.
Tune Rules with a Settings Object or Shared .psd1
Start with a small inline object for quick wins, then graduate to a repository-level settings file to keep teams aligned.
Inline Settings (fast to start)
$settings = @{
IncludeRules = @(
'PSUseApprovedVerbs',
'PSAvoidUsingWriteHost',
'PSUseConsistentWhitespace',
'PSAvoidUsingCmdletAliases'
)
Severity = @('Error','Warning')
}
$results = Invoke-ScriptAnalyzer -Path './src' -Recurse -Settings $settings
Shared Settings File (recommended)
Create PSScriptAnalyzerSettings.psd1 at the repo root and commit it.
@{
# Narrow scope to the rules you care most about
IncludeRules = @(
'PSUseApprovedVerbs',
'PSAvoidUsingWriteHost',
'PSUseConsistentWhitespace',
'PSUseDeclaredVarsMoreThanAssignments',
'PSUseConsistentIndentation'
)
# Customize per-rule behavior
Rules = @{
PSAvoidUsingWriteHost = @{ Severity = 'Warning' }
PSUseApprovedVerbs = @{ Severity = 'Error' }
PSUseConsistentIndentation = @{ Enable = $true; IndentationSize = 2 }
}
}
With a shared .psd1 you get repeatability: developers, CI, and pre-commit hooks all honor the same rules and severities.
Fail on Errors, Coach with Warnings
Make errors block merges while warnings guide the team. The pattern looks like:
$results = Invoke-ScriptAnalyzer -Path './src' -Recurse -Settings './PSScriptAnalyzerSettings.psd1'
if ($results) {
$errors = $results | Where-Object Severity -EQ 'Error'
$warnings = $results | Where-Object Severity -EQ 'Warning'
if ($warnings) { $warnings | ForEach-Object { Write-Warning $_.Message } }
if ($errors) { $errors | ForEach-Object { Write-Error $_.Message } ; exit 1 }
}
This keeps the pipeline strict without being punitive during adoption.
Developer Workflow: Faster Feedback
VS Code Integration
- Install the PowerShell extension; it surfaces PSScriptAnalyzer diagnostics in the Problems panel.
- Enable analysis in settings:
"powershell.scriptAnalysis.enable": true. - Optionally enable formatting-on-save with the built-in formatter (
Invoke-Formatter):"editor.formatOnSave": true.
Pre-commit Hook (cross-platform)
Add a Git hook to prevent committing broken scripts:
# .git/hooks/pre-commit (make executable on Linux/macOS)
#!/usr/bin/env pwsh
try {
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop | Out-Null
$settings = Join-Path $PSScriptRoot '..' 'PSScriptAnalyzerSettings.psd1'
# Only lint staged PowerShell files
$files = git diff --cached --name-only | Where-Object { $_ -match '\\.(ps1|psm1|psd1)$' }
if ($files) {
$results = Invoke-ScriptAnalyzer -Path $files -Settings $settings -ErrorAction Stop
if ($results -and ($results.Severity -contains 'Error')) { Write-Error 'PSScriptAnalyzer errors in staged files'; exit 1 }
}
} catch {
Write-Error $_.Exception.Message; exit 1
}
This tightens the feedback loop even further.
Performance and Reliability Tips
- Analyze only changed files in CI for speed: use
git diff --name-only origin/main...HEADto build the file list. - Exclude generated/third-party code by not passing those paths into
-Path. For example, gather files withGet-ChildItem -Recurse -Include *.ps1,*.psm1 -File | Where-Object FullName -notmatch 'generated|bin|obj'. - Pin PSScriptAnalyzer versions in CI to avoid surprise rule changes.
- Always use
-ErrorAction Stopso installation and analysis failures are visible and fail the pipeline.
Suppress or Baseline Thoughtfully
Sometimes you must suppress a specific finding. Prefer targeted suppressions over disabling rules globally:
function Start-ImportantTask {
[Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs','Justified for legacy API compatibility')]
param([string]$Mode)
# ...
}
Use this sparingly and document the rationale. If youre onboarding a legacy repo with many warnings, set stricter severities for a subset of rules first, then ratchet up over time.
Putting It All Together
- Local: run a script that lints
./srcusing your shared.psd1. - CI: install PSScriptAnalyzer, run the same script, fail on errors, log warnings.
- Team: share the settings file, enable VS Code analysis, add a pre-commit hook.
Result: fewer defects, faster reviews, and a consistent code style across the board.
Sharpen your PowerShell code quality further in the PowerShell Advanced CookBook https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/