Authoring YAML pipelines on Azure DevOps often tends to be repetitive and cumbersome. That repetition might happen at the tasks level, jobs level or stages level. If we do coding, we do refactoring those repetitive lines. Can we do such refactoring the pipelines? Of course, we can. Throughout this post, I'm going to discuss where the refactoring points are taken.
The YAML pipeline used for this post can be found at this repository.
Build Pipeline without Refactoring
First of all, let's build a typical pipeline without being refactored. It is a simple build stage
, which contains a single job
that includes one task
.
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: BuildWithoutTemplate | |
displayName: 'Build without Template' | |
jobs: | |
- job: HostedVs2017 | |
displayName: 'Hosted VS2017' | |
pool: | |
name: 'Hosted VS2017' | |
workspace: | |
clean: all | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: 'Write-Host "$(Greeting)"' | |
... |
Here's the result after running this pipeline. Nothing is special here.
Let's refactor this pipeline. We use template
for refactoring. According to this document, we can do templating at least three places – Steps
, Jobs
and Stages
.
Steps
Level
Refactoring Build Pipeline at the Let's say that we're building a node.js based application. A typical build order can be:
- Install node.js and npm package
- Restore npm packages
- Build application
- Test application
- Generate artifact
In most cases, Step 5 can be extra, but the steps 1-4 are almost identical and repetitive. If so, why not grouping them and making one template? From this perspective, we do refactoring at the Steps
level. If we need step 5, then we can add it after running the template.
Now, let's extract the steps from the above pipeline. The original pipeline has the template
field under the steps
field. Extra parameters
field is added to pass values from the parent pipeline to the refactored template.
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: BuildWithStepsTemplate | |
displayName: 'Build with Steps Template' | |
jobs: | |
- job: HostedVs2017 | |
displayName: 'Hosted VS2017' | |
pool: | |
name: 'Hosted VS2017' | |
workspace: | |
clean: all | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
steps: | |
- template: 'template-steps-build.yaml' | |
parameters: | |
message: 'This is from the steps template' | |
... |
The refactored template declares both parameters
and steps
. As mentioned above, the parameters
attribute gets values passed from the parent pipeline.
# template-stages-build.yaml | |
parameters: | |
vmImage: '' | |
message: '' | |
stages: | |
- stage: BuildWithStagesTemplate | |
displayName: 'Build with Stages Template' | |
jobs: | |
- job: TemplatedStage | |
displayName: 'Templated Stage' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
workspace: | |
clean: all | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
After refactoring the original pipeline, let's run it. Can you see the value passed from the parent pipeline to the steps template?
Now, we're all good at the Steps
level refactoring.
Jobs
Level
Refactoring Build Pipeline at the This time, let's do the same at the Jobs
level. Refactoring at the Steps
level lets us group common tasks while doing at the Jobs
level deals with a bigger chunk. At the Jobs
level refactoring, we're able to handle a build agent. All tasks under the steps are fixed when we call the Jobs
level template.
Of course, if we use some advanced template expressions, we can control tasks.
Let's update the original pipeline at the Jobs
level.
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: BuildWithJobsTemplate | |
displayName: 'Build with Jobs Template' | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
jobs: | |
- template: 'template-jobs-build.yaml' | |
parameters: | |
vmImage: 'vs2017-win2016' | |
message: 'This is from the jobs template' | |
... |
Then create the template-jobs-build.yaml
file that declares the Jobs
level template.
# template-jobs-build.yaml | |
parameters: | |
vmImage: '' | |
message: '' | |
jobs: | |
- job: TemplatedJob | |
displayName: 'Templated Job' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
workspace: | |
clean: all | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
Once we run the pipeline, we can figure out what can be parameterised. As we set up the build agent OS to Windows Server 2016
, the pipeline shows the log like:
Stages
Level
Refactoring Build Pipeline at the This time, let's refactor the pipeline at the Stages
level. One stage can have multiple job
s at the same time or one after the other. If there are common tasks at the Jobs
level, we can refactor them at the Jobs
level, but if there are common job
s, then the stage
itself can be refactored. The following parent pipeline calls the stage
template with parameters.
# pipeline.yaml | |
... | |
variables: | |
- name: Greeting | |
value: "G'day, mate" | |
... | |
stages: | |
... | |
- template: 'template-stages-build.yaml' | |
parameters: | |
vmImage: 'ubuntu-16.04' | |
message: 'This is from the stages template' | |
... |
The stage
template might look like the code below. Can you see the build agent OS and other values passed through parameters?
# template-stages-build.yaml | |
parameters: | |
vmImage: '' | |
message: '' | |
stages: | |
- stage: BuildWithStagesTemplate | |
displayName: 'Build with Stages Template' | |
jobs: | |
- job: TemplatedStage | |
displayName: 'Templated Stage' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
workspace: | |
clean: all | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
Let's run the refactored pipeline. Based on the parameter, the build agent has set to Ubuntu 16.04
.
Refactoring Build Pipeline with Nested Templates
We've refactored in at three different levels. It seems that we might be able to put them all together. Let's try it. The following pipeline passes Mac OS as the build agent.
# pipeline.yaml | |
... | |
variables: | |
- name: Greeting | |
value: "G'day, mate" | |
... | |
stages: | |
... | |
- template: 'template-stages-nested-build.yaml' | |
parameters: | |
vmImage: 'macOS-10.13' | |
message: 'This is from the nested stages template' | |
... |
The parent pipeline calls the nested pipeline at the Stages
level. Inside the nested template, it again calls another template at the Jobs
level.
# template-stages-nested-build.yaml | |
parameters: | |
vmImage: '' | |
message: '' | |
stages: | |
- stage: BuildWithNestedStagesTemplate | |
displayName: 'Build with Nested Stages Template' | |
jobs: | |
- template: 'template-jobs-nested-build.yaml' | |
parameters: | |
vmImage: '${{ parameters.vmImage }}' | |
message: '${{ parameters.message }}' |
Here's the nested template at the Jobs
level. It calls the existing template at the Steps
level.
# template-jobs-nested-build.yaml | |
parameters: | |
vmImage: '' | |
message: '' | |
jobs: | |
- job: NestedBuildJob | |
displayName: 'Nested Build Job on ${{ parameters.vmImage }}' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
workspace: | |
clean: all | |
steps: | |
- template: 'template-steps-build.yaml' | |
parameters: | |
message: '${{ parameters.message }}' |
This nested pipeline works perfectly.
The build pipeline has been refactored at different levels. Let's move onto the release pipeline.
Release Pipeline without Refactoring
It's not that different from the build pipeline. It uses the deployment job
instead of job
. The typical release pipeline without using a template might look like:
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: ReleaseWithoutTemplate | |
displayName: 'Release without Template' | |
jobs: | |
- deployment: HostedVs2017 | |
displayName: 'Hosted VS2017' | |
pool: | |
name: 'Hosted VS2017' | |
environment: release-without-template | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
strategy: | |
runOnce: | |
deploy: | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: 'Write-Host "$(Greeting)"' | |
... |
Can you find out the Jobs
level uses the deployment
job? Here's the pipeline run result.
Like the build pipeline, the release pipeline can also refactor at the three levels – Steps
, Jobs
and Stages
. As there's no difference between build and release, I'm going just to show the refactored templates.
Steps
Level
Refactoring Release Pipeline at the The easiest and simplest refactoring is happening at the Steps
level. Here's the parent pipeline.
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: ReleaseWithStepsTemplate | |
displayName: 'Release with Steps Template' | |
jobs: | |
- deployment: HostedVs2017 | |
displayName: 'Hosted VS2017' | |
pool: | |
name: 'Hosted VS2017' | |
environment: release-with-steps-template | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
strategy: | |
runOnce: | |
deploy: | |
steps: | |
- template: 'template-steps-release.yaml' | |
parameters: | |
message: 'This is from the steps template' | |
... |
And this is the Steps
template. There's no structure different from the one at the build pipeline.
# template-steps-release.yaml | |
parameters: | |
message: '' | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
This is the pipeline run result.
Jobs
Level
Refactoring Release Pipeline at the This is the release pipeline refactoring at the Jobs
level.
# pipeline.yaml | |
... | |
stages: | |
... | |
- stage: ReleaseWithJobsTemplate | |
displayName: 'Release with Jobs Template' | |
variables: | |
- name: Greeting | |
value: 'Hello World' | |
jobs: | |
- template: 'template-jobs-release.yaml' | |
parameters: | |
templateLevel: jobs | |
vmImage: 'vs2017-win2016' | |
message: 'This is from the jobs template' | |
... |
The refactored template looks like the one below. Each deployment job
contains the environment
field, which can also be parameterised.
# template-jobs-release.yaml | |
parameters: | |
templateLevel: '' | |
vmImage: '' | |
message: '' | |
jobs: | |
- deployment: TemplatedRelease | |
displayName: 'Templated Release' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
environment: release-with-${{ parameters.templateLevel }}-template | |
strategy: | |
runOnce: | |
deploy: | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
Stages
Level
Refactoring Release Pipeline at the As the refactoring process is the same, I'm just showing the result here:
# pipeline.yaml | |
... | |
stages: | |
... | |
- template: 'template-stages-release.yaml' | |
parameters: | |
templateLevel: stages | |
vmImage: 'ubuntu-16.04' | |
message: 'This is from the stages template' | |
... |
# tmplate-stages-release.yaml | |
parameters: | |
templateLevel: '' | |
vmImage: '' | |
message: '' | |
stages: | |
- stage: ReleaseWithStagesTemplate | |
displayName: 'Release with Stages Template' | |
jobs: | |
- deployment: TemplatedStage | |
displayName: 'Templated Stage' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
environment: release-with-${{ parameters.templateLevel }}-template | |
strategy: | |
runOnce: | |
deploy: | |
steps: | |
- task: PowerShell@2 | |
displayName: 'Echo Greeting in PowerShell' | |
inputs: | |
targetType: Inline | |
script: | | |
Write-Host "$(Greeting)" | |
Write-Host "${{ parameters.message }}" |
Refactoring Release Pipeline with Nested Templates
Of course, we can compose the release pipeline with nested templates.
# pipeline.yaml | |
... | |
stages: | |
... | |
- template: 'template-stages-nested-release.yaml' | |
parameters: | |
templateLevel: nested-stages | |
vmImage: 'macOS-10.13' | |
message: 'This is from the nested stages template' | |
... |
# template-stages-nested-release.yaml | |
parameters: | |
templateLevel: '' | |
vmImage: '' | |
message: '' | |
stages: | |
- stage: ReleaseWithNestedStagesTemplate | |
displayName: 'Release with Nested Stages Template' | |
jobs: | |
- template: 'template-jobs-nested-release.yaml' | |
parameters: | |
templateLevel: '${{ parameters.templateLevel }}' | |
vmImage: '${{ parameters.vmImage }}' | |
message: '${{ parameters.message }}' |
# template-jobs-nested-release.yaml | |
parameters: | |
templateLevel: '' | |
vmImage: '' | |
message: '' | |
jobs: | |
- deployment: NestedDeploymentJob | |
displayName: 'Nested Deployment Job on ${{ parameters.vmImage }}' | |
pool: | |
vmImage: '${{ parameters.vmImage }}' | |
environment: release-with-${{ parameters.templateLevel }}-template | |
strategy: | |
runOnce: | |
deploy: | |
steps: | |
- template: 'template-steps-release.yaml' | |
parameters: | |
message: '${{ parameters.message }}' |
So far, we've completed refactoring at the Stages
, Jobs
and Steps
levels by using templates. There must be a situation to use refactoring due to the nature of repetition. Therefore, this template approach should be considered, but it really depends on which level the refactoring template goes in because every circumstance is different.
However, there's one thing to consider. Try to create templates as simple as possible. It doesn't really matter the depth or level. The template expressions are rich enough to use advanced technics like conditions and iterations. But it doesn't mean we should use this. When to use templates, the first one should be small and simple, then make them better and more complex. The multi-stage pipeline feature is outstanding, although it's still in public preview. It would be even better with these refactoring technics.