70

I have a Visual Studio solution with multiple projects, each project is an individual microservice. It is very convenient to the development team to have all the services in the same solution, and git repo, as services can call each other.

Master.sln - SubFolderA - MicroserviceA.sln
           - SubFolderB - MicroserviceB.sln
           - SubFolderC - MicroserviceC.sln

I would, however, like to independently build/release the individual microservices in Azure DevOps when they change, so if ServiceA is the only service to change, then ServiceA is the only service built and deployed.

To this end I created a new build pipeline definition with "Path filters" set to trigger the build when the contents of a microservice folder change (so one path filter added per microservice to monitor).

My problem here is that when a build is triggered (based on a change to SubFolderA e.g.) I have no way to tell the build definition to only build the .sln file in SubFolderA.

I could create a separate build definition for each microservice and trigger each build on separate subfolders, but this would come at significant overhead, i.e. I would need to maintain 15 separate build definitions (same again for each branch I build), and the storage required on our self host build agent would now be NumberOfService x NumberOfBranchesBeingBuild x SizeOfRepo.

Is there a way to use a single Build Definition with git "Path filters" and multiple paths defined, which in turn kicks off multiple build instances and feeds the value of the path that triggered the build into the build definition and so telling the build instance which .sln file to build?

I hope that makes sense!

riQQ
  • 9,878
  • 7
  • 49
  • 66
Slicc
  • 3,217
  • 7
  • 35
  • 70
  • 1
    > as services can call each other. <-- I hope this is not _directly_ call each other (e.g. over HTTP/S, etc) but via a message bus .... – Pure.Krome Feb 06 '19 at 07:14
  • If you use a template for the common tasks, then maintaining 15 build pipelines is not difficult. – Vince Bowdren May 19 '22 at 13:59

8 Answers8

59

You can do like below

  1. Create variables based on your microservices with the values "False"

E.g,MicroserviceAUpdated= "False",MicroserviceBUpdated= "False" etc.,

  1. Add a Powershell script task at the begin of your build definition. The powershell script will do the following:

Get the changeset/commit in the build to check which files are changed.

  • Update the MicroserviceAUpdated variable to "true" if only any files are changed under SubFolderA.
  • Update the MicroserviceBUpdated variable to "true" if only any
    files are changed under SubFolderA.

So on....

  1. Create separate build task for each microservice, configure the build tasks to run with custom conditions like below

For MicroserviceA build Task

"Custom conditions": and(succeeded(), eq(variables['MicroserviceAUpdated'], 'True'))

For MicroserviceB build Task

"Custom conditions": and(succeeded(), eq(variables['MicroserviceBUpdated'], 'True'))

So on...

This way MicoserviceTask will be skipped if the value of the variable is False

For Step 2

$files=$(git diff HEAD HEAD~ --name-only)
$temp=$files -split ' '
$count=$temp.Length
echo "Total changed $count files"
For ($i=0; $i -lt $temp.Length; $i++)
{
  $name=$temp[$i]
  echo "this is $name file"
  if ($name -like "SubFolderA/*")
    {
      Write-Host "##vso[task.setvariable variable=MicroserviceAUpdated]True"
    }
}
Jayendran
  • 9,638
  • 8
  • 60
  • 103
  • Sounds promising, in step 2 do you know what the git command is to determine if a folder has changed and then to set a build variable to true? – Slicc Nov 09 '18 at 20:25
  • @Slicc I've updated my answer with the code. This way the latest commit is contains all the changes. So, make sure that you have all the changes in the latest commit. – Jayendran Nov 10 '18 at 05:11
  • 1
    If doing this, how do you handle project dependencies? Say you have SubFolderCommon – MathLib.sln which microservice A,B,K and F depends on – user1038502 Jan 24 '19 at 21:40
  • @user1038502 If the change to the `SubFolderCommon` changes something the Microservices (MSs), wouldn't the MSs also be updated to accommodate the change(which would trigger their own build)? And if the MSs depend on the output of `SubFolderCommon` then would't they continue to work by republishing `MathLib.sln`? – PedroC88 Jan 23 '20 at 16:14
  • Will the powershell script only work for code that is stored in GIT? or will it work with code that is stored in TFS? – Jared May 28 '20 at 22:39
  • Is there any way to apply this logic for **TFVC** repo.? – Dharti Sutariya Jul 30 '20 at 06:27
  • I cannot get this to work... getting error "Could not access 'HEAD'". Any idea what I am missing or why I am getting this error? – Lisa-Marie May 28 '21 at 06:54
  • make sure you provided necessary [access permission from your script to your repo](https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/git-commands?view=azure-devops&tabs=yaml#allow-scripts-to-access-the-system-token) – Jayendran May 29 '21 at 05:14
  • 1
    I have used the same technique for years, however, something has changed and `git diff HEAD HEAD~ --name-only` throws an error: `fatal: ambiguous argument 'HEAD~': unknown revision or path not in the working tree.` Not sure if this is AzDO or Git agent related. – woter324 Sep 08 '22 at 13:50
  • if you are getting fatal: ambiguous argument, make sure to set fetchDepth: 0 on checkout task. – Faizal Sidek Oct 25 '22 at 02:27
34

On the Triggers tab, there is an option to specify the path to the project you want to build. When that path is specified, only commits which contain modifications that match the include/exclude rules will trigger a build.

In my case this is a much better solution than the PowerShell script which still triggered builds and releases on all the projects spamming our Slack and filling with rubbish our project's history.

enter image description here

deleb
  • 569
  • 4
  • 6
  • 2
    This is indeed the most elegant solution. However, it should be noted that path filters is not supported for non-cloud hosted sources. So if you've got `Azure Repos Git`, `GitHub`, `Bitbucket Cloud` etc. as your source, you can use the solution above. However, if you're using the `Other Git` option as source (like me), the above solution is not available, unfortunately. – Kazu May 16 '19 at 14:27
  • 8
    No matter how many path filters you add, it would still build the whole solution instead of the specified solution, correct? If so, that would not accomplish the user's ask, which is to build one out of multiple solutions in a repo. – Greg Feb 27 '20 at 04:04
  • I believe it only builds the specified project. Though that project may have dependencies from other projects in the solution, in which case they will also be built. – deleb Feb 28 '20 at 12:42
  • 4
    @Greg is correct. If I set 3 path filters (libraryA, libraryB and libraryC), every time there is a change to _any_ of those folders, all three will be rebuilt. I believe OP wanted to only rebuild the one that has actually changed. So this solution won't actually do what OP was asking about... – EugeneRomero Mar 10 '20 at 09:15
  • 1
    This solution just explains the trigger. You can also specify what projects are being restored, built, published, etc in the actual tasks. – Safari137 Mar 13 '20 at 20:02
  • @Greg If you just select a single path then it only builds its dependencies and the project itself. But the need of the user is, according to my thoughts, is that he wants to publish/replace to server just those projects which have a change. Otherwise, he doesn't want to publish the other projects. So, if only a single project is set in a particular pipeline then it will replace the publish of only that project. – Umair Tahir Nov 09 '21 at 15:27
24

This post has helped me a lot so I wanted to add some useful modifications I've made to my process.

The first major problem I found is that this git diff command does not handle multiple commits at once.

git diff HEAD HEAD~ --name-only

HEAD~ only looks behind 1 commit, where as a single push could contain multiple commits at once.

I realized that I needed to do the diff between HEAD and the commit id since the pipeline last ran successfully.

git diff HEAD [commit id of last successful build] --name-only

This commit id is available by calling the Azure DevOps API at the /build/latest endpoint, sourceVersion.

$response = (Invoke-RestMethod -Uri $url -Method GET -Headers $AzureDevOpsAuthenicationHeader)
$editedFiles = (git diff HEAD $response.sourceVersion --name-only)

I also made a modification to the logic finding changed project / module folders. I did not want to have to modify my PowerShell script every time I added a new project by hardcoding my project names.

$editedFiles | ForEach-Object { 
    $sepIndex = $_.IndexOf('/')
    if($sepIndex -gt 0) {
        $projectName = $_.substring(0, $sepIndex)
        AppendQueueVariable $projectName
    }
}

AppendQueueVariable will maintain a list of all the changed projects to return to the pipeline.

Finally, I take the list of queued projects and pass them into my Maven multi-module build pipeline task.

mvn -amd -pl [list returned from PS task] clean install
fearnight
  • 241
  • 2
  • 2
  • 2
    Best answer here. Doing the exactly same thing right now in our project. – Repcak Oct 29 '20 at 10:16
  • Trying to use this git diff command but find that if I rerun pipeline on master branch then I get an error (first time PR merge to master works, but starting a new build manually on master when there has been no changes, that fails) Error I get is: `##[error]warning: refname 'bb3e659a02a130210f4dc7571d03a3a6693f169b' is ambiguous.` – Lisa-Marie Jun 03 '21 at 07:13
  • Could you please expand on this, `This commit id is available by calling the Azure DevOps API at the /build/latest endpoint, sourceVersion.` I tried couple of end points and also looked at the documentation, but can't figure out the exact url. – resp78 Jul 26 '21 at 14:09
  • 1
    I'm using the endpoint on the API called /build/latest/ Here is the link to the documentation: https://learn.microsoft.com/en-us/rest/api/azure/devops/build/latest/get?view=azure-devops-rest-6.1 This endpoint will give you a response with an element called sourceVersion. This will be the commit id needed for this process. – fearnight Jul 27 '21 at 15:12
  • When using the /build/latest API endpoint, what value should be used for the "definition" segment? I've tried looking for some information on this but haven't had any luck. The closest I [think I] got was this: https://learn.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-6.1#definitionreference, but I can't figure out how to iterate through the result array, and I'm not sure if the Build.definition property in that response is what I'm after, anyway. – RobC Nov 01 '22 at 12:47
  • 1
    Great answer, thanks for addressing multi-commit pushes. Building on this, I constructed the API URL dynamically as `$url = "$($env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI)$($env:SYSTEM_TEAMPROJECT)/_apis/build/latest/$($env:BUILD_DEFINITIONNAME)?api-version=6.1-preview.1"`, and the auth headers as `$headers = @{ Authorization = "Bearer $($env:SYSTEM_ACCESSTOKEN)" }` The access token does need to be [explicitly passed](https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken) to the script for security reasons – chossenger Jan 06 '23 at 02:03
22

Jayendran answer is very excellent! Here's a more PowerShell-y way to do step 2:

$editedFiles = git diff HEAD HEAD~ --name-only
$editedFiles | ForEach-Object {
    Switch -Wildcard ($_ ) {
        'SubFolderA/*' { Write-Output "##vso[task.setvariable variable=MicroserviceA]True" }
        # The rest of your path filters
    }
}
Taul
  • 2,113
  • 1
  • 14
  • 18
  • 7
    One thing I've noticed is if you push multiple commits at once, the pipeline only factors in the most recent. So if I edit three different projects and have a separate commit for each one, this script will only evaluate the most recent commit, resulting in only one project being built. Is there a way to edit the script or pipeline to run git diff against all of the pushed commits? – Rico Jun 23 '20 at 20:23
16

To complement deleb's answer, here is the YAML code to set up the path trigger:

trigger:
  branches:
    include:
    - master
  paths:
    include:
    - path/to/src*

Note that you need to have the branch trigger also to use the path trigger.

https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/azure-repos-git?view=azure-devops&tabs=yaml#paths

Edit: fixed path per Dariusz's comment

Lazer
  • 501
  • 4
  • 14
  • 2
    Please note Azure DevOps allow only paths that does match the pattern of `^[^\/~\^\: \[\]\\]+(\/[^\/~\^\: \[\]\\]+)*$`, so `include` path should be `path/to/src*` – Dariusz Woźniak Apr 07 '22 at 10:35
  • 1
    Thanks @DariuszWoźniak, I changed the path according to your comment – Lazer May 19 '22 at 13:45
10

The solution is to have one azure-pipelines.yml per service which is in a sub-folder. Each of the azure-pipelines.yml inside the sub-folders must have the following trigger definition.

trigger:
  branches:
    include:
      - master
  paths:
    include:
      - <service subfolder name>/*

The paths -> include section of the yaml, tells azure pipelines to trigger only if there are changes in that particular path.

Its not necessary to give a different name to azure-pipelines.yml inside the sub-folders and the same name can be retained. Similarly, it is not necessary to add a azure-pipelines.yml to the root of the repo unless there is some code which needs to be built in the root outside the subfolders. In such cases, the following trigger section has to be added to the azure-pipelines.yml at the root of the repo.

trigger:
  branches:
    include:
      - master
  paths:
    exclude:
      - <service subfolder name 1>/*
      - <service subfolder name 2>/*

The paths -> exclude section excludes the subfolders which already contain their own azure-pipelines.yml file and gets triggered only when there is a change in the root of the repo outside the subfolders.

This blog explains monorepo pipelines in more detail.

9

In bash, you can do something like the following:

  - task: Bash@3
    displayName: 'Determine which apps were updated'
    inputs:
      targetType: 'inline'
      script: |
        DIFFS="$(git diff HEAD HEAD~ --name-only)"
        [[ "${DIFFS[@]}" =~ "packages/shared" ]] && echo "##vso[task.setvariable variable=SHARED_UPDATED]True"
        [[ "${DIFFS[@]}" =~ "packages/mobile" ]] && echo "##vso[task.setvariable variable=MOBILE_UPDATED]True"
        [[ "${DIFFS[@]}" =~ "packages/web" ]] && echo "##vso[task.setvariable variable=WEB_UPDATED]True"
Stephen Saucier
  • 1,925
  • 17
  • 20
  • 3
    You will need to add set +e because if a file hasn't been changed the test will return an exit code of 1 and the task will fail in Azure Pipelines – ToDevAndBeyond Aug 12 '20 at 17:56
  • What do you mean by adding and set +e? where exactly do you have to add that? and where will the error be? @ToDevAndBeyond – Cesar Flores Aug 02 '22 at 16:57
  • set -e means the script will exit on the first error. set +e disables that. You can do this anywhere but it is most commonly done on the first line – ToDevAndBeyond Aug 03 '22 at 17:21
0

Improvise on solution from Taul: if one project needs to be rebuilt when two or more subfolders are updated, we can use regex to detect all relevant changes:

Given MicroserviceA depends on folders SubfolderA, SubfolderB and BaseSubfolder

$editedFiles = git diff HEAD HEAD~ --name-only
$editedFiles | ForEach-Object {
    Switch -Regex ($_ ) {
        '(SubfolderA|SubfolderB|BaseSubfolder)(\/|$)' { Write-Output "##vso[task.setvariable variable=MicroserviceA]True" }
        # The rest of your path filters
    }
}
blaz
  • 3,981
  • 22
  • 37