Scripting your build tasks in C# using cake

Cake is the C# counterpart of gulp.js – they're both task based. Except, we don't generally pipe things in cake.

From their website:

Cake (C# Make) is a cross-platform build automation system with a C# DSL for tasks such as compiling code, copying files and folders, running unit tests, compressing files and building NuGet packages.

In this blogpost, I'm going to demonstrate some basic usages for those who haven't used it before.

Tooling

The only tool you'll need to write and work with cake files is the Cake plugin for Visual Studio Code.

You'll also need .NET installed, because the cake tool needs .NET to run. Supported versions here. If you're using the latest .NET full framework / .NET Core / Mono, you're good to go.

A simple cake file - hello world

Cake build scripts are written in C# in a .cake file. Usually it's build.cake. Here's an example of a basic build.cake file:

// Create a task called hello
Task("hello")
  .Does(() =>
{
  // log a string using Information()
  // could also use Verbose(), Debug(), Warning(), Error()
  Information("Hello World!");
});

// And run that task
RunTarget("hello");

If you've used gulp.js before, this should feel quite familiar since both are task based.

Installing cake

You can have cake installed:

  1. locally to your project, (or)
  2. globally on the machine.

1A. Install cake locally as a local tool (.NET Core 3 onwards)

This is my preferred way of doing installing cake. Every project gets access to the version of cake it needs.

# Add a tool manifest for local tools
# if you don't already have one
dotnet new tool-manifest

# Install cake locally to the project
dotnet tool install cake.tool

You can now run build.cake as:

dotnet cake build.cake

For the rest of this post, I'll assume you've installed cake using this method because it's the most flexible way to add a given version of cake to your project.

1B. Install cake locally using bootstrapper scripts

This is the recommended way, particularly the pre-.NET Core 3 ones. You add two files to bootstrap cake: a build.sh file and a build.ps1 file to the project root. These files will download cake locally to a tools/ folder and use it on consecutive calls to it.

To add the bootstrapper files on VS Code using the Cake extension, press Cmd+Shift+P to open the command palette and select Cake: Install a bootstrapper. You'll need to do this twice—once for the .sh file and once for the .ps1 file—if you want others to use cake irrespective of their platform.

(You can also download the files manually from their GitHub resources repo.)

You can now run the build.cake file using the bootstrapper files:

# Shell
./build.sh

# Powershell
.\build.ps1

2A. Install cake globally as a global tool (.NET Core 2.1 onwards)

dotnet tool install --global Cake.Tool

You can now run  build.cake as:

dotnet cake build.cake

2B. Install cake globally using a package manager (or manually)

# Using brew on Mac
brew install cake

# Using scoop on Windows
scoop install cake

# Using chocolatey on Windows
choco install cake

You can now run  build.cake as:

cake build.cake

Tasks and arguments

In the hello-world example, we created a "hello" task using Task(), and then ran it using RunTarget(). Let's create another task and chain it to "hello" using IsDependentOn().

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

Task("world")
  .IsDependentOn("hello") // hello should be run before world can be run
  .Does(() =>
{
  Information("...world!");
});

// We run the world task
// Since world IsDependentOn on "hello", cake will run "hello" first.
RunTarget("world");

The only problem with this script is that there's no way to only run "hello". We can fix that by allowing the cake file to accept an argument.

// We define an argument called "target"
// with default value "world".
var target = Argument("target", "world");

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

Task("world")
  .IsDependentOn("hello") // implies that hello should be run first
  .Does(() =>
{
  Information("...world!");
});


// We run the target
RunTarget(target);

We can now run the "hello" task by passing a value to the "target" argument:

dotnet cake build.cake --target=hello

Arguments are super handy in CI environments–they let you pass arguments, and let you control which tasks to run.

Real world cake script example

I've taken the following example from my side-project called evlog. Original file here:

#tool "xunit.runner.console&version=2.2.0"
#addin "Cake.Docker&version=0.10.0"

The #tool directive loads a CLI tool; #addin loads a library. Both of them packaged as nuget packages and can be found at nuget.org. You can load most nuget packages here. So if you wanted to run a git operation, you could use a Cake.Git package, and so on.

//////////////////////////////////////////////////////////////////////
// ARGUMENTS
//////////////////////////////////////////////////////////////////////

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

//////////////////////////////////////////////////////////////////////
// GLOBALS
//////////////////////////////////////////////////////////////////////

const string sln = "./evlog.sln";
readonly string testDbContainerName = $"evlogtesdb-{Guid.NewGuid()}";

//////////////////////////////////////////////////////////////////////
// TASKS
//////////////////////////////////////////////////////////////////////

Task("build")
    .Does(() =>
{
    DotNetCoreBuild(sln,
        new DotNetCoreBuildSettings
        {
            Configuration = configuration
        }
    );
});

Task("startdb")
    .Does(() =>
{
    DockerRun(settings: new DockerContainerRunSettings {
            Name = testDbContainerName,
            Env = new[] { "MYSQL_ROOT_PASSWORD=Pa5sw0rd" },
            Publish = new[] { "3307:3306" },
            Detach = true,
            Rm = true
        },
        image: "mysql:8.0.16",
        command: null, args: null);
});

Task("stopdb")
    .Does(() =>
{
    DockerStop(testDbContainerName);
});

Task("xunit")
    .IsDependentOn("startdb")
    .IsDependentOn("build")
    .Does(() =>
{
    var projects = GetFiles("./tests/**/*.csproj");
    foreach(var project in projects)
    {
        DotNetCoreTest(
            project.FullPath,
            new DotNetCoreTestSettings()
            {
                Configuration = configuration,
                NoRestore = true,
                NoBuild = true,
                ResultsDirectory = project.GetDirectory(),
                ArgumentCustomization = args => args.Append("--logger:trx;LogFileName=test_result.xml")
            }
        );
    }
    RunTarget("stopdb");
});

Task("docker-build")
    .Does(() =>
{
    DockerBuild(new DockerImageBuildSettings{
        Tag = new string[] {
            "gldraphael/evlog"
        },
        File = "src/Evlog.Web/Dockerfile"
    }, ".");
    DockerBuild(new DockerImageBuildSettings{
        Tag = new string[] {
            "gldraphael/evlog-self-contained"
        },
        Target = "self-contained",
        File = "src/Evlog.Web/Dockerfile"
    }, ".");
});

//////////////////////////////////////////////////////////////////////
// TASK TARGETS
//////////////////////////////////////////////////////////////////////

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

Task("azure-pipelines")
    .IsDependentOn("xunit");

Task("travis")
    .IsDependentOn("docker-build");

//////////////////////////////////////////////////////////////////////
// EXECUTION
//////////////////////////////////////////////////////////////////////

RunTarget(target);

Using cake in a build environment

Cake is cross platform. But there are a few things you need to know, that might not be quite obvious at first.

  • Cake needs .NET to run.
  • Using the bootstrapper file would require the full .NET Framework on Windows (Mono on Linux).
  • If you use dotnet tools (be it global or local), you won't need the Full framework or Mono.
  • You can use Full Framework cake tool for a .NET core project and vice-versa.

Closing tips

  • Let your CI call individual task targets from your cake file to see each step in your CI pipeline (or a single task target that calls the others if you need it).
  • Getting used to their API reference page does take time, but once it clicks you'll be fine.

I've been using cake for about 3 years now, and love it. You will too :)