An Azure DevOps pipeline for PowerShell scripts
Using Azure DevOps Pipelines to automate the publishing of PowerShellGet script packages to multiple release channels.
Published in Articles on by Michiel van Oosterhout ~ 7 min read
This article demonstrates an implementation of the Git-based workflow described in an earlier article. The workflow, implemented using an Azure DevOps pipeline, will publish a PowerShell script package to either a private prerelease channel, or a private or public stable release channel.

Git repository layout
The code for the script package will be hosted in a dedicated Azure DevOps repository. The script file and its unit tests file should be added to the root of the repository. An Azure DevOps pipeline is defined in a YAML file, which can be saved anywhere in the repository.
azure-pipeline.yaml
Example.ps1
Example.Tests.ps1
Publish-PowerShellScript.ps1
(The Publish-PowerShellScript.ps1
file is described in detail in the previous article, with only one minor change.)
Azure DevOps pipeline
Triggers
The pipeline should be triggered when commits are pushed to the main branch, when a semantic version tag (e.g. v1.2.3
) is created, and when a pull request is (re)opened or its head branch is updated.
trigger:
branches:
include:
- main
tags:
include:
- v*.*.*
Unlike GitHub Actions workflows, the pull request trigger for an Azure DevOps pipeline cannot be declared in the YAML file. It must be setup as a Build validation for the branch policy of the repository's main branch.

The build validation's Build expiration must be set to Immediately when main is updated.
We use wildcards to limit the tags that may trigger our pipeline. The wildcard support for triggers in Azure DevOps Pipelines is limited compared to GitHub Actions. Where GitHub Actions supports a subset of regular expressions (e.g. v[0-9]+.[0-9]+.[0-9]+
), Azure DevOps only supports *
to match zero or more characters and ?
to match a single character.
Also unlike GitHub Actions workflows, an Azure DevOps pipeline is not automatically detected, but must be registered once before it can be triggered.

Release channels
Although Azure DevOps has a concept of environments, they do not allow us to define and use the information we need to implement release channels (e.g. the URL and API key required to push a NuGet package). Instead we use a dynamically selected variable group to represent the release channel. The three release channels used in this example workflow are:
- Azure DevOps Artifacts
- For prereleases built from pull request branches
- MyGet
- For stable releases built from the main branch
- PowerShell Gallery
- For releases built from a version tag
Each variable group should define a NuGetApiKey
and a NuGetUrl
variable. The Azure DevOps Artifacts variable group can use any value for the NuGetApiKey
variable.
variables:
- ${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
- group: Azure DevOps Artifacts
- name: Ref
value: $[ replace(variables['System.PullRequest.SourceBranch'], 'refs/heads/', '') ]
- ${{ elseif eq(variables['Build.SourceBranchName'], 'main') }}:
- group: MyGet
- name: Ref
value: ${{ variables['Build.SourceBranchName'] }}
- ${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/tags/') }}:
- group: PowerShell Gallery
- name: Ref
value: ${{ variables['Build.SourceBranchName'] }}
The release channel is selected based on the source branch that triggered the pipeline. Template (compile-time) expressions (${{ ... }}
) are evaluated during initialization of the pipeline, and they have access to parameters (not used in this pipeline) and to statically defined variables, which includes some of the predefined variables. We use conditional insertion to define one set of variables that wil be available at run time. The set of variables available at run time, after the template expressions have been processed (compiled), will contain the variables from the selected variable group, which should include the NuGetApiKey
and NuGetUrl
variables.
Notice that we need to use a run-time expression ($[ ... ]
) to set the $Ref
variable for pull requests, since the predefined variable System.PullRequest.SourceBranch
is not a statically predefined variable.
Checkout
The job consists of multiple steps. The first step simply checks out the code.
jobs:
- job: publish
displayName: Publish
steps:
- checkout: self
fetchDepth: 2
The self
keyword refers to the repository that contains the pipeline definition. The fetchDepth
must be set to at least 2, in order for the script to be able to compare a commit on the main branch to its parent.
Enable Azure DevOps Artifacts
The next step is only required for publishing to the Azure Devops Artifacts release channel, so it is inserted on the condition that the pipeline was triggered by a pull request. This step uses a built-in task that installs the NuGet credential provider for Azure DevOps Artifacts and sets environment variables to ensure that NuGet can acquire the credential required to push a package to Azure Devops Artifacts.
- ${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
- task: NuGetAuthenticate@1
displayName: Enable Azure DevOps Artifacts
This may require the configuration of permissions for the Azure DevOps Artifacts feed, so that the identity that executes the pipeline is allowed to push packages to the feed.
Publish
The next step performs the actual publish by dot sourcing a script and executing the publish function defined by it. Here macro expressions ($(...)
) are used to dereference the variables defined earlier.
- pwsh: |
. ./Publish-PowerShellScript.ps1
Publish-PowerShellScript `
-Name "$(Build.Repository.Name)" `
-Ref "$(Ref)" `
-Build $(Build.BuildId) `
-ArtifactsPath "$(Build.ArtifactStagingDirectory)" `
-NuGetApiKey "$(NuGetApiKey)" `
-NuGetUrl "$(NuGetUrl)" `
-InformationAction Continue
displayName: Publish
The -Name
parameter is set explicitly. By default the parameter takes its value from the current working directory. But in an Azure DevOps pipeline that name is s
, so we explicitly pass the name of the Azure DevOps repository.
The value of the -Ref
parameter is the value of the dynamically set Ref
variable, which is the name of the pull request branch, or the name of the main branch, or the name of the version tag. This will be used by the script to determine the correct versioning strategy.
The value of the -Build
parameter is an incrementing number that uniquely identifies the run (the predefined variable Build.BuildId
), and will be used by the script to generate an incrementing prerelease label for each iteration of a pull request.
Azure DevOps reserves a dedicated directory for artifacts. We provide the value of Build.ArtifactStagingDirectory
as the value of the -ArtifactsPath
parameter, so that the script can ensure artifacts are saved to this directory.
The values for both -NuGet
parameters will come from the dynamically selected variable group. This way the script does not need to know about any specific release channel.
Test and code coverage reports
Azure DevOps has built-in support to publish a test report and a code coverage report.
- task: PublishTestResults@2
displayName: Publish test report
condition: succeededOrFailed()
inputs:
testResultsFormat: NUnit
testResultsFiles: $(Build.ArtifactStagingDirectory)/tests.xml
- task: PublishCodeCoverageResults@1
displayName: Publish code coverage report
condition: succeededOrFailed()
inputs:
codeCoverageTool: JaCoCo
summaryFileLocation: $(Build.ArtifactStagingDirectory)/coverage.xml
pathToSources: $(Build.SourcesDirectory)
The script uses Pester to generate code coverage data. The default format uses relative paths to refer to code files. For the built-in PublishCodeCoverageResults
task to generate a code coverage report, it requires the pathToSources
input to be set, so it can resolve the relative paths.

Changes to the script
The script can report errors back to the Azure DevOps Pipelines runtime using logging commands. This requires a small change to the script:
If ($Env:GITHUB_ACTIONS -eq "true")
{
Write-Information "::error::$_"
}
ElseIf ($Env:TF_BUILD -eq "True")
{
Write-Information "##vso[task.logIssue type=error]$_"
}
Else
{
Write-Error $_
}
By using a well-known environment variable (TF_BUILD
) to select the method of error reporting, the script is compatible with Azure DevOps Pipelines.

Summary
Triggers in Azure DevOps Pipelines can be combined with dynamically selected variable groups to automate publishing of PowerShellGet script packages to multiple release channels. The same script that was used in the GitHub Actions workflow can be used in an Azure DevOps pipeline with only one minor change.
Links
Updates
- Added an explanation of the
fetchDepth: 2
requirement.