Dotnet Core served with a slice of Cake

While preparing slides for an introduction into DevOps I was about to give to a bunch of knowledge eager .NET developers, I was looking to build a sample project to illustrate a nice setup to start with. DotNet Core was a given, with it’s great CLI interface and ability to run everywhere, the choice was obvious. But when it came to build tools, I felt that the ones I knew (psake, fake), could easily startle people new to this whole build .NET code outside of Visual Studio kind of thing. There was one kid on the block which had been on my list to look at for quite a while, and you know how much I love sidetracking and spending way too much time exploring exciting new stuff!

Long story short, I fell in love with Cake. Compared to other build automation tools, it comes with a lot of nice first- and third-party integrations, reducing the need for boilerplate code. Before, I made use of heavily customized PowerShell scripts for versioning and uploading of artifacts and test results, Cake comes with all of that out of the box. Another advantage, if the feature you’re looking isn’t available, it’s easily extensible. All the wins \0/.

So, what would my ideal setup look like? Well, we need a few things when it comes to a nice CI setup. First, I want all the build logic to be managed from the source code, no dependencies in the GUI as it easily becomes a burden to maintain and switch context. On the plus side, we want to be able to run it locally as well as on our build agent, and Cake will help us achieve exactly that. Secondly, here’s a list of stuff I’m looking for that Cake should handle for me:

  • Split the build logic in tasks
  • Group tasks to allow different types of builds
  • Provide standard build functionality (clean, restore, build)
  • Restore NuGet packages
  • Enable static code analysis and linting
  • Run unit tests and upload coverage

Let’s have a look at each of those.

Split the build logic in tasks

To get started with Cake, you can make use of the bootstrappers. There are two versions, one for Windows, and one for Unix systems. I added both in my sample project, just make sure to use the correct line endings on each platform, or go for LF everywhere (yeah, I said it). If you’re using Visual Studio Code, feel free to have a look at the Cake extension, Gary Ewan Park even made accompanying videos to explain its capabilities.

After installing a bootstrapper, make sure to pin the Cake version. You can do so by adding a file at tools/packages.config with the following content.

<?xml version="1.0" encoding="utf-8"?>
<packages>
    <package id="Cake" version="0.27.2" />
</packages>

With all this in place we have the following files, other than the project files:

.
├── build.ps1
├── build.sh
├── tools
|   ├── packages.config

Remember, we’ve got a DotNet Core project set up, in case you don’t have one, feel free to clone the sample project to follow along. The purpose of the bootstrapper files build.ps1 and build.sh is to be able to resolve all dependencies, including Cake, and not having to worry about any of that. You can have a look at the source to see what they do, but the file we’re really interested in is build.cake.

Create the build.cake file which will contain al our build logic from now on. The Cake website provides a nice getting started sample, so let’s build from there.

var target = Argument("target", "Default");

Task("Default")
  .Does(() =>
{
  Information("Hello World!");
});

RunTarget(target);

You can see we’ve met the first requirement. We have the ability to define tasks that can contain whatever logic we need. Written entirely in C#, this is a huge advantage and reduces the startup cost as a .NET developer. The task called Default is the one that runs when no parameters are defined. You can immediately see what it does by using the bootstrapper in your CLI tool of choice: ./build.ps1 (but seriously, use ConEmu). The output looks like this:

Preparing to run build script...
Running build script...

========================================
Default
========================================
Hello World!

Task                          Duration
--------------------------------------------------
Default                       00:00:00.0136945
--------------------------------------------------
Total:                        00:00:00.0136945

Group tasks to allow different types of builds

Using Cake, it’s easy to group a few tasks as one. Let’s say we want to split our Hello world task into two meaningful ones and assign the combination as the Default. We can define separate tasks and have Default depend on both of them.

var target = Argument("target", "Default");

Task("Hello")
  .Does(() =>
{
  Information("Hello");
});

Task("World")
  .Does(() =>
{
  Information("World");
});

Task("Default")
  .IsDependentOn("Hello")
  .IsDependentOn("World");

RunTarget(target);

When you run this using ./build.ps1, you can see both tasks being executed. Now that we understand the basics, it’s time to implement the actual logic for our DotNet Core app.

Provide standard build functionality (clean, restore, build)

Cake provides support for DotNet Core via a built-in third party plugin. To clean a project, you can make use of DotNetCoreClean, but in my case, as I’m used to cleaning up after myself, I used a custom implementation in the sample project. The main reason is that there’s a lot more output coming from a build than the output from MSBuild, and I want to make sure everything is nice and tidy. In case you’d opt for the built-in, the task would look something like this.

Task("Clean")
  .Does(() =>
{
  DotNetCoreClean("./src/project");
});

Remember, you can do anything you want here, so maybe you’re fine with this, or prefer a more custom approach, the choice is yours.

The approach to restore nuget packages and build the projects is almost identical other than having the ability to define a few settings. You can find all information on the Cake website, or have a peek at the sample project for inspiration.

Enable static code analysis and linting

Now that we have our base setup ready, we finally get to the interesting bits. When we think about .NET and code analysis, I hope you can’t help but think about Resharper. By coincidence, JetBrains provides two CLI tools , inspectCode and dupFinder. The first one handles all checks included in Resharper, the second one will look for duplicate code blocks. The cool thing is that we can make it validate the same ruleset we enforce via Visual Studio, by saving them to your project’s DotSettings file. You can read more about that here, we’ll be using the ‘Solution team-shared’ layer in this sample.

What might also be good to know is that Resharper in Visual Studio provides support for .editorconfig files, but those can’t be used in combination with the CLI version. I spent about an hour trying to get it to work to finally come to that conclusion with the help of Maarten Balliauw.

To use these tools in our Cake script, we need a few dependencies. First of all, and maybe that’s not at all a problem in your case, we need the Visual Studio build tools and enable DotNet Core suppport. As a developer who only uses Visual Studio Code, I did not have these preinstalled, causing a lot of pain and unclear errors. So, if you’re like me, install these on your host or build machine if that’s not already the case. Secondly we can make use of a Cake plugin called, you guessed it, Resharper. To include it, add the following line on top of your build.cake script. This will make the bootstrapper resolve the dependency when running Cake.

#tool "nuget:https://www.nuget.org/api/v2?package=JetBrains.ReSharper.CommandLineTools&version=2018.1.0"

Make sure to lock the version number of external dependencies to avoid unwanted side effects. Updates to these tools should always be validated beforehand.

All that’s left is to create tasks to validate our code.

Task("DupFinder")
    .Description("Find duplicates in the code")
    .Does(() =>
{
    var settings = new DupFinderSettings() {
        ShowStats = true,
        ShowText = true,
        OutputFile = $"{artifactsDir}/dupfinder.xml",
        ExcludeCodeRegionsByNameSubstring = new string [] { "DupFinder Exclusion" },
        ThrowExceptionOnFindingDuplicates = true
    };
    DupFinder("./App.sln", settings);
});

Task("InspectCode")
    .Description("Inspect the code using Resharper's rule set")
    .Does(() =>
{
    var settings = new InspectCodeSettings() {
        SolutionWideAnalysis = true,
        OutputFile = $"{artifactsDir}/inspectcode.xml",
        ThrowExceptionOnFindingViolations = true
    };
    InspectCode("./App.sln", settings);
});

It will use our DotSettings file out of the box, so no need to specify it. You can look at the sample to see the syntax, if you need to know where to find the names for these rules, have a look at the documentation.

We can add another interesting tool caled DependenciesAnalyser. It will look at your nuget dependencies and see if they are outdated. It won’t fail the build but it’s such a good thing to include as you might otherwise forget about this (I know I do). When I run it at the time of writing, it provides the following ouput, telling me I can update two dependencies.

EDIT: It seems this makes the UNIX builds fail. I created an issue on the project to remind myself to have a look. For the time being I removed the task from the sample.

========================================
Analyse-Dependencies
========================================
Executing task: Analyse-Dependencies
Resolving assembly Domain.XmlSerializers, Version=2.0.0.0, Culture=neutral, PublicKeyToken=null
---------------------------------
Project: ./App\App.csproj
---------------------------------

---------------------------------
Project: ./Test\Test.csproj
coverlet.msbuild is on version 2.0.0. The dependency is outdated.
FluentAssertions is on version 5.3.0. The dependency is up-to-date.
Microsoft.NET.Test.Sdk is on version 15.7.2. The dependency is outdated.
xunit is on version 2.3.1. The dependency is up-to-date.
xunit.runner.visualstudio is on version 2.3.1. The dependency is up-to-date.
---------------------------------

Finished executing task: Analyse-Dependencies

For convenience we can group these as one Task, to facilitate running the checks separately when needed.

Task("Validate")
    .Description("Validate code quality using Resharper CLI. tools.")
    .IsDependentOn("Analyse-Dependencies")
    .IsDependentOn("DupFinder")
    .IsDependentOn("InspectCode");

Run unit tests and upload coverage

Writing unit tests has always been a pleasure on DotNet. My base setup consists of a few winners (opinion, it’s my blog after all) like xUnit and Fluent Assertions. Creating a test project can be done using the dotnet new xunit command, and installing packages follows the dotnet add package FluentAssertions syntax. You can run the tests using dotnet test. A sample setup and explanation can be found on Microsoft’s website if you need a more in-depth introduction.

Cake provides the same ease of use compared to the other standard DotNet Core functionality, which makes our task look like this.

Task("Test")
    .Description("Run all unit tests within the project.")
    .Does(() =>
{
    DotNetCoreTest("./Test/Test.csproj");
});

Now, this gives us the ability to run unit tests, but how about calculating code coverage? It turns out there’s an MSBuild extension that enables just that. It’s called coverlet and you can add it as a nuget package to your test project: dotnet add package coverlet.msbuild. To use its functionality, we need to add a few additional parameters to the Test task.

var settings = new DotNetCoreTestSettings
     {
         ArgumentCustomization = args => args.Append("/p:CollectCoverage=true")
                                             .Append("/p:CoverletOutputFormat=opencover")
                                             .Append("/p:ThresholdType=line")
                                             .Append($"/p:Threshold={coverageThreshold}")
     };
DotNetCoreTest("./Test/Test.csproj", settings);

What it does is add those parameters to MSBuild, which will then use coverlet to calculate the coverage. We want to have line coverage, which we can specify using /p:ThresholdType=line. This will calculate the percentage depending on whether or not every relevant line is covered by a unit test. If the percentage drops below a certain threshold (defined op top of build.cake), it will fail the build. The output format is opencover which we need to export the result to Coveralls.io, where we can keep track of our coverage cross build and cross branch. The advantage of using an external service is that you can’t simply adjust the threshold to fix a build failure. It will also hold you back to merge changes to master in case of a drop in coverage (thresholds can be agile about this) when configured that way in GitHub for example.

To upload the changes to Coveralls.io we need three extra dependencies.

#tool "nuget:https://www.nuget.org/api/v2?package=coveralls.io&version=1.4.2"
#addin "nuget:https://www.nuget.org/api/v2?package=Cake.Coveralls&version=0.8.0"
#addin "nuget:https://www.nuget.org/api/v2?package=Cake.Incubator&version=2.0.1"

Incubator is needed to work with environment variables as we don’t want to include our Coveralls.io API token in the build script (seriously, don’t). No matter the CI tool, you can always add environment variables to use in your build logic. The proper way is to have them in the build script as encrypted variables, so that they can’t be used other than during the CI phases, but you can still adjust them via source control. I’m using Appveyor myself and it can encrypt variables to add to your appveyor.yml script.

To upload the coverage statistics via Cake, we make use of the Coveralls add-on. Combine this with incubator to retrieve the API token during build and we can upload the file generated by coverlet.

Task("Upload-Coverage")
    .Does(() =>
{
    var isRunningOnAppveyor = EnvironmentVariable<bool>("APPVEYOR", false);
    if (!isRunningOnAppveyor)
        return;
    Information("Running on Appveyor, uploading coverage information to coveralls.io");
    CoverallsIo("./Test/coverage.xml", new CoverallsIoSettings()
    {
        RepoToken = EnvironmentVariable("coveralls_token")
    });
});

Now that we’ve got all of this available, we can define a basic CI task to execute on the build machine.

Task("CI")
    .Description("Build the code, test and validate")
    .IsDependentOn("Build")
    .IsDependentOn("Test")
    .IsDependentOn("Validate")
    .IsDependentOn("Upload-Coverage");

As we’re entering a world where C# has the ability to run on other machines than Windows alone, I made a Unix specific build task too.

Task("CI-UNIX")
    .Description("Build the code, test and validate")
    .IsDependentOn("Build")
    .IsDependentOn("Test");

This way we have Appveyor to run the tests on Windows (including the static analysis and code coverage uploading) and Linux, and Travis.ci to run the tests on OSX. As we’ve got a Cake build script, all we need is the two config files to tell our CI instances what to do. Beware, Appveyor can run PowerShell on Windows and Linux, so we need to check where we are before executing the same command twice.

image:
- Visual Studio 2017
- Ubuntu
environment:
  coveralls_token:
    secure: HMJLoYbkSD1p35JnHiBjv02frHKMvasoD9J6wLnzjSUsdPwFwiHU8t0SwcvyAYi1
build_script:
  - pwsh: |
      if ($isWindows) {
        .\build.ps1 -Target CI
      }
  - sh: ./build.sh --target=CI-UNIX
language: csharp
mono: latest
dotnet: 2.1.200
os: osx
script:
  - ./build.sh --target=CI-UNIX

If you’re curious about the output or build time, you can have a look at the project on Appveyor’s or Travis’ website.

This is as far as I wanted to go with this. I’ll try to keep the project up-to-date with interesting add-ons, or maybe you know of some improvement and would like to propose a PR? Do not hesitate to get in touch!

Source code

Comments