Pre- and post deployment approvals in YAML pipelines
Back in June 2020, Microsoft released multi-stage pipelines. Up until then, we would use Builds and Releases to build and deploy the software, respectively. The classical Release pipeline had the ability to add pre- and post-deployment approvers to any stage, such that the deployment to the next stage is halted until someone has given the approval to do so. For me, as a developer, this is handy when I need to test my newly developed feature on Development; by adding a pre-deployment approval I can prevent the pipeline from automatically pushing a new version of the main branch whilst I’m testing my stuff. Similarly, the business may want to add a post-deployment approval to the acceptance stage, where an approval would indicate that the user acceptance tests have passed
Multi-stage pipelines, written in YAML, do not have this capability. Microsoft suggests to e.g. add an approval to an environment. This is helpful for the operations department, they must approve any deployment to production. However, for e.g. the Development environment this feature is too coarse grained; it is to be expected that all products will be deployed to Development more than once a day, and if I only want to hold off the deployment of a single product, I will have to approve the deployment of all other products.
A second approach is to have the pipeline reference an Azure Devops Library, and add an approval to that library. This differs from the previous scenario only if there is a library per application per environment. If an organization has ten applications and the standard four environments, that means that 40 libraries are needed. Good luck with that.
The good news is, not all hope is lost. If you want to realize some sort of pre- and post-deployment validation, you can use Microsoft’s ManualValidation task; it will pause the pipeline and wait for manual interaction. All you have to do is insert a ManualValidation task before a (list of) deployment job(s), and one after the deployment job(s). There is a small complication though, a ManualValidation task needs to be run in an agentless job, and if you need to deploy an application to an environment hosted on a virtual machine, you cannot use an agentless job. This means that you need to surround your deployment jobs with two agentless jobs. The following template, which is heavily based on the documentation of the each-expression, will inject these two agentless jobs:
# File: add-approvers.yaml
parameters:
- name: environment
type: string
- name: preDeployApproverVariableName # POI1
type: string
default: ''
- name: postDeployApproverVariableName
type: string
default: ''
- name: deploymentJobs
type: jobList
jobs:
# ----- Pre-deployment approval -----
- job: AwaitPreDeployApproval
displayName: Awaiting pre-deployment approval
pool: server
timeoutInMinutes: 1440 # 24 hours
steps:
- task: ManualValidation@0
condition: ne(variables['${{parameters.preDeployApproverVariableName}}'], '') # POI2
inputs:
notifyUsers: |
$(${{parameters.preDeployApproverVariableName}})
onTimeout: reject
# ----- Add property 'dependsOn' to all of the actual deployment jobs -----
- ${{ each job in parameters.deploymentJobs}}:
- ${{ each pair in job }}:
${{if ne(pair.key, 'dependsOn')}}: # Insert all proprties except 'dependsOn'
${{ pair.key }}: ${{ pair.value }}
${{ if ne(parameters.preDeployApproverVariableName, '')}}: # POI3
dependsOn:
- AwaitPreDeployApproval
- ${{if job.dependsOn }}:
- ${{ job.dependsOn }}
${{ else }}: # POI4
${{if job.dependsOn}}:
dependsOn:
- ${{ job.dependsOn }}
# ----- Post-deployment approval -----
- job: AwaitPostDeployApproval
displayName: Awaiting post-deployment approval
pool: server
timeoutInMinutes: 1440 # 24 hours
dependsOn: # POI5
- ${{ parameters.deploymentJobs.*.job }}
- ${{ parameters.deploymentJobs.*.deployment }}
- ${{ parameters.deploymentJobs.*.template }}
steps:
- task: ManualValidation@0
condition: ne(variables['${{parameters.postDeployApproverVariableName}}'], '')
inputs:
notifyUsers: |
$(${{parameters.postDeployApproverVariableName}})
onTimeout: reject
There is a lot going on here, and I’ll explain the nitty details shortly, but first a bird’s view: Firstly, a job AwaitPreDeployApproval is created. This job will have a ManualValidation task that will wait for an approval provided the variable preDeployApproverVariableName
has a non-default value. Secondly, for each job in parameter deploymentJobs
a property dependsOn
is added. Lastly, a job AwaitPostDeployApproval is created, the working is similar to that of AwaitPreDeployApproval.
Now on to the details, in particular the points of interest (POI, see code sample):
Parameter
preDeployApproverVariableName
is the name of a runtime variable , it is not its value. The point about the runtime variable will be addressed in the sample that demonstrates how to use this template.variables['${{parameters.preDeployApproverVariableName}}']
will enclose the name of the runtime variable in single quotes, get the actual value fromvariables
, and subsequently evaluated the runtime variable’s value against an empty string; if no value forpreDeployApproverVariableName
is supplied, the ManualValidation task will be skipped. Note that if the runtime variable does not exist, the condition will evaluate tone(Null, '')
, which yields false.Again, the parameter
preDeployApproverVariableName
is evaluated, and if a non-default value has been supplied, all of the jobs indeploymentJobs
will be supplied with adependsOn
property.If
preDeployApproverVariableName
does not have a value, thedependsOn
property is only added if the job by itself has this property.The asterisk is a filtered array which is needed to get the list of job names. Note that a job can come in three different forms: (1) job, (2) deployment, and (3) template; by using filtered arrays the names of all these jobs can be retrieved, which is necessary to populate the
dependsOn
property of job AwaitPostDeployApproval.
Finally, here an example pipeline that makes use of this template to first deploy to Development and subsequently to Test.
stages:
- stage: Development
variables:
- group: Development
jobs:
- template: add-approvers.yaml
parameters:
environment: Ontwikkel
preDeployApproverVariableName: PreDeployApprover
#postDeployApproverVariableName: PostDeployApprover
deploymentJobs:
- job: FirstJob
steps:
- powershell: |
Write-Host "Deploy steps first job"
- job: SecondJob
steps:
- powershell: |
Write-Host "Deploy steps second job"
- stage: Test
variables:
- group: Test
jobs:
- template: add-approvers.yaml
parameters:
environment: Test
preDeployApproverVariableName: PreDeployApprover
postDeployApproverVariableName: PostDeployApprover
deploymentJobs:
- job: FirstJob
steps:
- powershell: |
Write-Host "Deploy steps first job"
- job: SecondJob
dependsOn: FirstJob
steps:
- powershell: |
Write-Host "Deploy steps second job"
At the beginning of each stage there is a variables
section where a library with the name of the environment is being accessed. This is typically where I store my runtime variables ‘PreDeployApprover‘ and ‘PostDeployApprover‘.
In the example, preDeployApproverVariableName
has been commented out for the Development environment, and as a result there will be no pre-deployment validation. When deploying to Development, jobs FirstJob and Second job can run simultaneously, and the post-deployment job will wait for both of them to complete before executing its ManualValidation task.
Lastly, the deployment to Test shows that a deployment job can have a dependsOn
property; the sample shows that SecondJob has a dependency on FirstJob.