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.

PowerShell banner
PowerShell banner source

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

A barrier to entry
A barrier to entry source

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.

Errors reported to the GitHub Actions runtime by the script
Errors reported to the GitHub Actions runtime by the script

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 to dotnet 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.

  1. GitHub issue 1583 provides a thorough overview of PowerShell error handling. ↩︎

  2. 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 use pwsh -f instead of pwsh -c. More information in GitHub issue 13501↩︎

  3. 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. ↩︎

  4. Pester auto-detects GitHub Actions by checking if the GITHUB_ACTIONS environment variable is equal to True↩︎

  5. See rulesets available in the PowerShell/PSScriptAnalyzer GitHub repository. ↩︎