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.
There are a lot of moving parts here, so let’s start with an overview. The following items will be covered:
- Environment prerequisites.
- New PowerShell module setup.
- Building the Azure DevOps pipeline.
- Artifact publishing strategies.
- Configuring branch policies.
- Testing the pipeline.
Source code used in this tutorial: Github project link.
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:
- Declare pipeline triggers (when/how this pipeline is kicked off).
- Declare the base image and supporting variables.
- Build stage: Run style checks (linting), run unit test suite, publish test results.
- 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:
- In Azure DevOps, select the Pipelines section.
- Click on Create Pipeline.
- Select Azure Repos Git (YAML) from the Where is your code? section.
- Select your git repository.
- Select Existing Azure Pipelines YAML file and choose your pipeline file from the file browser.
- 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:
- Have a PSGallery account and API key. Store this API key in Azure KeyVault (not in your YAML pipeline file).
- Ensure your module manifest (.psd1 file) has the correct/updated metadata including module version before you publish.
- 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.
- Find your Azure DevOps Project Settings (gear icon in the bottom left of the UI).
- Click on Repositories under the Repos heading in the left hand side-bar.
- Find your Git repo in the list and expand the branches to find the master branch, then select it.
- 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.