Detecting source code changes

Your pipeline might do more than just building code, like running some Terraform script, updating Kubernetes Deployments or anything else. Building code, that has not changed since the last run can be time and cost consuming, so here is how to skip a specific Azure Pipeline Job when no source code has changed.

If your repository hosts more than just your source code, but also some environment configuration files, we need to detect what exactly has changed since the last pipeline execution. The following Shell script lists all Git changes and compares them to a path filter:

#!/bin/sh
PATH_FILTER="src/"
CHANGED_FILES=$(git diff HEAD HEAD~ --name-only)
MATCH_COUNT=0

echo "Checking for file changes..."
for FILE in $CHANGED_FILES
do
  if [[ $FILE == *$PATH_FILTER* ]]; then
    echo "MATCH:  ${FILE} changed"
    MATCH_FOUND=true
    MATCH_COUNT=$(($MATCH_COUNT+1))
  else
    echo "IGNORE: ${FILE} changed"
  fi
done

echo "$MATCH_COUNT match(es) for filter '$PATH_FILTER' found."
if [[ $MATCH_COUNT -gt 0 ]]; then
  echo "##vso[task.setvariable variable=SOURCE_CODE_CHANGED;isOutput=true]true"
else
  echo "##vso[task.setvariable variable=SOURCE_CODE_CHANGED;isOutput=true]false"
fi

Take a look at the last block, which sets an Azure Pipelines variable SOURCE_CODE_CHANGED to true, if one or more changed files match the src/ path filter. It's also worth noting, that we used isOutput=true to mark the variable as an Output Variable, to make it reusable across other pipeline jobs.

Set changed code as a condition for a job

To use the SOURCE_CODE_CHANGED variable as a condition for another job, we need to set the job that executes the Shell script as a dependency. Afterwards, we can create a condition like this:

#                           job name               step name     variable name
condition: eq(dependencies.CheckChanges.outputs['check_changes.SOURCE_CODE_CHANGED'], 'true')

A full example pipeline could look like this:

name: $(BuildID)
trigger:
  paths:
    include:
      - src/*
      - env/*
      - tests/*

stages:
  - stage: 'Build'
    jobs:
      - job: CheckChanges
        displayName: 'Check changes'
        steps:
          - bash: |
              PATH_FILTER="src/"
              CHANGED_FILES=$(git diff HEAD HEAD~ --name-only)
              MATCH_COUNT=0

              echo "Checking for file changes..."
              for FILE in $CHANGED_FILES
              do
                if [[ $FILE == *$PATH_FILTER* ]]; then
                  echo "MATCH:  ${FILE} changed"
                  MATCH_FOUND=true
                  MATCH_COUNT=$(($MATCH_COUNT+1))
                else
                  echo "IGNORE: ${FILE} changed"
                fi
              done

              echo "$MATCH_COUNT match(es) for filter '$PATH_FILTER' found."
              if [[ $MATCH_COUNT -gt 0 ]]; then
                echo "##vso[task.setvariable variable=SOURCE_CODE_CHANGED;isOutput=true]true"
              else
                echo "##vso[task.setvariable variable=SOURCE_CODE_CHANGED;isOutput=true]false"
              fi
            name: check_changes
            displayName: 'Check changed files'

      - job: Build       
        displayName: 'Only when source code changed'    
        dependsOn: CheckChanges # <- Important: Mark previous job as dependency        
        condition: eq(dependencies.CheckChanges.outputs['check_changes.SOURCE_CODE_CHANGED'], 'true')
        steps:
          - # Add your build steps here
          
      - job: Rest
        displayName: 'Will always be execured'
        steps:
          - # ...

I hope, that helps to save you some build time and costs!


☝️ Advertisement Block: I will buy myself a pizza every time I make enough money with these ads to do so. So please feed a hungry developer and consider disabling your Ad Blocker.