This blog shows how to create a private build process using Cake and assumes you’ll be using some type of continuous integration (CI) build such as Azure DevOps pipelines. 

Cake is an Open Source project built on top of the Roslyn compiler that uses a build script written in C#. It is structured around tasks that are chained together through dependency relationships.

 If you’re coming from an existing tool like psake or FAKE, I think you’ll find a lot of compelling reasons to switch to Cake, like great Intellisense support, built-in snippets, and the awesome debugging capability.

Mike Sigsworth

The script will usually have multiple, high-level tasks that allow the developer to run quick builds, clean builds for before committing, and ones for the CI system.

The basic steps for creating a Cake build

The following list are the basic steps to get building with Cake.  Each one is discussed below.

  1. Ensure the Solution builds in Visual Studio
  2. Install Cake
  3. Create build.cake file at the solution file level with the following tasks:
    a.Init task for cleanup of artifacts
    b.Compile task to build the code
    c.Test task to run the unit tests
    d.Database task deploy the database if applicable

Visual Studio Code has a great extension that simplifies installing, editing, and even debugging Cake scripts. Visual Studio also has an extension, but it is not as full featured as the VS Code one.

Ensure it Builds

Obviously you must be able to cleanly build your code.  The important word here is cleanly since the build pipeline will be building in a clean environment.  Forgetting to add a file to VCS is a common mistake that will let you successfully build locally, but will lose a cycle on the build.

Install Cake

Install Cake simply by downloading a PowerShell script into the solution folder.  Detailed directions are here but the basic command is as follows.  build.ps1 is the Cake script and will install all the necessary dependencies the first time it runs.

Invoke-WebRequest https://cakebuild.net/download/bootstrapper/windows -OutFile build.ps1

Note that to run unsigned PowerShell scripts locally (like build.ps1), you should issue the following command from an Administrative PowerShell prompt.

Set-ExecutionPolicy RemoteSigned

Create the Build.cake script

Usually the Cake script is in the same folder as the solution to make finding and running it easy.  This file should be committed to VCS so it can be run by anyone, including Azure DevOps.  

build.cakeis a C# source file that is the input for Cake’s build.ps1.  It is comprised of multiple Task function calls that define dependencies, and Does calls that are passed lambdas to execute for each step of the build process.

Prolog

At the top of the file, you can load Cake tools that you want to use.  Cake has many builtin and addin tools that are listed here.  Some tools must be explicitly added before using them.  For example.

#tool nuget:?package=GitVersion.CommandLine&version=4.0.1-beta1-50
#tool nuget:?package=OctopusTools

Variables and Arguments

This section defines variables that you can reference in any lambda in the script, like a global.  The Argument function gets arguments passed in on the command line.

var target = Argument("target", "Default");
var projectConfig = Argument("configuration", "Release");
var version = Argument("version", "1.0");
var runOctoPack = Argument("runOctoPack", "false");

Helper Functions

Next you can write any helper functions you may want to include.  It’s just a C# file, so have at it.

void copyAndFlatten(string source,string filter,string dest)
{
  var list = GetFiles( $"{source}/**/{filter}");

  var files = list.Where( o => !o.FullPath.Contains(@"\packages\"));
  Verbose( $"Copying {files.Count()} files from {source} to {dest}");
  CopyFiles(files, dest );
}

Setup and Teardown

There are separate methods you can call in Cake for setup and teardown.  Setup can allow be achieved with dependencies.

Tasks

Each call to Task takes the name of the task, then you use fluent syntax to chain additional methods,  such as IsDependentOn and Does. Ones with only IsDependentOn are high-level tasks, which are usually in the Epilog (see below)

The Init task does cleanup and setup needed to run the build

Task("Init").Does( () => {
  Information("Starting: Build task 'Init'");

  if ( FileExists(packageFile) )
    DeleteFile(packageFile);
  var dirs = new string[] {buildDir, testresultsDir, testDir, buildDir};
  CleanDirectories(dirs);

  Information( projectConfig );
  Information( version );
  Information( runOctoPack );

  Information( databaseServer );
  Information( databaseName );
  Information("Finishing: Build task 'Init'");

});

Other tasks do the compile, setup test database, run tests etc.  Below are a couple more examples.  Since any task can be executed when invoking Cake, make sure that each one has appropriate dependencies.  For example Test depends on Compile that in turn depends on Init.

Task("Compile")
  .IsDependentOn("Init")
  .Does(() =>
{
  Information("Starting: Build task 'Compile'");

  var settings = new MSBuildSettings {
    MaxCpuCount = 1,
    Configuration = projectConfig,
    Verbosity = Verbosity.Minimal
  };
  settings.WithProperty("RunCodeAnalysis", "true" );
  settings.WithProperty("ActiveRulesets", "MinimumRecommendedRules.ruleset");
  settings.WithProperty("OctoPackPackageVersion", version );
  settings.WithProperty("RunOctoPack", runOctoPack );
  settings.WithProperty("OctoPackEnforceAddingFiles", "true" );
  settings.Targets.Add("Clean");
  settings.Targets.Add("Rebuild");

  MSBuild( $@"{sourceDir}\{projectName}.sln", settings);

  copyAndFlatten( sourceDir, "*.nupkg", buildDir );
  Information("Finishing: Build task 'Compile'");
});

Task("Test")
  .IsDependentOn("Compile")
  .Does(() =>
{
    Information($"Starting: Build task 'Test' in {testDir}");
    copyAllAssembliesForTest(sourceDir,testDir);
    Verbose($"NUnitPath is {nunitPath}");

Epilog

At the end of the file, you can specify your high-level targets, and finally the actual command to run the target for this run.

// High-level targets
Task("Default").IsDependentOn("UnitTests");
Task("CI").IsDependentOn("Pack");

// Run it
RunTarget(target);

Running the Build

At this point every developer can run a build and local tests to make sure that when they commit, it will compile and run in Azure DevOps.   build.ps1 is the command that actually does the work. If no arguments are specified for it, Cake will build one called Default since the target variable in the Prolog above defaults to that name. Passing in a different target name will run that one.  In our example above, at the high level we have Default and CI, each of which has different dependent tasks. 

You will usually have a high-level task for devs to run for a quick build, and one for a build with tests that they should run before committing to VCS.  Then you may have others that are specific to an environment, with the idea of having as much common code run in the private build as runs in the build pipeline.

Debugging

If you use Visual Studio Code, the extension will allow you to debug a Cake file.  Otherwise you can use a debugger to attach to or run cake.exe as shown here.

Summary

It is virtually impossible to have the build environment in Azure DevOps match that of the developer, but using a shared build script can help avoid red builds and a developer scrambling to find that missing file or fix a bug they introduced to the unit test suite.  Cake is one way to solve this problem.  In another blog post we looked at psake, which is the PowerShell cousin of Cake. 

References