Contents

This is the second issue in a series around designing a build system for .NET projects

  1. The build submodule & Versioning dependencies
  2. Build, test and pack .NET projects by convention using Cake
  3. Build and pack .NET applications ready for deployment
  4. Introducing build configuration options and smart defaults
  5. Versioning strategy using GitVersion and auto-tagging from your CI build
  6. Templating new .NET solutions from our build submodule

Previously we began our journey in designing a build system by setting up a GIT submodule build and adding support for centralised NuGet dependency versioning. In this issue, we'll introduce the Cake build tool, and look at how we can build a convention-based build system.

Example repositories

A reminder of the available repositories:

  1. build - https://github.com/Antaris/build
  2. The parent repo - https://github.com/Antaris/example-repo-with-build-system

The Cake build tool

Cake is a build tool that allows you to write C# build files ( .cake files). It is a flexible scripting system that has first class support for building .NET projects. Cake makes uses of the Roslyn compiler to compiler your Cake files into runnable code - for building, testing and packaging code.

Our use of Cake is exactly this, but what I want to do is introduce a build script that builds by convention. To get started, let's install the Cake CLI as a global tool

dotnet tool install --global Cake.Tool

At the time of writing, the current version of 0.35.0 ( NuGet ).

Next, we can create our first Cake script. So create a file at build\build.cake:

/** ARGUMENTS **/

var configuration = Argument("Configuration", "Release");
var target = Argument("Target", "Default");

/** TASKS **/

Task("Build")
.Does(() =>
{

});

Task("Default")
.IsDependentOn("Build");

/** EXECUTION **/

RunTarget(target);
build\build.cake

So there are a few things going on here

  • We define a set of arguments, in this case configuration which defaults to Release, and target which defaults to Default.
  • We define a set of tasks, one named Build and another named Default. The latter will execute Build.
  • We run task represented by the target argument.

Let's try executing this, using the dotnet-cake command we installed previously (from the Cake.Tool package).

cd build
dotnet-cake

If all goes well, it will execute the tasks, which results in the following output:


========================================
Build
========================================

========================================
Default
========================================

Task                          Duration
--------------------------------------------------
Build                         00:00:00.0093558
--------------------------------------------------
Total:                        00:00:00.0108493

Although quite uninspiring at this stage, these are building blocks to building something bigger.

You can execute a specific target, or use a specific configuration:

dotnet-cake -Target=Build -Configuration=Debug

Convention-based builds

By having our build script centred within our build submodule, this means we can approach designing our script by convention. We want our build submodule to apply the same conventions to any project that we need.

For this to work, it requires those consumer projects to utilise the same layout. So the conventions we will be using are:

  • Libraries (those destined for NuGet package feeds) are located under src\
  • Apps (those destined for deployment somewhere) are located under apps\
  • Tests are located under tests\
  • Build outputs (artefacts) will be saved to artefacts\

We can embody this as an anonymous object:

/** VARIABLES **/

var root = MakeAbsolute(new DirectoryPath("../"));

var folders = new 
{
    root = root,
    artefacts = root + "/artifacts",
    src = root + "/src",
    apps = root + "/apps",
    tests = root + "/tests"
};
build\build.cake

Now, we need to determine how we are going to build our solution. But to answer that question for a convention-based build system, we need some logic to determine what the correct solution will be.

Initially this could be an input argument:

var solution = Argument<string>("Solution", null);
build\build.cake

If the user provides this argument, we can use this directly, e.g.:

dotnet-cake -Solution="Example.sln"

Otherwise, we need to figure out the solution. The rules are fairly simple, if there is exactly one solution, we use that, otherwise we throw an error, as we do not know which solution we should build. Let's define a function to handle this:

/** FUNCTIONS **/

FilePath GetSolutionFile(DirectoryPath root, string solution)
{
    if (solution is object)
    {
        var solutionFile = root.CombineWithFilePath(solution);
        if (FileExists(solutionFile))
        {
            Information("Using solution file: " + solutionFile.FullPath);
            return solutionFile;
        }
        else
        {
            Error("Unable to resolve solution file: " + solutionFile.FullPath);
        }
    }
    else
    {
        var solutionFiles = GetFiles(root + "/*.sln");
        if (solutionFiles.Count == 1)
        {
            var solutionFile = solutionFiles.Single();
            Information("Using solution file: " + solutionFile.FullPath);
            return solutionFile;
        }
        else if (solutionFiles.Count > 1)
        {
            Error("Unable to resolve solution file, there is more than 1 solution file available at: " + root.FullPath);
        }
        else
        {
            Error("Unable to resolve solution file");
        }
    }

    return null;
}
build\build.cake

We're making use of some of Cake's primitives - DirectoryPath and FilePath. These types normalise a lot of the difference between the path systems of different OSes. Now we can resolve our solution file, we can update our Build task:

Task("Build")
.Does(() =>
{
    var solutionFile = GetSolutionFile(root, solution);
    
    if (solutionFile is object)
    {
        Information($"Building solution: {solutionFile.FullPath}");
        
        DotNetCoreBuild(solutionFile.FullPath, new DotNetCoreBuildSettings
        {
            Configuration = configuration
        });
    }
});
build\build.cake

Let's try executing our solution now:


========================================
Build
========================================
Using solution file: C:/blog/example/Example.sln
Microsoft (R) Build Engine version 16.4.0+e901037fe for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 172.66 ms for C:\blog\example\src\ExampleLib\ExampleLib.csproj.
  ExampleLib -> C:\blog\example\src\ExampleLib\bin\Release\netstandard2.0\ExampleLib.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:01.93

========================================
Default
========================================

Task                          Duration
--------------------------------------------------
Build                         00:00:02.3856024
--------------------------------------------------
Total                         00:00:02.3856024

Success! An initial solution build!

Cleaning stale artefacts

When we repeat builds, ideally we would like the result to be idempotent when nothing has changed. To support this, it is important for us to sweep away any previously built artefacts, we can do this by defining a new task:

Task("Clean")
.Does(() =>
{
    CleanDirectories(new DirectoryPath[]
    {
        folders.artefacts
    });

    CleanDirectories(folders.src + "/**/bin/" + configuration);
    CleanDirectories(folders.apps + "/**/bin/" + configuration);
    CleanDirectories(folders.tests + "/**/bin/" + configuration);
});
build\build.cake

This task does two things:

  1. Cleans away stale artefacts under artefacts\
  2. Removes any build outputs from our configuration-specific builds at the standard output locations. So for our example project running in Release configuration, this would mean deleting everything under src\ExampleLib\bin\Release

We now need to tell Cake that this task is a dependency for the Build task, so let's update the definition:

Task("Build")
.IsDependentOn("Clean")
.Does(() => // Rest of task
build\build.cake

By adding this as a per-task dependency, it allows you to execute individual tasks and ensuring the correct prerequisite tasks are run in order. This means if I decided to run a specific target:

dotnet-cake -Target=Build

I can be assured that my Clean tasks is executed before build.

Running unit tests

We do not currently have a unit test project defined, so let's go ahead and do that now - from the root of your repo, run the following:

mkdir tests
cd tests
dotnet new xunit --name ExampleLib.Tests

cd ..
dotnet sln add ./tests/ExampleLib.Tests/ExampleLib.Tests.csproj

We're not too concerned with having actual tests at this stage, we testing a build system, not actual code. So we can create an empty Xunit project (or NUnit/MSTest project if you are inclined) within our tests\ directory. We need to add this to our solution so when we perform a build, the unit test project is built.

As a bonus you can add a reference to our ExampleLib class library, but its not critical:

cd tests\ExampleLib.Tests
dotnet add reference ../../src/ExampleLib/ExampleLib.csproj

Now, we can define out Test task:

Task("Test")
.IsDependentOn("Build")
.Does(() =>
{
    var projects = GetFiles(folders.tests + "/**/*.csproj");

    foreach (var project in projects)
    {
        Information($"Running unit test project: {project.FullPath}");

        string projectName = System.IO.Path.GetFileNameWithoutExtension(project.FullPath);
        string resultsFile = $"{projectName}.xml";

        DotNetCoreTest(project.FullPath, new DotNetCoreTestSettings
        {
            Configuration = configuration,
            Logger = $"trx;LogFilename={resultsFile}",
            NoBuild = true,
            ResultsDirectory = folders.artefacts + "/test-results"
        });
    }
});
build\build.cake

A couple things going on here, let's break it down:

  • We'll enumerate each test project under tests\ and execute dotnet test using Cake's built in aliases.
  • We don't bother re-building the libraries as they have been built previously in the Build task
  • We output the test results in VS Test Results File, and move this to artefacts\test-results. This will allow a CI/CD tool to pull the test results to be reported.

Creating NuGet packages

As part of our convention based build, we need to package up any libraries under src as NuGet packages. We can make use of Cake's native support for dotnet pack, so let's add a new task

Task("Pack-Libraries")
.IsDependentOn("Test")
.Does(() =>
{
    var projects = GetFiles(folders.src + "/**/*.csproj");
    foreach (var project in projects)
    {
        DotNetCorePack(project.FullPath, new DotNetCorePackSettings
        {
            Configuration = configuration,
            NoBuild = true,
            OutputDirectory = folders.artefacts + "/packages"
        });
    }
});
build\build.cake

The pack command creates a NuGet package from any project under src\. Again, much like the Test task, we do not need to build the project again, as this has already completed as part of the Build task.

Before we can run this build, we need to update our Default task definition:

Task("Default")
.IsDependentOn("Pack-Libraries");

It's import to understand the dependency chain we've constructed:

Default -> Pack-Libraries -> Test -> Build -> Clean

Executing any one of these targets, will execute the dependency chain from that task onwards. If we run our build again, we should also now see our output NuGet packages:

Takeaways and next steps

Another jam packed issue, however our Cake build is not complete. We still need to build and package up our Apps. Because of the length of this issue, I've split this in two, and will follow up the build steps for the Apps in the next issue.

Let's recap what we've done in this issue:

  • We installed the Cake global tool for executing *.cake files using dotnet tool install --global Cake.Tool
  • We have created our build script within our build submodule - we've called this build.cake which is the default filename supported by the Cake build system
  • We've defined a set of conventions for our project layout, embodied as an anonymous object of directory paths
  • We've defined a number of tasks to build, test and pack our .NET projects under the src\ folder

Next up in the series, we'll continue evolving our build script to build and pack Apps, with support for custom per-App build scripts.