Conditional Bicep Deployment in Azure DevOps Using Git

Conditional Bicep Deployment in Azure DevOps Using Git

Optimizing Your Pipeline by Deploying Bicep Files Only When They Change

In a previous article, we've explored how Bicep makes deploying infrastructure to Azure both efficient and streamlined. This approach shines particularly when you need to spin up new environments rapidly. However, you've likely noticed a snag: our Azure DevOps pipeline reruns Bicep deployments with every execution, regardless of whether the Bicep files have changed. While Azure is intelligent enough to assess file changes against the existing resource group setup, this comparison isn't instantaneous; it can take up valuable minutes.

As you can see in the screenshot above, it takes ~1m 15s to run a deployment that doesn't have any infrastructure changes.

As a solution, you might consider disabling the Bicep deployment altogether, triggering it manually only when required. But that approach has its pitfalls: What if you deploy a new feature requiring infrastructure modifications but forget that critical manual step? You risk breaking your application, causing downtime for your users until you update the infrastructure and redeploy.

To sidestep these issues, we'll employ a more surgical method: using Git commands to detect changes in our Bicep files, triggering the infrastructure deployment only when necessary. This not only streamlines the process but also minimizes the risk of human error.

Using Git to detect changes in Bicep files

Azure DevOps doesn't offer a straightforward mechanism to conditionally run a pipeline step based on changes to specific files or folders. To work around this limitation, we'll leverage Git, specifically the git diff command, to achieve this granular control.

Before we can execute any git commands we have to fetch our repository, so I'll use this command:

- checkout: self
    fetchDepth: 0

Now we are going to add a script step in our pipeline which will check if there are any .bicep files modified and put this result into a variable. Our script looks like this:

- script: |
     echo "##vso[task.setvariable variable=RunBicepDeployment]$(git diff --quiet HEAD HEAD~1 **/*.bicep; echo $?)"

I know it looks very messy, so let's break it down in two parts:

  1. Checking if Bicep files have been modified in the latest commit
  1. Setting a variable in our pipeline with the result of the previous command

To check if any .bicep files have been modified we use this command:

git diff --quiet HEAD HEAD~1 **/*.bicep; echo $?

Here's a breakdown of each component of this command:

  • git diff: The basic command to show differences between two points in your Git history.

  • --quiet: This flag suppresses output and focuses solely on the exit status. The exit status tells you whether or not there are differences.

  • HEAD: Represents the latest commit in the current branch.

  • HEAD~1: Represents the commit just before the latest commit in the current branch.

  • **/*.bicep: A glob pattern indicating that we're interested in any .bicep files, regardless of their location in the directory structure.

  • echo $?: Outputs the exit status of the last command. If the exit status is 0, it means there's no difference; otherwise, it'll be 1, indicating a difference.

Putting it all together, this command compares the current and previous commits, checks if any .bicep files have changed, and then echoes the result. An exit code of 0 means no change, and 1 means there is a change.

The next step is to set a variable with this exit code so we can reuse it in another task:

echo "##vso[task.setvariable variable=RunBicepDeployment]$(git diff --quiet HEAD HEAD~1 **/*.bicep; echo $?)"

echo "##vso[task.setvariable variable=RunBicepDeployment]: This is Azure DevOps specific syntax to set a pipeline variable. It sets a variable named RunBicepDeployment which we'll reuse in the next step:

- task: AzureCLI@2
  displayName: 'Deploy Bicep Infrastructure'
  inputs:
    azureSubscription: 'AzureConnection'
    scriptType: 'pscore'
    scriptLocation: 'scriptPath'
    scriptPath: './infrastructure/deploy.ps1'                
    arguments: '$(resourceGroupName) $(location) $(configFileName)'
  condition: eq(variables.RunBicepDeployment, '1')

Our Deploy Bicep Infrastructure task has only one change, the condition added at the end which allows the task to run only if the RunBicepDeployment variable equals 1.

Our entire stage looks like this:

stages:
- stage: QA
  displayName: 'Deploy To QA Env'
  dependsOn: Test
  variables:
    location: 'westeurope'
    configFileName: 'qa.json'
    resourceGroupName: 'azure-devops-yaml-pipeline-qa'
    stagingAppUrl: 'https://bogdan-todo-qa-app-staging.azurewebsites.net/health'
  jobs:
    - job: UpdateAzureResources
      steps:
        - checkout: self
          fetchDepth: 0
        - script: |
            echo "##vso[task.setvariable variable=RunBicepDeployment]$(git diff --quiet HEAD HEAD~1 **/*.bicep; echo $?)"
          displayName: Check Bicep Changes
          name: setRunBicepDeployment
        - task: AzureCLI@2
          displayName: 'Deploy Bicep Infrastructure'
          inputs:
            azureSubscription: 'AzureConnection'
            scriptType: 'pscore'
            scriptLocation: 'scriptPath'
            scriptPath: './infrastructure/deploy.ps1'                
            arguments: '$(resourceGroupName) $(location) $(configFileName)'
          condition: eq(variables.RunBicepDeployment, '1')
    - job: Deploy
      dependsOn: UpdateAzureResources
      steps:
        - task: DownloadPipelineArtifact@2
          displayName: 'Download pipeline artifact'
          inputs:
            buildType: 'current'
            artifactName: 'drop'
            targetPath: '$(Pipeline.Workspace)/drop'
        - task: AzureWebApp@1
          displayName: 'Deploy to QA'
          inputs:
            azureSubscription: 'AzureConnection'
            appType: 'webAppLinux'
            appName: 'bogdan-todo-qa-app'
            package: '$(Pipeline.Workspace)/drop'
            resourceGroupName: $(resourceGroupName)
            runtimeStack: 'DOTNETCORE|7.0'
            startUpCommand: 'dotnet ToDoApp.Server.dll'

If we push this change we notice that the Bicep deployment is skipped:

Conclusion

By incorporating this Git-based conditional logic into our pipeline, we've made it substantially more efficient. Now, the Bicep deployment step takes less than a second when there are no changes to the .bicep files. While saving a minute may not seem significant, it's important to recognize that this is a simplified example featuring only an app service and an app service plan. In a more complex, real-world application, the Bicep files would likely be far more intricate, potentially taking several minutes for each unnecessary deployment. Over time, these saved minutes add up, accelerating the time-to-market for new features and bug fixes. Moreover, for pipelines that run frequently, these efficiencies can also translate into cost savings.

The code and the pipeline I'm using to showcase these articles can be accessed here:

https://dev.azure.com/bujdea/_git/AzureDevopsYamlPipeline

https://dev.azure.com/bujdea/AzureDevopsYamlPipeline/_build?definitionId=16

Feel free to subscribe to the newsletter below or follow me on Twitter if you'd like to be notified as soon as possible when I post my next article!

Did you find this article valuable?

Support Bogdan Bujdea by becoming a sponsor. Any amount is appreciated!