Back for seconds: Practical Cake build automation tasks

Cake (C# Make) provides a wonderfully straightforward and extensible means of automating your software builds.

In my previous article, I provided some background on what Cake is and how to get started with it. I walked through how to set up Cake on your system and how to create and run your first build script. If you haven’t gotten Cake set up yet I recommend that you check out my previous article first.

In this article, I am continuing my coverage of Cake with practical examples of several useful tasks you can get Cake to perform for you as part of an automated build process.

Aliases

To effectively use Cake, it’s important to know what ‘aliases’ are and be familiar with the most common aliases.

In simple terms, aliases are convenience methods that are built into Cake and help to make build-related tasks easier to accomplish with a simplified API surface.

I have listed a few of the most frequently used aliases below for reference.

It should be possible to guess from the names of the above aliases what they can be used for. Aliases form part of the Cake DSL (Domain-Specific Language), helping to make build scripts simpler and more readable.

Having said that, it’s important to bear in mind that aliases are C# methods at the end of the day and there is no magic involved.

Cleaning up files

Below is a quick example of a ‘Clean-Files’ task that uses some Cake aliases to delete a file and empty a directory.

Note that I will cover tasks in more detail shortly.

Task("Clean-Files")
    .Does(() =>
{
    var lockFile = "./lockfile.txt";
 
    if (FileExists(lockFile))
    {
	DeleteFile(lockFile);
    }
 
    CleanDirectory("./temp");
});

In the above example, if the file named ‘lockfile.txt’ exists in the same directory as the build script, the file will be deleted. The CleanDirectory alias will then remove all files and subdirectories from the directory named ‘temp’. If this temp directory does not already exist, it will be created automatically.

Zipping directories

Cake provides a very convenient means of zipping up directories using the Zip alias.

Task("Zip-Directory")
    .Does(() =>
{
    Zip("./tools", "./tools.zip");
});

The simplest Zip alias method overload specifies two parameters; the path to the directory to zip and the path to the resulting zip file. In the above example, the contents of a ‘tools’ directory located at the same level as the build script are copied into a ‘tools.zip’ file.

To complement the Zip alias there is also the Unzip alias.

Now that we’ve seen some examples of Cake aliases in action, let’s move on to the implementation of some practical build tasks.

Tasks

Tasks in Cake are where the real build action happens.

The general idea when creating a build script is that you define several individual tasks and then chain these together to define the overall script logic. Loosely speaking, these tasks should be broken down into the individual steps that you would undertake if you had to carry out your build process manually.

Now might be a good time to think about what the manual steps would be for building and deploying your software and work out the order in which these steps would need to be performed. You can then start using some of the examples shown further below as inspiration and build on them further.

Note that this article assumes you are developing a build script for a .NET Core or .NET 5.0+ application. If you are building a .NET Framework application you may need to use alternative aliases such as NuGetRestore instead of DotNetRestore.

Build information

Let’s warm up with a basic task to kick things off.

It can be useful when your build script starts running to see some information that verifies that the correct Cake version is being used and that argument values have been passed into the script correctly.

The example script below contains the declaration and assignment of the arguments and environment variables that are used by the script to provide some context. You can assume that the remaining task examples will make use of these values too.

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

var
 target = Argument("target", "Default"); var configuration = Argument("configuration", "Release"); ///////////////////////////////////////////////////////////////////////////////
// ENVIRONMENT VARIABLES
///////////////////////////////////////////////////////////////////////////////
var solutionFileName = EnvironmentVariable("SolutionFileName") ?? "MyApp.sln";
///////////////////////////////////////////////////////////////////////////////
// TASKS
///////////////////////////////////////////////////////////////////////////////

Task("Build-Information") .Does(() => 
{ DisplayCakeVersion(); NewLine(); Information($"Target: {target}"); Information($"Configuration: {configuration}"); Information($"SolutionFileName: {solutionFileName}"); });

Task("Default")
.IsDependentOn("Build-Information")
.Does(() =>
{
});
RunTarget(target);

///////////////////////////////////////////////////////////////////////////////
// METHODS
///////////////////////////////////////////////////////////////////////////////

void DisplayCakeVersion() { string cakeVersion = typeof(ICakeContext).Assembly.GetName().Version.ToString(); Information($"Building using version {cakeVersion} of Cake"); } void NewLine() { Information(""); }

In the above example, I’d like you to focus on the ‘Build-Information’ task.

The first thing the task does is display information about the current Cake version using a method called DisplayCakeVersion. This method has been defined at the bottom of the script and acts as a convenience method. I’ll show you a way to separate your own convenience methods into a separate file later in the article.

You should be able to tell from the DisplayCakeVersion method implementation that any valid C# code will work in a Cake script. It doesn’t pose a problem at all if you need to reach into the .NET BCL to perform more advanced logic. In this case, the methods and properties of the .NET Assembly class are used to extract the version number of the main Cake assembly. The Information alias is then used to log this information to the console.

NewLine is another convenience method that simply calls the Information alias to write a new blank line to the console to help separate the output for us.

The rest of the ‘Build-Information’ task uses the Information alias to output the values of the two arguments and the single environment variable that the script uses. Logging the values of script inputs like this can be extremely valuable, as it lets you verify that the values are what you were expecting them to be.

Below is an example of how to call a Cake build script from a command (.cmd) file, setting an environment variable value and passing along an argument value to the script.

SET SolutionFileName=MyOtherApp.sln
 
powershell -File "./build.ps1" -Arguments "--target Build-Information"
pause

Alternatively, you could bypass the Cake bootstrapper PowerShell file and call the .NET Cake Tool directly, as follows.

$env:SolutionFileName = 'MyOtherApp.sln'

dotnet cake --target Build-Information

Along with other information, your terminal should display the output from the ‘Build-Information’ task, which should be similar to the following.

========================================
Build-Information
========================================
Building using version 2.2.0.0 of Cake

Target: Default
Configuration: Release
SolutionFileName: MyOtherApp.sln

Now that you know the basics, you can start adding to the ‘Build-Information’ task and include the information that you feel is relevant to your situation.

Let’s move on to our next task.

Clean

It’s common practice to clear out the application bin directory before proceeding to the main build tasks. This provides a clean baseline to start from before building the solution. This is a similar concept to the ‘Rebuild Solution’ feature within Visual Studio, whereby the bin directory is emptied before the solution is built.

The below example demonstrates how to clean the project bin directory.

Task("Clean")
    .IsDependentOn("Build-Information")
    .WithCriteria(c => HasArgument("rebuild"))
    .Does(() => 
{
    CleanDirectory($"./MyApp/bin/{configuration}");
    Information("Clean completed");
});

In the above code, the ‘Clean’ task has been chained to the ‘Build-Information’ task using the IsDependentOn method. This means that when we run the ‘Clean’ task the ‘Build-Information’ task will be called first. You’ll see why this is useful when we start chaining other more important tasks together.

The WithCriteria method allows us to specify criteria that must be met in order for the task to be executed, otherwise, the task will be skipped. In this case, the ‘Clean’ task will only be executed if a ‘rebuild’ argument has been specified when running the build script (e.g. dotnet cake --target Clean --rebuild).

The implementation of the ‘Clean’ task is very basic. The CleanDirectory alias is used to empty the bin directory. The Information alias is then used to output that the cleaning operation has been completed successfully.

Note that in the example, part of the directory path is hardcoded. I strongly recommend creating a separate ‘paths.cake’ file containing your directory and file paths. You can then reference these paths from your main build script (more to follow on this).

Restore

Although the .NET Core build process will automatically restore NuGet packages if required, you may still prefer to create a separate build task for restoring packages. The example below demonstrates how you can do this.

Task("Restore")
    .IsDependentOn("Clean")
    .Does(() =>
{
    DotNetRestore(solutionFileName);
});

The ‘Restore’ task is dependent on the ‘Clean’ task. However, note that if the ‘Clean’ task is skipped because the criteria to run it wasn’t met, the ‘Restore’ task will still execute.

The DotNetRestore alias takes care of abstracting away the necessary command-line calls to restore NuGet packages for us. If required, you can pass in an instance of the DotNetRestoreSettings class if you need more control over the restore process.

Build

Now for the most important task, below is a simple example of how to build our application.

Task("Build")
    .IsDependentOn("Restore")
    .Does(() =>
{
    DotNetBuild(solutionFileName, new DotNetBuildSettings
    {
        Configuration = configuration,
	NoRestore = true
    });
});

The DotNetBuild alias is used to build our solution. An instance of the DotNetBuildSettings class is used to customise how the build is carried out. The Configuration property is set according to the configuration specified by the relevant build script argument i.e. ‘Debug’ or ‘Release’. The NoRestore property is set to true since we’ve already restored NuGet packages in the ‘Restore’ task.

There are lots of other settings you can configure, including MSBuildSettings. These give you a lot of flexibility for cases where you need to ensure that specific platform and target settings are used, for example.

Note that you can pass a settings object to most Cake aliases and the settings class typically derives ultimately from a base class called ToolSettings which exposes an ArgumentCustomization property. This is a Func property that allows you to customise the arguments that are passed to the underlying tooling.

Test

After building our application we can proceed to run unit tests. Below is an example of how to do this.

Task("Test")
    .IsDependentOn("Build")
    .Does(() =>
{
    DotNetTest(solutionFileName, new DotNetTestSettings
    {
        Configuration = configuration,
        NoBuild = true
    });
});

The DotNetTest alias provides a convenient means of running tests. In the above example, the Configuration property is set according to the configuration specified by the relevant build script argument. The NoBuild property is set to true since we have just built the application in the previous task.

Deploy

Usually, the last part of the build process is the deployment of the compiled application.

Deployment for your particular application could mean creating a setup file or zip file and transferring it somewhere, or uploading your build files to a web server and restarting the website process.

Cake provides several aliases that can help you with deployment. However, because deployment processes vary so greatly, most deployment methods are provided via Cake ‘addins’. An example of such an addin is the Cake WebDeploy addin which can be used to publish to IIS or Azure App Services etc.

Aside from this, there are built-in aliases and community addins available specifically for OctopusDeploy, Azure DevOps and many other platforms.

Considerations

Before wrapping up this article, I want to mention a couple of things to bear in mind as you write your build scripts.

Separate files

It’s a good idea to move parts of your build script into separate files. This helps to keep your script files smaller and easier to comprehend.

For example, you could create a file called ‘extension-methods.cake’ and move the convenience methods we covered earlier into this as follows.

/////////////////////////////////////////////////////////////////////////////// 
// METHODS
///////////////////////////////////////////////////////////////////////////////


public static 
void DisplayCakeVersion(this ICakeContext context) { string cakeVersion = typeof(ICakeContext).Assembly.GetName().Version.ToString(); context.Information($"Building using version {cakeVersion} of Cake"); } public static void NewLine(this ICakeContext context) { context.Information(""); }

Notice that the methods are now marked as public static and are defined as extension methods on the ICakeContext interface. When we want to use a Cake alias method we must invoke it on the context object.

To use these convenience methods from our main Cake build script, we must make a few changes there too.

/////////////////////////////////////////////////////////////////////////////// 
// LOAD
///////////////////////////////////////////////////////////////////////////////


#load
 extension-methods.cake

// Other code left out for brevity...

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


Task("Build-Information")
.Does(() => 
{
Context.DisplayCakeVersion();
Context.NewLine();
Information($"Target: {target}");
Information($"Configuration: {configuration}");
Information($"SolutionFileName: {solutionFileName}");
});

Notice in the above build script how we use the #load directive to load the file containing the convenience methods.

Note that if the file were located in a different directory we would need to specify the relative or full path instead of just the filename.

Also notice that instead of just writing DisplayCakeVersion(); as before, we now need to write Context.DisplayCakeVersion(); so that we can invoke the alias methods correctly.

Paths

Further to my advice regarding separate files, it’s a good idea to have a separate file containing any paths that are specific to your application. Below is a very brief example of the contents of a file called ‘paths.cake’.

public static class Paths
{
    public static string SolutionFileName { get; set; }
    public static string VisualStudioFilePath => @"C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.com\";
    // Other paths and filenames...
}

The property values can be set directly if they don’t need to change on a case-by-case basis, as per the  VisualStudioFilePath property. Alternatively, you can set the property values in your main build script, for example, in the Setup hook based on argument or environment variable values. You can then add properties that combine the values of other properties to build different directory paths or file paths.

Note that the Cake for Visual Studio extension doesn’t always get the syntax highlighting right, so some of the colours in the example code used in this article may look a little off. I have kept this as is so that you know what to expect.

Addins

Depending on your specific scenario, you’ll probably not be able to achieve everything you need to do using only the officially supported Cake aliases. At some point, you’ll need to turn to community-developed addins.

Aside from the Cake WebDeploy addin that was mentioned previously, a great example of this is the Cake Git addin.

Below is an example of how to reference and use the Cake Git addin.

/////////////////////////////////////////////////////////////////////////////// 
// ADDINS
///////////////////////////////////////////////////////////////////////////////


#addin
 nuget:?package=Cake.Git&version=2.0.0
///////////////////////////////////////////////////////////////////////////////
// TASKS
///////////////////////////////////////////////////////////////////////////////

Task("Check-Git-Status") .Does(() =>  { bool uncommittedChanges = GitHasUncommitedChanges("."); if (uncommittedChanges) Warning("There are uncommitted changes"); });

The #addin directive is used to reference the addin and a particular version of the addin is specified. This helps to improve the reliability of the script. If we don’t specify a version Cake will pull down the latest addin version automatically which could introduce breaking changes.

In the above example, the GitHasUncommitedChanges method that is defined as part of the addin is used to check if there are any uncommitted changes in the local Git repository. If so, a warning is logged using the Warning alias. If you wanted the script to fail in this scenario you could simply throw an Exception in the same way you normally would in a C# program.

The Cake Git addin provides lots of other useful methods such as GitPull which could be useful for continuous integration to make sure that the latest code has been merged before building the software.

I encourage you to check out the great Cake Reference page to see the full list of Cake aliases and addins that are available. Whatever problem you’re trying to solve likely has a solution already available as part of Cake.

Summary

In this article, I have continued the Cake journey by covering practical examples of Cake tasks that you can use and extend as part of your own automated build process.

I started by looking at some examples of built-in Cake aliases that act as convenience methods to help make our scripts easier to build and comprehend.

I then proceeded to document several different examples of Cake tasks that you can include in your own build scripts and adapt according to your needs.

Lastly, I cover some considerations you should make when creating Cake builds scripts in relation to separate files, directory paths and file paths, and Cake addins for more custom scenarios.


I hope you enjoyed this post! Comments are always welcome and I respond to all questions.

If you like my content and it helped you out, please check out the button below 🙂

Comments