Skip to content
James Dawson By James Dawson Principal I
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?

Power BI Weekly is a collation of the week's top news and articles from the Power BI ecosystem, all presented to you in one, handy newsletter!

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:

  1. No code duplication - the published script should use the same code file as the function within the module
  2. 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

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 exist
  • Update-ScriptFileInfo expects the file to already contain a PSScriptInfo 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!)
Programming C# 10 Book, by Ian Griffiths, published by O'Reilly Media, is now available to buy.

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:

  1. ScriptFileInfo block comment - generated using the existing module metadata
  2. Comment-based Documentation - extracted from the function's source file
  3. 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
The best hour you can spend to refine your own data strategy and leverage the latest capabilities on Azure to accelerate your road map.

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?

FAQs

How can you access metadata about a PowerShell function? Using the function provider, the Get-Item cmdlet will return an object containing details of the function's definition (e.g. 'Get-Item function:/myFunction').
Can you publish a standalone script to the PowerShell Gallery? Yes. Use the Publish-Script cmdlet to publish an existing script file to the PowerShell Gallery - an account and API key will be required to do this.

James Dawson

Principal I

James Dawson

James is an experienced consultant with a 20+ year history of working across such wide-ranging fields as infrastructure platform design, internet security, application lifecycle management and DevOps consulting - both technical and in a coaching capacity. He enjoys solving problems, particularly those that reduce friction for others or otherwise makes them more effective.