Contents
This is the second issue in a series around designing a build system for .NET projects
- The build submodule & Versioning dependencies
- Build, test and pack .NET projects by convention using Cake
- Build and pack .NET applications ready for deployment
- Introducing build configuration options and smart defaults
- Versioning strategy using GitVersion and auto-tagging from your CI build
- 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:
build
- https://github.com/Antaris/build- 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);
So there are a few things going on here
- We define a set of arguments, in this case
configuration
which defaults toRelease
, andtarget
which defaults toDefault
. - We define a set of tasks, one named
Build
and another namedDefault
. The latter will executeBuild
. - 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"
};
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);
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;
}
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
});
}
});
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);
});
This task does two things:
- Cleans away stale artefacts under
artefacts\
- 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 undersrc\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
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"
});
}
});
A couple things going on here, let's break it down:
- We'll enumerate each test project under
tests\
and executedotnet 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"
});
}
});
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 usingdotnet tool install --global Cake.Tool
- We have created our build script within our
build
submodule - we've called thisbuild.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.