Skip to content

How to use Cake with Azure DevOps

In the dotnet developer community it seems Azure DevOps is gaining popularity and it is gaining an increasing share in the Enterprise CI/CD space.

Azure DevOps supports a collaborative culture and set of processes that bring together developers, project managers, and contributors to develop software. Enabling organisations to create and improve products at a faster pace than they can with traditional software development approaches.

Azure Devops  provides a number of devops related tools, automation, automation infrastructure, resource-aware services, and integrated monitoring and alerting, among other capabilities.

What are Azure Devops Pipelines

Azure Pipelines enable the ability to automatically build and test code projects on pull requests of commits to your code repository. It supports all major languages and project types and combines continuous integrationcontinuous delivery, and continuous testing to build, test, and deliver your code to any destination.

When developing your app and setting up your CI/CD pipeline for your dotnet app, you'll need to make use of the Dotnet CLI to execute various commands to build and test your application. Typically most users would more than likely make use of YAML to create their pipeline. However, I hate to admit I'm not that much of a fan of YAML files because from my experience they tend include a lot of complexity, incurred mostly because developers intertwining shell commands within and calling out to number of other systems and services to achieve what is required. This often results in a mangled mess code.

YAML can be a great format, if you're going to use for relatively simple configuration things and setting basic properties. For instance, I don't mind using it in Helm Chart configuration in K8s. However, I find it terrible to use when defining CI/CD pipelines and other DevOps related tasks. I much prefer to resort to programming languages for this type of work.

DevOps practices emerged enabling an entire cross-functional team to focus on moving software changes to production regularly and in an sustainable and secure manner

Continuous Architecture in Practice

in-depth guidance for addressing today's key quality attributes and cross-cutting concerns such as security, performance, scalability, resilience, data, and emerging technologies. Each key technique is demonstrated through a start-to-finish case study reflecting the authors’ deep experience with complex software environments.

What is Cake

I've posted before about How to write a Cake Build script for ASP.net core project and even How to use Cake with Github Actions and How to build Docker Containers with Cake. It is my preferred way to write build and CI/CD scripts for Dotnet based applications.

Cake (C# Make) is a cross platform build automation system with a C# DSL to do things like compiling code, copy files/folders, running unit tests, compress files and build NuGet packages.

CakeBuild.net

In How to use Cake with Rider I walked through the process of how to install the Cake.Tool either locally to your project or as I much prefer to use it as a global tool available to all projects on my computer. The rest of this guide will assume that you have Cake installed locally and are at least familiar with the basics on how to use it.

How to configure Azure Pipelines to use cake

In this post we are going to initially create a Cake Script to Build, Test and Publish a test coverage to azure devops on every commit to master.

I appreciate that this might only be a Gary problem, but I find the whole Azure Devops Pipeline Configuration UI way too complex and difficult to understand what exactly I need to do and much prefer to do everything I need to do in code.

The first thing we need to do is a create a new file and name it azure-pipelines.yml in the root of your project directory. This file will run the cake script we're going to create which we're going to name cake.build

trigger:
    - master

pool:
    vmImage: ubuntu-latest

steps:
    -   script: echo Running Dotnet cake
        displayName: 'Running dotnet cake'

    -   task: UseDotNet@2
        displayName: 'Install .NET Core SDK'
        inputs:
            version: 7.x
            performMultiLevelLookup: true

    -   script: |
            dotnet new tool-manifest
            dotnet tool install Cake.Tool
            dotnet tool restore
            dotnet tool run dotnet-cake ./build.cake
          

We selected a Ubuntu based image to do our build on and we configure it to use .net 7. Then we simply ensure that we install the Cake tool which will enable us to execute our cake script.

We can create our cake.build script which we'll place in the root of our project directory.

Our script below will make use of ReportGenerator to generate reports to show the coverage quotas and also visualize which lines of your source code have been covered, by unit tests. We'll be utilising Coverlet to create these reports, so in my case I usually create a Directory.Build.props file for my unit test projects and include the following references.

<Project>
    <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))"/>

    <ItemGroup>
        <PackageReference Include="coverlet.collector" Version="3.1.2"/>
        <PackageReference Include="coverlet.msbuild" Version="3.2.0"/>
    </ItemGroup>
</Project>

Adding this references to out unit test projects, will enable Coverlet a cross platform code coverage framework for .NET, to add support for line, branch and method coverage. It works with .NET Framework on Windows and .NET Core on all supported platforms.

We also want to add a couple of tool references to our Cake Script.

  • Cake.Coverlet
  • Cake.AzureDevOps
  • dotnet-reportgenerator-globaltool

To do thiswesimply import the tools at the top our Cake Script

#addin nuget:?package=Cake.Coverlet&version=3.0.4
#addin nuget:?package=Cake.AzureDevOps&version=3.0.0
#tool dotnet:?package=dotnet-reportgenerator-globaltool&version=5.1.19

The next step we want to create some variables and define the values we require. The target value will determine which element of our script to use as the start up point. The Configuration will determine which configuration we will use for our build, because we're going to deploy for production we're going to set this to release.

The TEST_COVERAGE_OUTPUT_DIR is folder we will want to create to store our text coverage report.

var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
const string TEST_COVERAGE_OUTPUT_DIR = "coverage";

Clean

The first task we want to do is clean our our project, to remove any artifacts from previous buildruns etc. However, there will only ever be artifacts remaining from previous builds when we are running the script locally. When the script is executed on the Azure Devops environment it will be executed in the fresh clean virtual machine which will not have any artifacts to clean so this step may fail. So we want to put a conditional statement in there to check whether we are executing in Azure and only execute if not.

We make use of the Cake.AzureDevOps import we added earlier to check if we're running in Azure Pipelines.

We target our solution file for this clean up. In the example here the solution file is named diogel.sln.

Task("Clean")
    .Does(() => {
    
    if (!BuildSystem.AzurePipelines.IsRunningOnAzurePipelines)
    {
      DotNetClean("./diogel.sln");
    }
   
});

Restore

The next task is to restore all libraries we going to use in our application. We need to set the sources we want to use to pull the libraries from. In our case we're only going to make use of public libraries on Nuget. If you're going to make use of other sources etc this is where you will need to configure these.

We iterate through all the projects in all the directories and restore the libraries.

Task("Restore")
    .IsDependentOn("Clean")
    .Description("Restoring the solution dependencies")
    .Does(() => {
    var settings =  new DotNetRestoreSettings
               {
                 Verbosity = DotNetVerbosity.Minimal,
                 Sources = new [] { "https://api.nuget.org/v3/index.json" }
               };
   
    GetFiles("./**/**/*.csproj").ToList().ForEach(project => {
         Information($"Restoring dependencies for: { project.ToString()}");
         DotNetRestore(project.ToString(), settings);
    });
});

Build

After restore is complete we can now do the similar process to build the libraries.

Task("Build")
    .IsDependentOn("Restore")
    .Does(() => {
     var buildSettings = new DotNetBuildSettings {
                        Configuration = configuration,
                       };
     GetFiles("./**/**/*.csproj").ToList().ForEach(project => {
         Information($"Building {project.ToString()}");
         DotNetBuild(project.ToString(),buildSettings);
     });
});

Test

The test task is a little more complex than the rest of the tasks we've encountered so far. There is actually quite a bit going on in here, but fundamentally we just execute tests, generate a test report then we publish the resut to the Azure Devops Code Coverage report.

We want the ability to run this same script locally as well on Azure DevOps, so we just use a similar approach as before to check that we are on Azure Devops before we publish the report.

Our tests are configured to run with our coverlet settings,which we configure to generate the coverage report to a folder name we provided in the TEST_COVERAGE_OUTPUT_DIR variable. This will enable use view the report locally as well as on the CI/CD server later.

Once we have generatedthe report we publish it to Azure, however we only do this is we are running within the context of Azure Pipeline.

To view the reports are runing locally we can browse to our folder locally and view by clicking index.html

Below I have opted to view the report within my Rider IDE

Task("Test")
    .IsDependentOn("Build")
    .Does(() => {
       var directory = Directory(TEST_COVERAGE_OUTPUT_DIR);
       var testSettings = new DotNetTestSettings  {
                             Configuration = configuration,
                             NoBuild = true,
                    };
      var coverageOutput = Directory(TEST_COVERAGE_OUTPUT_DIR);           
     GetFiles("./tests/**/*.csproj").ToList().ForEach(project => {
         Information($"Running Tests : { project.ToString()}");
        
         var codeCoverageOutputName = $"{project.GetFilenameWithoutExtension()}.cobertura.xml";
         var coverletSettings = new CoverletSettings {
                       CollectCoverage = true,
                       CoverletOutputFormat = CoverletOutputFormat.cobertura,
                       CoverletOutputDirectory =  coverageOutput,
                       CoverletOutputName =codeCoverageOutputName,
                       ArgumentCustomization = args => args.Append($"--logger trx")
         };
             
         Information($"Running Tests : { project.ToString()}");
         DotNetTest(project.ToString(), testSettings, coverletSettings );
     });
     
     Information($"Output Directory Path : { coverageOutput.ToString()}");
          
     var glob = new GlobPattern($"./{ coverageOutput}/*.cobertura.xml");
         
     Information($"The result of the globpattern : { glob.ToString()}");
     var outputDirectory = Directory("./coverage/reports");
     
     var reportSettings = new ReportGeneratorSettings
     {
       ArgumentCustomization = args => args.Append($"-reportTypes:HtmlInline_AzurePipelines;Cobertura")
     };
         
     ReportGenerator(glob, outputDirectory, reportSettings);
        
     if (BuildSystem.AzurePipelines.IsRunningOnAzurePipelines)
     {
       var coverageFile = $"coverage/reports/Cobertura.xml";
       var coverageData = new AzurePipelinesPublishCodeCoverageData
       {
         CodeCoverageTool = AzurePipelinesCodeCoverageToolType.Cobertura,
         SummaryFileLocation = coverageFile,
         ReportDirectory = "coverage/reports"
       };
       Information($"Publishing Test Coverage : {coverageFile}");
       BuildSystem.AzurePipelines.Commands.PublishCodeCoverage(coverageData);
     }
});

View Coverage report on Azure

Once we commit our script to our repo and the Azure Pipelines are executed we will see the result of our code coverage tab displayed in Azure Devop Piipeline agent.

If we view the report it will displayed as follows. You will be able to drill down and view areas of interest.

The complete script

Our fully completed script to use will be as follows:

#addin nuget:?package=Cake.Coverlet&version=3.0.4
#addin nuget:?package=Cake.AzureDevOps&version=3.0.0
#tool dotnet:?package=dotnet-reportgenerator-globaltool&version=5.1.19

var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
const string TEST_COVERAGE_OUTPUT_DIR = "coverage";
Task("Clean")
    .Does(() => {
    
    if (!BuildSystem.AzurePipelines.IsRunningOnAzurePipelines)
    {
      DotNetClean("./*.sln");
    }
   
});

Task("Restore")
    .IsDependentOn("Clean")
    .Description("Restoring the solution dependencies")
    .Does(() => {
    var settings =  new DotNetRestoreSettings
               {
                 Verbosity = DotNetVerbosity.Minimal,
                 Sources = new [] { "https://api.nuget.org/v3/index.json" }
               };
   
    GetFiles("./**/**/*.csproj").ToList().ForEach(project => {
         Information($"Restoring dependencies for project:  { project.ToString()}");
         DotNetRestore(project.ToString(), settings);
    });
});

Task("Build")
    .IsDependentOn("Restore")
    .Does(() => {
     var buildSettings = new DotNetBuildSettings {
                        Configuration = configuration,
                       };
     GetFiles("./**/**/*.csproj").ToList().ForEach(project => {
         Information($"Building {project.ToString()}");
         DotNetBuild(project.ToString(),buildSettings);
     });
});

Task("Test")
    .IsDependentOn("Build")
    .Does(() => {
       var directory = Directory(TEST_COVERAGE_OUTPUT_DIR);
       var testSettings = new DotNetTestSettings  {
                             Configuration = configuration,
                             NoBuild = true,
                    };
      var coverageOutput = Directory(TEST_COVERAGE_OUTPUT_DIR);           
     GetFiles("./tests/**/*.csproj").ToList().ForEach(project => {
         Information($"Running Tests : { project.ToString()}");
        
         var codeCoverageOutputName = $"{project.GetFilenameWithoutExtension()}.cobertura.xml";
         var coverletSettings = new CoverletSettings {
                       CollectCoverage = true,
                       CoverletOutputFormat = CoverletOutputFormat.cobertura,
                       CoverletOutputDirectory =  coverageOutput,
                       CoverletOutputName =codeCoverageOutputName,
                       ArgumentCustomization = args => args.Append($"--logger trx")
         };
             
         Information($"Running Tests : { project.ToString()}");
         DotNetTest(project.ToString(), testSettings, coverletSettings );
     });
     
     Information($"Output Directory Path : { coverageOutput.ToString()}");
          
     var glob = new GlobPattern($"./{ coverageOutput}/*.cobertura.xml");
         
     Information($"globpattern : { glob.ToString()}");
     var outputDirectory = Directory("./coverage/reports");
     
     var reportSettings = new ReportGeneratorSettings
     {
       ArgumentCustomization = args => args.Append($"-reportTypes:HtmlInline_AzurePipelines;Cobertura")
     };
         
     ReportGenerator(glob, outputDirectory, reportSettings);
        
     if (BuildSystem.AzurePipelines.IsRunningOnAzurePipelines)
     {
       var coverageFile = $"coverage/reports/Cobertura.xml";
       var coverageData = new AzurePipelinesPublishCodeCoverageData
       {
         CodeCoverageTool = AzurePipelinesCodeCoverageToolType.Cobertura,
         SummaryFileLocation = coverageFile,
         ReportDirectory = "coverage/reports"
       };
       Information($"Publishing Test Coverage : {coverageFile}");
       BuildSystem.AzurePipelines.Commands.PublishCodeCoverage(coverageData);
     }
});

Task("Default")
       .IsDependentOn("Clean")
       .IsDependentOn("Restore")
       .IsDependentOn("Build")
       .IsDependentOn("Test");
       

RunTarget(target);
Gary Woodfine
Latest posts by Gary Woodfine (see all)