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):

  1. 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.

  2. variables['${{parameters.preDeployApproverVariableName}}'] will enclose the name of the runtime variable in single quotes, get the actual value from variables, and subsequently evaluated the runtime variable’s value against an empty string; if no value for preDeployApproverVariableName is supplied, the ManualValidation task will be skipped. Note that if the runtime variable does not exist, the condition will evaluate to ne(Null, ''), which yields false.

  3. Again, the parameter preDeployApproverVariableName is evaluated, and if a non-default value has been supplied, all of the jobs in deploymentJobs will be supplied with a dependsOn property.

  4. If preDeployApproverVariableName does not have a value, the dependsOn property is only added if the job by itself has this property.

  5. 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.