How to build a CI pipeline for PowerShell modules in Azure DevOps

In this post we will do a complete walkthrough for configuring a new continuous integration (CI) pipeline that builds PowerShell modules in Azure DevOps Pipelines. Formalizing your PowerShell build steps into a CI pipeline helps enforce code quality standards and setup a fully automated process for publishing.

I have covered some of these pieces individually in other posts; for example my module starter kit and linting configurations. However a full post is helpful to tie all the pieces together in a detailed guide.

Overview

There are a lot of moving parts here, so let’s start with an overview. The following items will be covered:

  1. Environment prerequisites.
  2. New PowerShell module setup.
  3. Building the Azure DevOps pipeline.
  4. Artifact publishing strategies.
  5. Configuring branch policies.
  6. Testing the pipeline.

Source code used in this tutorial: Github project link.

PowerShell pipeline example

Environment Prerequisites

You will need an Azure DevOps account. You can create one for free here. You need an Azure DevOps organization and project to be in place before getting started as well.

Once you have those things in place, create a new git repository for your new project and then clone it to your computer. Instructions for creating and cloning a git repository from Azure DevOps can be found here.

New PowerShell module setup

After the git repository is cloned to your computer, you can setup the module scaffolding. These are the project files/folders used by most projects. This helps keep your project organized according to best practices.

You can use a module generator such as Plaster or borrow the structure from an already configured demo module. I have two example starters on my Github that may be helpful:

Example Module 1: PowerShellCiPipelineStarterKit. Stripped down module example used in this post that includes Azure DevOps CI pipeline support.

Example Module 2: PowerShellModuleStarterKit. More full featured module sample that demonstrates features like classes, external library references, private module data usage, and static resource files.

Move onto the next step once you have your module scaffolding and at least one function and one (Pester) unit test in place.

Building the Azure DevOps pipeline

Azure DevOps CI pipelines are defined by YAML configuration files stored in source control. At a high level, this is what we want our pipeline definition to look like:

  1. Declare pipeline triggers (when/how this pipeline is kicked off).
  2. Declare the base image and supporting variables.
  3. Build stage: Run style checks (linting), run unit test suite, publish test results.
  4. Release stage: Package and publish artifacts.

The main action for this step is to add your Pipeline.yaml file to the repository with your build steps. The example below leverages built-in tasks like CopyFiles, PublishBuildArtifacts, and PowerShell. Style checks and unit testing steps are defined as PowerShell scripts under the \Pipeline folder in the repository and referenced in the YAML file. This helps keep the YAML pipeline file a bit cleaner.

Note: The publish step used here is a simple flat file publish for demo purposes but we will cover additional publishing strategies in a section further down.

File on Github with syntax highlighting here: Pipeline.yaml

# Azure DevOps YAML pipeline
# Schema/help: https://aka.ms/yaml

trigger:
- master

pr:
  branches:
    include:
      - '*'

pool:
  vmImage: windows-latest

variables:
- name: ModPath
  value: $(Build.SourcesDirectory)\MyModule
- name: PSScriptAnalyzerVersion
  value: 1.19.0
- name: LinterSettings
  value: $(Build.SourcesDirectory)\PSScriptAnalyzerSettings.psd1
- name: PesterVersion
  value: 4.10.1
- name: TestResultsFile
  value: $(Build.ArtifactStagingDirectory)\MyModuleUnitTestResults.xml

stages:
- stage: Build
  displayName: Lint and Test
  jobs:
    - job: Default
      steps:
        - task: PowerShell@2
          name: Linter
          displayName: Run PSScriptAnalyzer Linter
          inputs:
            targetType: filePath
            filePath: $(Build.SourcesDirectory)\Pipeline\Invoke-LinterStep.ps1
            arguments: -ModulePath $(ModPath) -LinterSettingsPath $(LinterSettings) -AnalyzerVersion $(PSScriptAnalyzerVersion)
            errorActionPreference: stop
            failOnStderr: true
            pwsh: true
        - task: PowerShell@2
          name: UnitTests
          displayName: Run Pester Unit Tests
          inputs:
            targetType: filePath
            filePath: $(Build.SourcesDirectory)\Pipeline\Invoke-UnitTestsStep.ps1
            arguments: -ModulePath $(ModPath) -PesterVersion $(PesterVersion) -TestResultsFilePath $(TestResultsFile)
            errorActionPreference: stop
            failOnStderr: true
            pwsh: true
        - task: PublishTestResults@2
          name: TestResults
          displayName: Publish Unit Test Results
          condition: always()
          inputs:
            testResultsFormat: NUnit
            testResultsFiles: $(TestResultsFile)

- stage: Release
  displayName: Package and Publish
  condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/master'))
  jobs: 
    - job: Default
      steps:
        - task: CopyFiles@2
          name: Package
          displayName: Package Flat Files
          inputs:
            sourceFolder: $(ModPath)
            contents: |
              **
              !Tests/**
            targetFolder: $(Build.ArtifactStagingDirectory)\drop
        - task: PublishBuildArtifacts@1
          name: Publish
          displayName: Publish Flat Files
          inputs:
            pathToPublish: $(Build.ArtifactStagingDirectory)\drop
            artifactName: MyModule-Drop
            publishLocation: Container

The scripts referenced here will run the style checks (linting) with PSScriptAnalyzer and run the unit tests with the Pester framework. You can find those files in the sample module as well under Invoke-LinterStep.ps1 and Invoke-UnitTestsStep.ps1.

Once you have your YAML pipeline file and build scripts checked in, you can create the new pipeline from the YAML definition:

  1. In Azure DevOps, select the Pipelines section.
  2. Click on Create Pipeline.
  3. Select Azure Repos Git (YAML) from the Where is your code? section.
  4. Select your git repository.
  5. Select Existing Azure Pipelines YAML file and choose your pipeline file from the file browser.
  6. On the Review window, click Save.

When you have reached this point any check-ins to this repository or pull requests will trigger your pipeline.

Note: PR builds will not trigger the release/publish phase based on the conditional check in that stage.

Artifact publishing strategies

There are several different artifact publishing options available. Our example above used a flat files method, where we simply copied the module contents into a folder and published it as a folder artifact in the Azure DevOps artifact feeds.

Flat files publish: This approach is nice if your module is meant for private usage and the functions are mostly consumed by other CI pipelines in the same Azure DevOps organization. The reason is that Azure DevOps pipelines can easily pull down the correct/latest version of flat file artifacts from your internal feed and then other pipeline scripts can simply import the module by path from the extracted artifacts folder.

Public module publish: If your module is meant for public usage and you need to publish the module to PSGallery, then you need to follow these general steps:

  1. Have a PSGallery account and API key. Store this API key in Azure KeyVault (not in your YAML pipeline file).
  2. Ensure your module manifest (.psd1 file) has the correct/updated metadata including module version before you publish.
  3. Call Publish-Module and specify the path to your module and your PSGallery API key (retrieved from an Azure KeyVault lookup task).

More information about PSGallery publishing can be found here.

Private module publish: If your module is meant for private usage but must be consumed from a module repository, you can package the module as a nuget package and publish it to a nuget feed. A tutorial on that can be found here.

Configuring branch policies

Final step in the process is to configure branch policies for our repository. This will help ensure that best practices like a pull request + code review process is followed and people don’t just check-in directly to master.

  1. Find your Azure DevOps Project Settings (gear icon in the bottom left of the UI).
  2. Click on Repositories under the Repos heading in the left hand side-bar.
  3. Find your Git repo in the list and expand the branches to find the master branch, then select it.
  4. Switch to the Policies tab towards the top of the page.

These settings are a good starting point, but additional restrictions may be helpful depending on your environment.

  • Require a minimum number of reviewers: 1
  • Check for linked work items: required (if you use Azure DevOps for work tracking)
  • Check for comment resolution: required

Next up add a build validation step. This enforces that the build must pass before pull requests can be merged. I also added some automatic code reviewers (in this case, someone from this group must approve before we can merge).

Testing the pipeline

Once the pipeline is created and we have our branch policies configured we can run through a full test.

To exercise the entire pipeline: Create private branch from the repository, change a file, commit those changes, and then open a new pull request in Azure DevOps.

You will notice a couple things in the Azure DevOps pull request screenshot below:

  • A build was automatically triggered because we submitted the PR. It must pass otherwise I cannot merge.
  • The PR automatically added my code reviewers group as a required reviewer.
  • I can’t just approve my own changes, someone else in the reviewers group must approve my changeset.
  • If I look at the PR build, the ‘package and publish’ stage is skipped (because we dont want this running on pull request builds).

Once I get a code review sign off and complete the pull request, the code change is merged to master and the CI build automatically starts. We can see that ran successfully too but now the build ran the flat files publish steps we had configured just for CI builds:

Looking at the build output we can see our test results published and our (flat files) artifact was published. Now we have a fully functional PowerShell pipeline backed by YAML configuration in our repository.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s