Creating Reusable Build Tasks in Azure DevOps Pipelines With Templates
Use case for pipeline templates
In the world of complex enterprise applications and distributed systems you may have a need to perform many more actions and validations as part of a build pipeline than before:
- build an application,
- execute multiple types of tests like unit tests and API tests,
- perform security validations like SCA, SAST, container image scanning and scanning of third-party dependencies,
- perform application packaging and deployment, etc.
That's when it's worth considering to implement a multi-staged pipeline where you can run several jobs in parallel and control application flow with stages. Each stage may then have it's own set of checks and validations. You may even have multiple applications which have similar build tasks as part of the build pipeline - for instance, if you have multiple .NET Web API applications, it's very likely that build pipelines for those will be similar to some extent.
So, instead of duplicating build tasks in every build stage or in every build pipeline, can you optimize this somehow? Yes, you can! By introducing re-usability with build templates. 😺
A template in this case is a collection of tasks that can be re-used across build pipelines and build stages. With parameters and build conditions you can dynamically adjust configuration for the template depending on the stage or pipeline where the template is being integrated.
So, let's take a look at some examples!😼
Example 1: re-use template in multiple build pipelines
Let's say you have a collection of build tasks that you need to add to several build pipelines in your project. For example, build tasks that download some build artifacts, install some tools and run some common scripts. What you can do then is to make these tasks reusable by creating a YAML template and storing it in a common repo that can be referenced from all the respective pipelines. Here's an example of such a template YAML that is stored in a repo called common-templates
:
# prep-env-template.yaml
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '6.x'
- task: DownloadPipelineArtifact@2
displayName: 'Download Build Artifacts - File Viewer'
inputs:
buildType: 'specific'
project: '3c8d4d22-f3d0-11ec-b939-0242ac120002'
definition: '007'
buildVersionToDownload: 'latestFromBranch'
branchName: 'refs/heads/master'
artifactName: 'my-artifact'
targetPath: '$(Build.SourcesDirectory)/temp/my-artifact'
- task: PowerShell@2
displayName: 'Update service data'
inputs:
filePath: 'build-scripts/Update-Service.ps1'
As you can see, this template installs latest version of .NET 6 SDK on the build agent, downloads artifact from another repo's build and executes a PowerShell script to update some data for the service. Now, in every build pipeline where we want to execute these tasks we can check out the common-templates
repo and integrate template with a template
build task, like this:
# azure-pipeline.yaml
...
- job: prepare_environment
timeoutInMinutes: 60
pool:
vmImage : 'ubuntu-latest'
steps:
- checkout: common-templates # integrate another repo into current pipeline
- template: prep-env-template.yaml # load and execute build tasks from provided template
...
And you're all set! 🤟
Example 2: re-use parameterized template in multiple build stages
In this example we have a multi-staged build pipeline for an application where one stage builds the app, executes tests, packages and publishes the application while another stage performs security validations. Both stages need to build an app while only build stage needs to run the tests and publish an app - that's when we can dynamically adjust which template tasks should be executed for which stage based on parameterization and task conditions. So in this case we store our template YAML in the same repo as the application, in a pipeline-templates
folder:
# build-app-template.yaml
parameters:
build: true
test: true
publish: false
steps:
- task: DotNetCoreCLI@2
displayName: 'Build solution'
inputs:
command: 'build'
projects: 'source/My.TestApp.sln'
configuration: '$(buildConfiguration)' # here we refer a variable defined in main build pipeline definition
arguments: '-f net6.0'
condition: and(succeeded(),eq('${{ parameters.build }}', true))
- task: DotNetCoreCLI@2
displayName: 'Execute unit tests'
inputs:
command: 'test'
arguments: '--no-build /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./CodeCoverage/my-testapp/'
publishTestResults: true
projects: 'source/My.TestApp.sln'
condition: and(succeeded(),eq('${{ parameters.test }}', true))
- task: DotNetCoreCLI@2
displayName: 'Publish application'
inputs:
command: publish
publishWebProjects: false
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/pub-output'
projects: 'source/My.TestApp/My.TestApp.csproj'
zipAfterPublish: false
condition: and(succeeded(),eq('${{ parameters.publish }}', true))
As you can see, in the top of the template we have parameters
section where we define which parameters that can be sent to the template and what the default values for those parameters should be. With condition
property on every build task we can refer to respective parameters and activate the task based on the provided value of the parameter.
Now, in every stage of our main build pipeline we can dynamically adjust how the template should be executed, also by using parameters
property:
# azure-pipeline.yaml
...
stages:
- stage: Build
jobs:
- job: BuildApplication
pool:
vmImage: 'ubuntu-latest'
steps:
...
- template: pipeline-templates/build-app-template.yaml
parameters:
build: true
publish: true
test: true
...
- stage: Security
jobs:
- job: SecurityChecks
pool:
vmImage: 'ubuntu-latest'
steps:
...
- template: pipeline-templates/build-app-template.yaml
parameters:
build: true
publish: false # no need to publish app in this stage -you can also skip providing this parameter - then default value will be used which is also 'false'
test: false # no need to run tests in this stage
...
And we're good to go!😸
Build templates can really help you make your pipeline code more reusable and keep the build pipeline definition cleaner and smaller since you don't need to duplicate the same build tasks every single time, which is always nice. 💖
You can learn more about templates here: Template types & usage
Thanks for reading and till next tech tip! 😻