If you are using a monorepo in Azure DevOps, big chances are that you want configure the pipelines for running the CI/CD in a custom way for each project inside the solution. That is when the parameters and templates that Azure DevOps provides become handy.

Lets have a look to the following simple (but realistic) example that I encounter at work few days ago.

Suppose that you have a .NET Solution containing several projects, in this case, Azure Functions Projects.

Solution
|--> AzureFunction.A
|--> AzureFunction.B

Because they are Azure Functions, naturally you want to deploy each project in a given Function App on Azure. Also, we don’t want to build and deploy AzureFunction.B if we only made changes on AzureFunction.A.

My recommendation to anyone new to fighting with the setup of pipelines is to start with the most straightforward way and then, once working, start to split things and organize them. And that is what we are going to do.

I’m going to assume that you have a basic knowledge on Yaml file format and know the differences between stage, job and step inside a pipeline. If that’s not the case, have a look to this stackoverflow answer.

Build & Deploy Azure Function

Let’s start just making a pipeline to build and deploy AzureFunction.A project. On Azure DevOps portal, go to:

Pipelines -> New pipeline -> Choose Repository -> .Net Core Function App to (Windows/Linux) on Azure

You will have to select an Azure subscription, a function app name and a project from the solution. Once done, this will configure you a ready-to-go pipeline file. You should get something quite similar to the next code:

# azure-pipeline.yml

trigger:
  - main

variables:
  azureSubscription: "magic-string"
  functionAppName: "cool-function-app"
  workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunction.A"

stages:
  - stage:
    displayName: Build stage

    jobs:
      - job:
        displayName: Build

        steps:
          - task: DotNetCoreCLI@2
            displayName: Build
            inputs:
              command: "build"
              project: $(workingDirectory)/*.csproj
              arguments: # value not relevant for the sample


          - task: ArchiveFile@2
            displayName: "Archive Files"
            inputs: # inputs values not relevant for the sample

          - publish: # value not relevant for the sample
            artifact: drop

  - stage:
    displayName: Deploy
    dependsOn: Build
    condition: succeeded()

    jobs:
      - deployment: Deploy
        displayName: Deploy
        environment: 'development'

        strategy:
          runOnce:
            deploy:

              steps:
                - task: AzureFunctionApp@1
                displayName: 'Azure functions app deploy'
                inputs:
                    azureSubscription: $(azureSubscription)
                    appName: $(functionAppName)
                    appType: # value not relevant for the sample
                    package: # value not relevant for the sample

This is simplified a little bit for our sanity and to focus only on the important stuff. We can see two stages, build and deploy, each with one or more task related.

If we want to make an other pipeline for AzureFunction.B we would have to create an other file almost identical to this one.

Split Stages

If you want to add a job to any stage, you would have to go trough each pipeline file and change it. To avoid that, we are going to split the stages into their own files. This is called a template.

# build-template.yml

parameters:
  - name: workingDirectory
    type: string
    default: ""

steps:
  - task: DotNetCoreCLI@2
    displayName: Build
    inputs:
      command: "build"
      projects: "${{ parameters.workingDirectory }}/*.csproj"
      arguments: # value not relevant for the sample

  - task: ArchiveFiles@2
    displayName: "Archive files"
    inputs: # inputs values not relevant for the sample

  - publish: # value not relevant for the sample
    artifact: drop

First thing to notice is the parameters section. In there we define the name, type and default value of the parameter to receive.

For making use of it just write ${{ parameters.name }}

In the previous sample, we are saying that this template will receive one parameter and then we use it to select the folder of the project we want to search for csproj inside, forcing to build only that specific project.

Now let’s do the same for the deploy stage.

# deploy-template.yml

parameters:
  - name: azureSubscription
    type: string
    default: ""
  - name: functionAppName
    type: string
    default: ""

steps:
  - task: AzureFunctionApp@1
    displayName: "Azure functions app deploy"
    inputs:
      azureSubscription: "${{ parameters.azureSubscription }}"
      appName: "${{ parameters.functionAppName }}"
      appType: # value not relevant for the sample
      package: # value not relevant for the sample

As we can se, this time we have 2 parameters that are later use for deploying the function.

Composing templates

Now that we have the templates for each stage, lets join them together. I created a azure-functions-template.yml file that wraps the 2 previous templates and compose them. Have a look.

# azure-functions-template.yml

parameters:
  - name: workingDirectory
    type: string
    default: ""
  - name: azureSubscription
    type: string
    default: ""
  - name: functionAppName
    type: string
    default: ""

stages:
  - stage: Build
    displayName: Build stage

    jobs:
      - job: Build
        displayName: Build

        steps:
          - template: ./build-template.yml
            parameters:
              workingDirectory: ${{ parameters.workingDirectory }}

  - stage: Deploy
    displayName: Deploy stage
    dependsOn: Build
    condition: succeeded()

    jobs:
      - deployment: Deploy
        displayName: Deploy
        environment: "development"

        strategy:
          runOnce:
            deploy:
              steps:
                - template: ./deploy-template.yml
                  parameters:
                    azureSubscription: "${{ parameters.azureSubscription }}"
                    functionAppName: "${{ parameters.functionAppName }}"

Again, first thing first, the parameters for all the templates are defined first. Then we compose the stages of the pipeline and for the steps we call template and give it the name of the template to use.

Once the template it called, is time to pass down the parameters.

Now we can reuse azure-functions-template.yml to create the actual pipelines.

The real pipeline

# functionA.yml

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - AzureFunction.A

extends:
  template: azure-functions-template.yml
  parameters:
    workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunctions.A"
    azureSubscription: "magic string"
    functionAppName: "wonderful-function-A-app-name"
# functionB.yml

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - AzureFunction.B

extends:
  template: azure-functions-template.yml
  parameters:
    workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunction.B"
    azureSubscription: "magic string"
    functionAppName: "wonderful-function-B-app-name"

Code is so little at this point that is pretty much self-explainable.

Magic word here is extends followed by the template to extend, and of course, the parameters.

  • workingDirectory is where we select the project to compile and build. $(System.DefaultWorkingDirectory) is the root of the repo.
  • azureSubscription is the service connection
  • functionAppName is the name of the function app to deploy.

The path on the trigger section is to only trigger the pipeline when changes are done on this specific project.

This is as minimalist as it can get. We have decouple all stages making our pipeline file simpler and reusable.

Visual resume

             ----------------
             | function A/B | # actual pipeline
             ----------------
                    |
                    | extends
        ----------------------------
        | azure-functions-template | # compose several stages
        ----------------------------
            |               |
    extends |               | extends
------------------     -------------------
| build-template |     | deploy-template | # individual stages and tasks
------------------     -------------------

Hope it helps 😊