Published in Articles on by Michiel van Oosterhout ~ 9 min read
This article describes the Publish-PowerShellScript.ps1
script mentioned in the previous article, which describes a GitHub Actions workflow for publishing a PowerShell script package.

Publish script
The script declares a single function, which takes 6 optional parameters.
Function Publish-PowerShellScript
{
Param(
[Parameter()]
[string]$Name = (Split-Path $Pwd -Leaf),
[Parameter()]
[string]$Ref = (&{
$Tag = git describe --tags --exact-match --match "v*.*.*" HEAD 2> $null
If ($LastExitCode -eq 0)
{
Return $Tag
}
Else
{
Return (git rev-parse --abbrev-ref HEAD 2> $null)
}
}),
[Parameter()]
[string]$Main = "main",
[Parameter()]
[int]$Build = ([int][Math]::Ceiling([double]::Parse((Get-Date -UFormat %s), [CultureInfo]::CurrentCulture))),
[Parameter()]
[string]$ArtifactsPath = (Join-Path $Pwd "artifacts"),
[Parameter()]
[string]$NuGetApiKey,
[Parameter()]
[string]$NuGetUrl
)
The default parameter values allow the function to be executed locally (e.g. pwsh -c ". ./Publish-PowerShellScript; Publish-PowerShellScript"
) This is great if you want to make changes, you can test them locally without having to commit, push, and wait for GitHub Actions to trigger your workflow.
Prelude
The $errors
array is used to collect errors, and the artifacts directory is cleaned.
$errors = @()
# Clean artifacts directory
If (-not (Test-Path $ArtifactsPath))
{
New-Item $ArtifactsPath -Type Directory > $null
}
Get-ChildItem $ArtifactsPath -Recurse | Remove-Item -Force
Validate manifest

Several validations must pass successfully before the script package can be published. The first part of the script validates the manifest using Test-ScriptFileInfo
:
# Validate manifest
$manifestPath = Join-Path $Pwd "$Name.ps1"
Try
{
$manifest = Test-ScriptFileInfo $manifestPath
}
Catch
{
$errors += $_.Exception.Message
$Error.Clear()
}
When the manifest is invalid, Test-ScriptFileInfo
throws a terminating error, which we catch. The error's exception message is added to the $errors
variable declared in the prelude and cleared.
Run tests
The next part of the script uses Pester, the ubiquitous test and mock framework for PowerShell
, installed by default on GitHub Actions' runners 3, to run all unit tests in the current working directory:
# Run tests
$testResult = Invoke-Pester -Configuration @{
CodeCoverage = @{
Enabled = $true
OutputPath = Join-Path (&{If ($Null -eq $TestArtifactsPath) {Return $ArtifactsPath} Else {Return $TestArtifactsPath}}) "coverage.xml"
}
Output = @{
Verbosity = "None"
}
Run = @{
Exit = $false
PassThru = $true
Path = $Pwd
}
TestResult = @{
Enabled = $true
OutputPath = Join-Path $ArtifactsPath "tests.xml"
TestSuiteName = $Name
}
}
If ($LastExitCode)
{
$errors += "$($testResult.FailedCount) test(s) failed"
$testResult.Tests | Where-Object Result -eq "Failed" | ForEach-Object `
{
$errors += $_.Result + ": " + $_.Name
}
}
When running in a GitHub Actions workflow, Pester will set an error message for the job for each failed test and output the failure details in an expandable group 4. We suppress this behavior by setting the -Configuration
's Output.Verbosity
to "None"
. Because we also set Run.PassThru
to $True
, the test results are returned. We save them in the $testResults
variable.
By default Pester will exit with an exit code equal to the number of failed tests, thus causing the step to fail immediately after the test run completes.
We change this behavior by setting the -Configuration
's Run.Exit
to $False
, which instead sets $LastExitCode
. This allows us to check for failed tests, collect the failures in $errors
, and continue the script.
A test result and a code coverage file are created as well. GitHub Actions has no built-in support for these files, but other platforms like Azure DevOps Pipelines can use these to generate reports.
Code analysis
The next part of the script runs code analysis on all PowerShell scripts (except tests) in the current directory using the PSScriptAnalyzer module:
# Code analysis
Get-ChildItem (Join-Path $Pwd "*.ps1") -Exclude "*.Tests.ps1" -Recurse | ForEach-Object `
{
$diagnosticRecords = Invoke-ScriptAnalyzer `
-IncludeDefaultRules `
-Path $_.FullName `
-Severity "Information","Warning","Error"
If ($diagnosticRecords)
{
$diagnosticRecords | ForEach-Object `
{
$errors += $_.ScriptPath + `
":$($_.Extent.StartLineNumber)" + `
":$($_.Extent.StartColumnNumber)" + `
": " + $_.Message
}
}
}
The PSScriptAnalyzer modules comes with a set of built-in rules, and each rule has a specific severity level. By default all available rules are included, so setting the -IncludeDefaultRules
parameter to $True
is not strictly necessary.
The -Severity
parameter acts as a filter that filters out violations before they are returned (so it does not filter out rules before they are evaluated).
We don't use the -EnableExit
parameter, because it would cause the step to immediately fail by exiting with exit code 1. Instead we use the diagnostic records returned by Invoke-ScriptAnalyzer
to collect any rule violations in $errors
.
Versioning
The next part of the script validates the version declared in the manifest. If there is no $version
, then we skip the part, assuming that the manifest validation will have already reported the error.
# Versioning
[Version] $version = $manifest.Version
If ($version)
{
If ($Ref -match "^v\d+\.\d+\.\d+$")
{
If ("v$($version.Major).$($version.Minor).$($version.Build)" -ne $Ref)
{
$errors += "Version in manifest ($version) does not match tag ($Ref)"
}
}
ElseIf ((git rev-list --count HEAD) -gt 1)
{
If ($Ref -eq $Main)
{
$revision = "HEAD^1"
}
Else
{
$revision = $Main
git fetch origin "${Main}:$Main" --depth=1 --quiet
$topic = $Ref -replace "[^a-zA-Z0-9]",""
$prereleaseLabel = $topic + ("{0:000000}" -f $Build)
$prereleaseVersion = "$($manifest.Version)" + "-" + $prereleaseLabel
Update-ScriptFileInfo `
-Path $manifestPath `
-Version $prereleaseVersion
}
$path = [IO.Path]::GetTempFileName() + ".ps1"
git show "${Revision}:$(Resolve-Path $manifestPath -Relative)" > "$path"
[Version] $current = (Test-ScriptFileInfo $path).Version
If (-not ($version -gt $current))
{
$errors += "Version in manifest does not increment $current"
}
}
ElseIf($version -ne [Version] "0.0.1" -and $version -ne [Version] "0.1.0" -and $version -ne [Version] "1.0.0")
{
$errors += "Version in manifest should be 0.0.1, 0.1.0, or 1.0.0 on initial commit, or fetch depth must be at least 2."
}
}
When the workflow is triggered for a version tag, then the tag must match the version declared in the manifest.
When the workflow is triggered for the main branch or a pull request (and it is not the initial commit on the main branch), the version declared must increment the current version. On the main branch, the current version is declared in the previous commit (HEAD^1
, the first parent, using a squash merge strategy ensures this works), and thus requires a checkout depth of at least 2. On a pull request the current version is declared on the main branch, and requires fetching the head of the main branch. The correct manifest is checked out to a temporary file and parsed using Test-ScriptFileInfo
.
On a pull request a prerelease label is also added to the version in the manifest. The prerelease label is compatible with PowerShellGet v2's limited semantic versioning support (e.g. 1.2.0-topica000243
).
When the workflow is triggered for the initial commit, then the version must be 0.0.1
, 0.1.0
, or 1.0.0
.
Exit with errors
# Exit with errors
If ($errors)
{
$errors | ForEach-Object `
{
If ($Env:GITHUB_ACTIONS -eq "true")
{
Write-Information "::error::$_"
}
Else
{
Write-Error $_
}
}
$errors.Clear()
Exit 1
}
When there are errors at this point in the script, they are used to set error messages for the job when running in GitHub Actions, or to write errors to the error stream when running locally, and the script exits.

Build package
The next part of the script publishes a PowerShellGet script package to a local directory, so we can use the NuGet executable to push it in the next step. Although Publish-Script
could have been used to publish directly to the PowerShell package source (e.g. MyGet or PowerShell Gallery), it is not very flexible, for example, it cannot deal with duplicate packages. Also, by publishing the package locally, we have the option to add a step to the job to upload the package as a build artifact in GitHub Actions.
# Build package
Register-PackageSource Artifacts $ArtifactsPath `
-ProviderName PowerShellGet `
-PublishLocation $ArtifactsPath > $null
Publish-Script `
-Path $manifestPath `
-Repository Artifacts
$ignore = $Error |
Where-Object { $_.CategoryInfo.Activity -eq "Find-Package" } |
Where-Object { $_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound }
$ignore | ForEach-Object { $Error.Remove($_) }
Unregister-PackageSource Artifacts
Publish-Script
generates errors when it uses Find-Package
to ensure the package does not exist yet. These errors are removed from $Error
so that a consecutive run of the script in the same PowerShell session does not start off with errors in $Error
.
Push package
The final part of the script uses NuGet to push the package to the release channel.
# Push package
If ($NuGetUrl -and $NuGetApiKey)
{
Get-ChildItem $ArtifactsPath "*.nupkg" | ForEach-Object {
dotnet nuget push "$($_.FullName)" `
--api-key "$NuGetApiKey" `
--skip-duplicate `
--source "$NuGetUrl"
}
}
}
The GitHub Actions workflow can control where the package is publihsed to by providing the values for the $NuGetApiKey
and $NuGetUrl
parameters (see previous article).
Summary
The script to publish a PowerShell script package can use commonly available tools like Pester and PSScriptAnalyzer to validate the script prior to publishing. The script does not need to know about the specifics of GitHub Actions or GitHub environments. The script can use the Git command-line to find the information it needs for proper versioning. This should make the script portable to another platform, such as Azure DevOps pipelines (see article) or Bitbucket pipelines (see article).
Links
Updates
- Added
$ArtifactsPath
parameter and create test result and code coverage files. - Added screenshots of GitHub Actions error messages.
- Added validation of initial commit, and removed
git fetch --deepen=1
, replacing it with the requirement that the workflow performs a fetch of at least depth 2 during checkout. - Switched from
nuget
todotnet nuget
for pushing the package. - Made the script compatible with Windows PowerShell 5.1
- Fixed a bug in the default value of the
$Build
parameter.
-
GitHub issue 1583 provides a thorough overview of PowerShell error handling. ↩︎
-
GitHub issues 11461 explains that PowerShell, when executed with
-c
return exit code 1 when the command returns or sets$LastExitCode
to a number highter than 1. GitHub Actions could append; Exit ``$LastExitCode
to the command line for PowerShell steps, or usepwsh -f
instead ofpwsh -c
. More information in GitHub issue 13501. ↩︎ -
See this list of PowerShell modules installed on the Ubuntu 2204 runner which as of August 2022 includes Pester 5.3.3 and PSScriptAnalyzer 1.20.0. ↩︎
-
Pester auto-detects GitHub Actions by checking if the
GITHUB_ACTIONS
environment variable is equal toTrue
. ↩︎ -
See rulesets available in the PowerShell/PSScriptAnalyzer GitHub repository. ↩︎