Blog

Using GitHub Actions to automate testing and publishing of PowerShell modules

Published Dec 16, 2022

Warn­ing
This blog post is cur­rently un­fin­ished. It is still pub­lished, be­cause its cur­rent state might be valu­able to oth­ers.

I’ve re­cently had to dip my toes into Pow­er­Shell mod­ule de­vel­op­ment. Being far from an ex­pert on Pow­er­Shell or the com­mu­nity around it, I found it a bit daunt­ing to setup my mod­ule de­vel­op­ment space for the first time. For the sake of sav­ing you (and me) the trou­ble of going through that again, I’ll be de­scrib­ing my mod­ule de­vel­op­ment setup here. This will largely focus on the ac­tual code lay­out, and not so much on the Pow­er­Shell code.

Project goals

I found that all the project tem­plates I could find lacked a few things that were im­por­tant to me. These in­clude:

Given these re­quire­ments, Cat­esta ap­peared to be the project tem­plate that got clos­est - with a few changes re­quired.

Ini­tial project setup

Be­fore set­ting up, you’ll have to de­cide on whether you want a monorepo, or want to use a new repos­i­tory for each mod­ule. The dif­fer­ences are rather slight, mostly re­volv­ing around how con­tri­bu­tions hap­pen to each mod­ule; but in my case I found that a lot of my mod­ules were re­lated to ea­chother, and as such chose to use a monorepo, with each project being rep­re­sented by a folder in a shared repos­i­tory.

Set­ting up the basic project for a mod­ule is sim­ple enough, using Cat­esta to pro­vide the boil­er­plate:

Terminal window
# install Catesta from the PSGallery
Install-Module -Name Catesta -Repository PSGallery -Scope CurrentUser
# run Catesta to set up a new project
# when run this will prompt you for some basic information: the name of the module, a description, etc.
# choose the options that work best for you; I usually do changelog and GitHub files, MIT license, OTBS coding style and platyPS for documentation
New-PowerShellProject -CICDChoice 'GitHubActions' -DestinationPath './Module-Name' # you can just use `-DestinationPath '.'` if you aren't doing the monorepo thing

This will set up a gen­eral mod­ule struc­ture for de­vel­op­ment. While this struc­ture largely fit my needs (and has been de­signed by peo­ple much smarter than me), I did find that I needed to make a few changes:

actions_bootstrap.ps1

This is a file Cat­esta sets up to in­stall the nec­es­sary mod­ules. How­ever, I found that not only was this file in­cred­i­bly slow when ran in a CI en­vi­ron­ment, as it forced in­stalls of mod­ules even if al­ready in­stalled, but it would also some­times con­tinue ex­e­cu­tion even if in­stal­la­tion of a mod­ule failed (e.g. if you had a Pow­er­Shell in­stance open with one of the mod­ules im­ported). I added the fol­low­ing to im­prove on it:

Terminal window
'Installing PowerShell Modules'
foreach ($module in $modulesToInstall) {
$updateSplat = @{
Name = $module.ModuleName
RequiredVersion = $module.ModuleVersion
Force = $true
ErrorAction = 'Stop'
}
$installSplat = @{
Name = $module.ModuleName
RequiredVersion = $module.ModuleVersion
Repository = 'PSGallery'
SkipPublisherCheck = $true
Force = $true
ErrorAction = 'Stop'
}
$curVersion = Get-Module $module.ModuleName | Select-Object -ExpandProperty Version -Last 1
if ($curVersion -eq $module.ModuleVersion) {
" - Already installed $($module.ModuleName) ${curVersion}, skipping"
continue
}
try {
if ($curVersion) {
" - Updating to $($module.ModuleName) $($module.ModuleVersion) (from old version ${curVersion})"
Update-Module @updateSplat
} else {
" - Installing $($module.ModuleName) $($module.ModuleVersion) (not previously installed)"
Install-Module @installSplat
}
Import-Module -Name $module.ModuleName -RequiredVersion $module.ModuleVersion -ErrorAction Stop
$newVersion = Get-Module $module.ModuleName | Select-Object -ExpandProperty Version -Last 1
if ($newVersion -ne $module.ModuleVersion) {
throw "New version ${newVersion} does not match expected $($module.ModuleVersion)"
}
' - Successfully installed {0}' -f $module.ModuleName
} catch {
$message = 'Failed to install {0}' -f $module.ModuleName
" - $message"
throw
}
}

src/PSScriptAnalyzerSettings.psd1

This file is used to con­fig­ure the Pow­er­Shell lin­ter PSS­crip­t­An­a­lyzer, which runs every time we build to mod­ule. While the de­faults set up by Cat­esta are nice, I did find that there were some rules that caused more prob­lems than they solved. In par­tic­u­lar, I added the fol­low­ing:

...
@{
#ExcludeRules
#Specify ExcludeRules when you want to exclude a certain rule from the the default set of rules.
ExcludeRules = @(
'PSAvoidUsingWriteHost', # I often create modules that write user-facing information, instead of programmatic output
'PSUseSingularNouns', # If I'm working on functions that take lists of items, I want to use plural nouns
'PSUseShouldProcessForStateChangingFunctions' # The heuristic for when to apply this rule is simply too poor, making it unusable
)
# ...
}

src/Module-Name/Module-Name.psd1

Mod­i­fy­ing this file is ac­tu­ally a re­quire­ment by Cat­esta. It con­tains the mod­ule man­i­fest that will be used by Pow­er­Shell and PS­Gallery to un­der­stand the mod­ule, and con­tains a few cru­cial fields that must be set man­u­ally:

FunctionsToExport|CmdletsToExport|VariablesToExport|AliasesToExport = '*'
Contains a list of the functions, cmdlets, variables or aliases the module exports. The default value '*' is not good practice, and will throw an error in the linting stage. Update these as you develop the module (e.g. 'Foo','Bar'), or set them to the empty list @() if you have none to export.
PrivateData.PSData.ProjectUri
Contains a link to the main website for the project, which will be shown on the PSGallery page. Will throw an error in the linting stage if not set. Usually I set this to the link of the repository on GitHub, linking directly to the sub-folder of the module if I'm using a monorepo.

Build­ing the mod­ule for the first time

Once the above changes are done, we’ll want to test that the build process still works:

Terminal window
$ .\actions_bootstrap.ps1
Installing PowerShell Modules
- Already installed Pester 5.3.3, skipping
- Installing InvokeBuild 5.10.1 (from old version 5.9.9.0)
- Successfully installed InvokeBuild
- Already installed PSScriptAnalyzer 1.21.0, skipping
- Already installed platyPS 0.12.0, skipping
$ Invoke-Build -File .\src\ModuleName.build.ps1
# ... output removed for clarity
Build succeeded. 18 tasks, 0 errors, 0 warnings 00:00:12.3459517

De­pend­ing on whether or not you’ve im­ple­mented your mod­ule, you might have a fail­ing test - this is in­ten­tional from the Cat­esta tem­plate we used to gen­er­ate the project. Fix­ing it is a good in­tro­duc­tion to how the project is set up.