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.

A build validation that will trigger the pipeline
A build validation that will trigger the pipeline

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.

Azure DevOps pipeline registration
Azure DevOps pipeline registration

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.

Test and code coverage reported for a PowerShell script in the summary page of an Azure DevOps pipeline run
Test and code coverage reported for a PowerShell script in the summary page of an Azure DevOps pipeline run

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.

Errors reported to the Azure DevOps Pipelines runtime
Errors reported to the Azure DevOps Pipelines runtime

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.