CI that keeps up

How do I run PSScriptAnalyzer on PowerShell scripts in CI?

To run PSScriptAnalyzer in CI, analyze every PowerShell script with one pinned PSScriptAnalyzer version, and fail the build on findings. Keep the rules in a settings file in the repository, and skip the scripts that the repository does not own. By hand, Install-PSResource and Invoke-ScriptAnalyzer -Recurse -EnableExit meet these requirements on a laptop and in a CI job. Dagger meets the same requirements in one place. Its official PSScriptAnalyzer module runs the same analysis in a container, on a laptop and in Cloud Checks.

What a good PSScriptAnalyzer setup needs

A PSScriptAnalyzer setup for CI has five requirements. Each one comes from Microsoft’s PSScriptAnalyzer docs or releases:

  1. Analyze every script. Invoke-ScriptAnalyzer analyzes .ps1, .psm1, and .psd1 files. To include the files in subdirectories, use a wildcard or the -Recurse parameter (Invoke-ScriptAnalyzer).
  2. Fail the build on findings. The cmdlet returns diagnostics as objects. “For usage in CI systems, the EnableExit exits the shell with an exit code equal to the number of error records”.
  3. Pin the PSScriptAnalyzer version. New releases add rules. For example, release 1.25.0 added AvoidReservedWordsAsFunctionNames and UseConsistentParameterSetName. A new rule can fail a build with no change in the repository.
  4. Keep the rules in the repository. A PSScriptAnalyzerSettings.psd1 file selects the rules. PSScriptAnalyzer finds it in the project root when you analyze that root. SuppressMessageAttribute in a script turns off a rule for one function.
  5. Skip scripts you do not own. Test fixtures and vendored modules stay out of the analysis. Invoke-ScriptAnalyzer has no parameter that excludes a path, so the -Path value decides which scripts it analyzes.

Run PSScriptAnalyzer by hand

On a laptop, install a fixed version of PSScriptAnalyzer from the PowerShell Gallery. Install-PSResource takes a -Version parameter, and the value “can be an exact version”. The same cmdlet reads a -RequiredResourceFile, a .psd1 or .json file that lists the modules and versions to install. Commit that file, and each machine installs the same version.

Then run Invoke-ScriptAnalyzer -Path . -Recurse -EnableExit from the project root. The command analyzes every script below the root and reads PSScriptAnalyzerSettings.psd1 from the root. To skip a directory, run the command once for each directory that you own, with that directory as -Path.

In CI, a job runs the same command. On GitHub Actions, a step with shell: pwsh runs PowerShell (workflow syntax). GitHub’s Ubuntu and Windows runner images include PowerShell and the PSScriptAnalyzer module. The Ubuntu 24.04 image readme lists both versions. To pin the version, the job installs it with Install-PSResource before the analysis.

Where the hand-built setup strains

The commands work, but each requirement lives in more than one place:

  • Pin the PSScriptAnalyzer version. The runner image changes its PSScriptAnalyzer version when the image updates. A laptop keeps the version that its developer installed. Only a version in the job’s install step and a shared required-resource file keep them the same.
  • Analyze every script and skip scripts you do not own. The list of paths lives in the CI step and in each developer’s command, so a new directory must go in both.
  • Fail the build on findings. -EnableExit must be on every command that CI runs, and on the command that each developer runs before a push.

Analyze every script with the Dagger PSScriptAnalyzer module

The official PSScriptAnalyzer module analyzes every PowerShell script in the project. A Dagger module is a package of functions that build, test, or check a project. The project must be a Git repository with a Dagger workspace. The workspace is the dagger.toml file at the project root. If the project does not have this file, follow the Quickstart. Install the module from the project root:

dagger module install dagger.io/dotnet/PsScriptAnalyzer
Installed module "ps-analyzer" in /work/dagger.toml
dagger.io/dotnet/PsScriptAnalyzer ➡️ https://github.com/dagger/PsScriptAnalyzer#main

The module’s name in the workspace is ps-analyzer. The command adds these lines to dagger.toml:

[modules.ps-analyzer]
source = "dagger.io/dotnet/PsScriptAnalyzer"

The check copies the .ps1, .psm1, and .psd1 files at or below the directory where dagger runs into a container (module README). The container runs Alpine Linux with PowerShell and PSScriptAnalyzer. The check runs the same command as by hand:

Invoke-ScriptAnalyzer -Path . -Recurse -EnableExit

If the analysis needs files that are not PowerShell files, such as data/, add them with the includeExtraFiles setting.

Fail the build on findings with dagger check

The module adds one check, ps-analyzer:check. A check is a function that passes or fails, and dagger check runs it. List the checks in the workspace:

dagger check -l
MODULE       CHECK  DESCRIPTION
ps-analyzer  check  Run PSScriptAnalyzer on all PowerShell scripts at or below the client's

Run every check in the workspace:

dagger check

To run only the PSScriptAnalyzer check, give its address:

dagger check dag://ps-analyzer/check

In this guide’s runs, the exit code counted warnings too, so a script with only warnings also makes the check fail. In a test project, tests/Helper.ps1 used gci, %, and Write-Host. This is the start of the output:

== CHECKS ==  ✘ 1 failed
✘ dag://?check=ps-analyzer/check 13.6s ERROR
  ✘ withExec pwsh -NoLogo -NoProfile -NonInteractive -Command '$ErrorActionPreference = '\''Stop'\''\nInvoke-ScriptAnalyzer -Path . -Recurse -EnableExit' 0.7s ERROR
  ┃ 
  ┃ RuleName                            Severity     ScriptName Line  Message
  ┃ --------                            --------     ---------- ----  -------
  ┃ PSAvoidUsingWriteHost               Warning      Helper.ps1 1     File 'Helper.

Next, the output lists two PSAvoidUsingCmdletAliases warnings. It ends with ! exit code: 3, one for each warning. When every script is clean, the check passes:

== CHECKS ==  ✔ 1 passed
✔ dag://?check=ps-analyzer/check 0.9s OK

Pin the PSScriptAnalyzer version with the version setting

The version setting is the PSScriptAnalyzer release that the module installs. The default is 1.22.0. Set the release that your team uses, so a new release cannot make the check fail without a change in your repository. List the current settings:

dagger module settings ps-analyzer
MODULE       KEY                VALUE  DESCRIPTION
ps-analyzer  version                   PSScriptAnalyzer version to install
ps-analyzer  exclude                   PowerShell script paths to exclude from…
ps-analyzer  includeExtraFiles         Extra non-PowerShell file paths to incl…

Set a different release:

dagger module settings ps-analyzer version 1.24.0

The command writes the setting to dagger.toml, so it applies to all users of the repository:

[modules.ps-analyzer.settings]
version = "1.24.0"

The module downloads the PSScriptAnalyzer.<version>.nupkg file from the PSScriptAnalyzer GitHub releases. Use a version that has a release on that page.

Dagger also writes dagger.lock. In this guide’s run, the file pinned the module to a Git commit and the alpine base image to a digest. Commit it with dagger.toml, so every machine runs the same module code on the same image.

Keep the rules in the repository with PSScriptAnalyzerSettings.psd1

Select the rules in a PSScriptAnalyzer settings file, the same file as by hand. The Dagger module has no rule settings of its own. The check analyzes the project root, so put PSScriptAnalyzerSettings.psd1 there. This file removes one rule:

@{
    ExcludeRules = @('PSAvoidUsingWriteHost')
}

The check copies every .psd1 file into its container, so it reads this file with no other configuration. In the test project, the PSAvoidUsingWriteHost warning stopped. The two alias warnings stayed:

  ┃ RuleName                            Severity     ScriptName Line  Message
  ┃ PSAvoidUsingCmdletAliases           Warning      Helper.ps1 2     'gci' is an a
  ┃ PSAvoidUsingCmdletAliases           Warning      Helper.ps1 2     '%' is an ali
  ! exit code: 2

To stop a warning for one function only, add SuppressMessageAttribute to that function in the script. Microsoft’s Suppressing Rules section shows how.

Skip scripts you do not own with exclude

The exclude setting is a list of script paths that the check does not analyze, such as test fixtures or vendored modules. Give the list as one JSON argument:

dagger module settings ps-analyzer exclude '["tests/"]'

The command adds the list to the settings in dagger.toml:

[modules.ps-analyzer.settings]
version = "1.24.0"
exclude = ["tests/"]

In the test project, this setting removed tests/Helper.ps1 from the analysis, and the check passed. The paths live in dagger.toml, so a laptop and Cloud Checks skip the same scripts.

Run the check locally and with Cloud Checks

The dagger check command runs the check on your machine, in the Dagger engine. The engine runs the containers. It caches each step by its inputs, so a rerun skips the steps that did not change. In this guide’s test project:

  • The first dagger check with new content in PSScriptAnalyzerSettings.psd1 took 1.7 seconds.
  • A rerun with no changes took 0.6 seconds.
  • After another change to that file, the check took 1.7 seconds.
  • The first run after a change to version took 3.1 seconds. The engine downloaded the new PSScriptAnalyzer release and used the PowerShell install from its cache.

These numbers come from one run on one machine.

Cloud Checks runs the same checks on Dagger’s Cloud Engines for every push and pull request. You do not write a CI workflow file. The repository must be on GitHub, and its origin remote must point to it. Enable Cloud Checks from the project root:

dagger cloud checks on

If you are not signed in to Dagger Cloud, or GitHub is not connected, the command asks you to do these steps first. Commit the workspace files, then push:

git add dagger.toml dagger.lock
git commit -m "Run PSScriptAnalyzer with Dagger"
git push -u origin HEAD

If you added PSScriptAnalyzerSettings.psd1, commit it too. Watch the result with dagger workspace activity. If the check fails in Cloud Checks, run dagger check locally. It runs the same analyzer version and rules on the same scripts.

Bottom line: To run PSScriptAnalyzer in CI, analyze every script with a pinned version and -EnableExit. Keep the rules in PSScriptAnalyzerSettings.psd1, and skip the scripts you do not own. By hand, the version and the paths live in each CI job and on each laptop. The Dagger PSScriptAnalyzer module keeps them in dagger.toml, and Cloud Checks run the same dagger check on every push.