Publishing Scripts to the PowerShell Gallery
The ability to publish standalone scripts via the PowerShell Gallery has been available for a couple years now, but until recently I never had a compelling use-case to experiment with the feature. At endjin we maintain a number of PowerShell modules to encapsulate different areas of functionality that use across many of our projects, some of which are open source.
As mentioned, until recently, our needs were entirely met by publishing the full modules. For example, the ability to get core deployment functionality onto a deployment agent with a simple Install-Module
command has been transformative in simplifying how we share such functionality and ensure consistency across projects. I recently noticed a recurring pattern in such scenarios where we are consuming one or more of our modules; they all had a block of code near the start that handled the task of installing the required version of one of these modules.
That doesn't sound too bad I hear you mutter, you just said it was a simple Install-Module
command! True enough, however, when you throw the following considerations into the mix it ends up being a bit more involved:
- Checking whether the module is already imported, if so which version
- Checking whether the required module version is already installed
- Installing a specific or minimum version
- Supporting pre-release module versions
All of these considerations are needed to ensure a script behaves in a consistent manner whether running on a pristine CI/CD agent or on a developer's workstation.
As luck would have it, we already have such functionality included in one of our modules, so that should make life easy, right? Well, once this module is loaded then absolutely, but until then we find ourselves in a 'chicken & egg' situation - how do we install the module that contains the function that simplifies installing modules?
To-date, we relied on a block of code that was a simplified implementation of the above Assert-Module
function; but even this added bloat to what might otherwise be a really simple script (if it weren't for this boilerplate code at the start). The most basic version of this boilerplate is shown below and you can see that it certainly doesn't deal with all the considerations mentioned above.
if (!(Get-Module -ListAvailable MyModule | ? { $_.Version -eq $RequiredModuleVersion })) {
Write-Information "Installing 'MyModule' module..."
Install-Module MyModule -RequiredVersion $RequiredModuleVersion -Scope CurrentUser -Force -Repository PSGallery
}
$BuildModulePath = "MyModule"
Import-Module $BuildModulePath -RequiredVersion $RequiredModuleVersion -Force
Finally the idea dawned that if we could publish that single function as a standalone script, then the 'chicken & egg' problem could be solved with a simple Install-Script
command. Unlike the module installation, it seems justifiable to use only that command when installing a single artefact, particularly given it is unlikely to change too significantly over time (i.e. there is relatively little 'safety' value to be derived from pinning to a specific version, compared to when you are referencing a full-blown module containing many different functions).
In order for this idea to be a viable option, I set the following requirements:
- No code duplication - the published script should use the same code file as the function within the module
- No metadata duplication - the published script should use the same metadata that is used for the main module
Publishing PowerShell Script Packages
Before we get stuck in, let's take a look at what is needed to publish a script to the PowerShell Gallery. Other than the script itself, the publishing process requires the following additional elements:
- Script package metadata (aka
ScriptFileInfo
) - A 'Description' comment-based documentation entry
Script Package Metadata
The script package metadata is largely the same as the metadata required when publishing a module. However, since we are publishing a single script this metadata needs to reside in the script file itself, rather than a separate manifest file (i.e. the .psd1
file). This metadata needs to take the following form:
<#PSScriptInfo
.VERSION
.GUID
.AUTHOR
.COMPANYNAME
.COPYRIGHT
.TAGS
.LICENSEURI
.PROJECTURI
.ICONURI
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
.PRIVATEDATA
#>
PowerShell includes tooling to scaffold the above content (New-ScriptFileInfo
) as well as updating it in existing files (Update-ScriptFileInfo
). These commands have parameters so you can optionally provide values for the metadata properties as part of creating/updating the script file. For example:
New-ScriptFileInfo -Description "This script does things" -Version 1.0.0 -Author "Me"
Script 'Description' Metadata
PowerShell includes a comment-based documentation system that can be used to ensure that users of your code can get the full Get-Help
experience.
When publishing a script the only mandatory documentation comment field is Description
, which is used to populate your script's listing on the PowerShell Gallery site. The *-ScriptFileInfo
cmdlets include this in their output, as well as an empty parameter definition block:
<#
<-- 'ScriptFileInfo' comment block shown above -->
#>
<#
.DESCRIPTION
foo
#>
Param()
Unfortunately for this scenario, where the script file already exists, neither of the above commands are very helpful:
New-ScriptFileInfo
expects the file to not already existUpdate-ScriptFileInfo
expects the file to already contain aPSScriptInfo
block comment - whilst we could add this as a permanent part of the file, it would mean duplicating the metadata that is currently maintained in the module's.psd1
file; as mentioned above this was something we wanted to avoid
Script Contents
The last, and most important part, is our script's functionality - the PowerShell code itself. In our case this already exists as a function in the module; this presents another hurdle. To illustrate this let's consider the following examples of identical functionality, one implemented as a function the other as a script:
PowerShell Function
function RepeatMe
{
param(
[Parameter(Mandatory=$true)]
[string] $message
)
Write-Host $message
}
When you execute this, nothing happens as the script has only defined the function, rather than invoking the function.
PowerShell Script
param(
[Parameter(Mandatory=$true)]
[string] $message
)
Write-Host $message
When you execute this version, you will be prompted to enter a value for the message
parameter and then have it echoed back to you.
We therefore have a problem. If we publish the existing code file in its present 'function' form, then when someone runs it nothing will happen as it will merely declare the function - having a 'script' version of this file will be an issue for one of the requirements set out above:
No code duplication
In order to meet that requirement we will need to strip away the function
definition part of the original code file before we publish it - ideally we won't have to resort to string manipulation or regular expressions to accomplish this!
PowerShell Introspection / Reflection
One of the options for getting 'meta' with your PowerShell scripts in the built-in function provider. This allows us to explore all of the PowerShell functions available in the current process as if we were navigating a file system:
PS:> dir function:/RepeatMe
CommandType Name Version Source
----------- ---- ------- ------
Function RepeatMe
However, the above isn't a 'directory' listing of the function, that's just the default output format. What we actually have is an object representing the function definition:
PS:> dir function:/RepeatMe | Get-Member
TypeName: System.Management.Automation.FunctionInfo
OutputType Property System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.PSTypeName] OutputType {get;}
Parameters Property System.Collections.Generic.Dictionary[string,System.Management.Automation.ParameterMetadata] Parameters {get;}
ParameterSets Property System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.CommandParameterSetInfo] ParameterSets {get;}
RemotingCapability Property System.Management.Automation.RemotingCapability RemotingCapability {get;}
ScriptBlock Property scriptblock ScriptBlock {get;}
Source Property string Source {get;}
Verb Property string Verb {get;}
Version Property version Version {get;}
Visibility Property System.Management.Automation.SessionStateEntryVisibility Visibility {get;set;}
HelpUri ScriptProperty System.Object HelpUri {get=$oldProgressPreference = $ProgressPreference…
Given what we need to achieve, the ScriptBlock
property looks particularly interesting:
PS:> dir function:/RepeatMe | Select-Object -Expand ScriptBlock
param(
[Parameter(Mandatory=$true)]
[string] $message
)
Write-Host $message
The ScriptBlock
property contains the body of the function, which is exactly what we need to create our 'bare' script version.
So that's the first challenge solved, however, it has given us another problem. The ScriptBlock
property does not include the documentation comments, which means we're missing the mandatory 'Description' property; to say nothing of all the useful help content that the original function has.
Accessing PowerShell Comment-Based Documentation
Getting hold of the comment-based documentation turned out to be more tricky, at least to make it available in a way that was useful for this use case.
Before progressing, let's update our example function to include some minimal comment-based documentation:
<#
.SYNOPSIS
Displays a message containing whatever it is told
.DESCRIPTION
Repeats whatever it is told
.PARAMETER message
The message you want repeated
#>
function RepeatMe
{
param(
[Parameter(Mandatory=$true)]
[string] $message
)
Write-Host $message
}
NOTE: When writing this I was expecting to have to save the above to a file and 'dot-source' it to get the documentation working, however, I was surprised to notice that the online help continues to work fine even if you paste the above into an interactive/console session.
To access this online help simply use the Get-Help
command (or call the function with the -?
parameter):
PS:> Get-Help RepeatMe
NAME
RepeatMe
SYNOPSIS
Displays a message containing whatever it is told
SYNTAX
RepeatMe [-message] <String> [<CommonParameters>]
DESCRIPTION
Repeats whatever it is told
RELATED LINKS
REMARKS
To see the examples, type: "Get-Help RepeatMe -Examples"
For more information, type: "Get-Help RepeatMe -Detailed"
For technical information, type: "Get-Help RepeatMe -Full"
Despite the textual nature of the above output, it should come as no surprise that Get-Help
is actually returning an object; as proven by the trusty Get-Member
:
PS:> RepeatMe -? | Get-Member
TypeName: MamlCommandHelpInfo
Name MemberType Definition
---- ---------- ----------
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
ToString Method string ToString()
Category NoteProperty string Category=Function
Component NoteProperty object Component=null
description NoteProperty psobject[] description=System.Management.Automation.PSObject[]
details NoteProperty MamlCommandHelpInfo#details details=@{description=System.Management.Automation.PSObject[]; name=RepeatMe}
Functionality NoteProperty object Functionality=null
ModuleName NoteProperty string ModuleName=
Name NoteProperty string Name=RepeatMe
parameters NoteProperty MamlCommandHelpInfo#parameters parameters=@{parameter=@{description=System.Management.Automation.PSObject[]; defaultValue=; type=@{name=String}; name=message; parameterValue=String; required=true; globbing=fal…
Role NoteProperty object Role=null
Synopsis NoteProperty string Synopsis=Displays a message containing whatever it is told
syntax NoteProperty MamlCommandHelpInfo#syntax syntax=@{syntaxItem=@{parameter=@{description=System.Management.Automation.PSObject[]; name=message; parameterValue=String; required=true; globbing=false; pipelineInput=false; positi…
xmlns:command NoteProperty string xmlns:command=http://schemas.microsoft.com/maml/dev/command/2004/10
xmlns:dev NoteProperty string xmlns:dev=http://schemas.microsoft.com/maml/dev/2004/10
xmlns:maml NoteProperty string xmlns:maml=http://schemas.microsoft.com/maml/2004/10
The equally trusty Format-List
will give us an inkling as to what these properties contain:
PS:> RepeatMe -? | Format-List
parameters : @{parameter=@{description=System.Management.Automation.PSObject[]; defaultValue=; type=@{name=String}; name=message; parameterValue=String; required=true; globbing=false; pipelineInput=false; position=1}}
syntax : @{syntaxItem=@{parameter=@{description=System.Management.Automation.PSObject[]; name=message; parameterValue=String; required=true; globbing=false; pipelineInput=false; position=1}; name=RepeatMe}}
details : @{description=System.Management.Automation.PSObject[]; name=RepeatMe}
description : {@{Text=Repeats whatever it is told}}
xmlns:maml : http://schemas.microsoft.com/maml/2004/10
xmlns:command : http://schemas.microsoft.com/maml/dev/command/2004/10
xmlns:dev : http://schemas.microsoft.com/maml/dev/2004/10
Name : RepeatMe
Category : Function
Synopsis : Displays a message containing whatever it is told
Component :
Role :
Functionality :
ModuleName :
This was interesting, but it didn't seem like it would help us to re-create the original block comment without quite a lot of work. However, the block comment is well structured and (for all our modules at least) always appears at the top of the code file, outside the function definition - on that basis I conveniently convinced myself that using a Regular Expression was a reasonable option for this part.
Regular expressions across multiple lines are fraught with edge cases, so let's add some to our example function:
<#
.SYNOPSIS
Displays a message containing whatever it is told
.DESCRIPTION
Repeats whatever it is told
.PARAMETER message
The message you want repeated
#>
function RepeatMe
{
param(
[Parameter(Mandatory=$true)]
[string] $message
)
<#
A really important block comment inside the function
#>
# some other useful comment
Write-Host $message
}
<#
A bizarre comment at the end of the script!
#>
After some experimentation we end up with the following 'single-line', 'non-greedy' regular expression which extracts the comment-based documentation from our script:
- Single line (
?s
): given that our comment-based documentation spans several lines, we need the regular expression to treat the whole file as a single line in order to match the complete comment block - Non-Greedy (
.*?
): our conventions ensure that the comment block is at the top of the file, so a 'non-greedy' match of the closing block comment#>
is how we ignore any others later in the file (though clearly nested block comments would quickly unravel things!)
NOTE: We need to have our example function saved to a file this time.
PS:> Get-Content -Raw ./RepeatMe.ps1 |
Select-String "(?s)\<#.*?#\>" |
Select -Expand Matches |
Select -Expand Value
<#
.SYNOPSIS
Displays a message containing whatever it is told
.DESCRIPTION
Repeats whatever it is told
.PARAMETER message
The message you want repeated
#>
Converting the PowerShell Function into a Script
We now have all the constituent parts needed to get this existing module function published to PowerShell Gallery as a script, we just need a mechanism for putting them all together. Ultimately we're dealing with 3 sets of strings in this endeavour:
ScriptFileInfo
block comment - generated using the existing module metadata- Comment-based Documentation - extracted from the function's source file
- The code implementation - extracted from the function's source file
Whilst you could achieve this with a large format string, I tend to find that a standalone text template provides a much better development and maintenance experience. I use the EPS module for this, which is inspired by erb, Ruby's classic text templating system.
We start with creating a new file, script-to-publish.template.ps1
, with the following contents:
<#PSScriptInfo
.VERSION <%= $scriptVersion %>
.GUID <%= $scriptGuid %>
.AUTHOR <%= $scriptAuthor %>
.COMPANYNAME <%= $scriptCompanyName %>
.COPYRIGHT <%= $scriptCopyright %>
.TAGS
.LICENSEURI <%= $scriptLicenseUri %>
.PROJECTURI <%= $scriptProjectUri %>
.ICONURI <%= $scriptIconUri %>
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
.PRIVATEDATA
#>
<%= $scriptDocComments %>
<%= $scriptImplementation %>
The first part is the required ScriptFileInfo
details where the individual values are expected to be passed-in via template variables, whereas the final two of our constituent parts are expected to be provided by variables containing the complete blocks of comments and code.
Now we have our template file we can pull everything together and generate a script file that will be publishable.
NOTE: The example below is treating the RepeatMe
function mentioned above as being part of an existing module called MyModule
.
# Read the existing module metadata from the module's manifest file
$moduleMetadata = Import-PowerShellDataFile ./MyModule/MyModule.psd1
# Dot-source the file that defines the function we want to publish as a script (so we can introspect on it)
# NOTE: As an alternative, we could just import MyModule which would also make the function available (assuming it is exported)
. ./MyModule/functions/RepeatMe.ps1
# Extract the function's implementation code
$scriptImplementation = (Get-Item function:/RepeatMe).ScriptBlock.ToString()
# Extract the function's comment-based documentation
$functionDocComments = Get-Content -raw $script.path |
Select-String "(?s)\<#.*?#\>" |
Select-Object -Expand Matches |
Select-Object -Expand Value
# Setup the metadata that is specific to the script in its published form
$scriptPackage = @{
version = "1.0.0" # This should be incremented when publishing updates
guid = "cc40f684-1f78-47a5-bb25-fa4b93f3d798" # This must be unique for each script you publish
}
# Use EPS to generate the script file using the script template and constituent parts extracted above passed as binding variables
$scriptContents = Invoke-EpsTemplate -Path ./scriptFile.template.ps1 `
-Binding @{
scriptVersion = $scriptPackage.version
scriptGuid = $scriptPackage.guid
scriptAuthor = $moduleMetadata.Author
scriptCompanyName = $moduleMetadata.CompanyName
scriptProjectUri = $moduleMetadata.PrivateData.PSData.ProjectUri
scriptLicenseUri = $moduleMetadata.PrivateData.PSData.LicenseUri
scriptCopyright = $moduleMetadata.Copyright
scriptIconUri = $moduleMetadata.PrivateData.PSData.IconUri
scriptDocComments = $functionDocComments.Trim()
scriptImplementation = $functionCode.Trim()
}
Set-Content -Path ./MyScript.ps1 -Value $scriptContents -Encoding utf8
Having run the above we now have a new script file that contains the functionality that was previously only available via the module, as well as the documentation and metadata required to publish it to the PowerShell Gallery.
NOTE: To publish to the PowerShell Gallery, you will need to setup an account and create an API key.
PS:> Publish-Script -Path ./MyScript.ps1 `
-Repository PSGallery `
-NuGetApiKey "<YOUR-PSGALLERY-API-KEY>"
If the above command succeeds, you should be able to see your published script on the PowerShell Gallery.
We've used this approach to add support to our standard build process that handles packaging PowerShell modules, so that we can optionally flag specific functions to also be published as standalone scripts. The Assert-Module
example cited at the start of this post is now not only available as part of our Corvus.Deployment
module, but also as a standalone script:
Stop Press
Whilst coming to the end of writing this I thought of another way to solve the problem, without any introspection or a regular expression in sight! (although it would require us to change one of our authoring conventions). The basic technique is something used in this post and takes advantage of PowerShell's interpreted nature - can you guess how?