Build, test and deploy a dotnet core website using Cake, TeamCity and Octopus Deploy

I have just completed setting up a simple deploy pipeline for a dotnet core project where I use TeamCity to build the project and run xUnit tests and then Octopus Deploy to deploy. One of the things I wanted to try out for this project, was to use Cake Build to make a build script. By using Cake, I can set up my entire build process in code, instead of setting up multiple build steps in TeamCity. The advantage is that I can now commit my entire build process to git, and can easily reuse it for my next project.

The downside is that Cake is far from plug-and-play, and setting up the build process was a lot more complicated than a normal setup in TeamCity. That is why I will use this blog post to hopefully help others avoid a few of the headaches I got along the way.

The complete project is available on GitHub, have a look at the repository if the code snippets below don’t make sense in isolation.

What’s in the project?

There are a lot of moving parts here, but in short, this is the setup in Visual Studio:

The project I want to build is a super simple web api made with dotnet core 2.1. If you clone my repository and run it from Visual Studio, you will get a web site running at https://localhost:44360/api/values. I started out with the dotnet core web api project template in Visual Studio and removed everything that wasn’t necessary to run it.

A test project

The test project consists of a single xUnit test class, WebTests, with two tests; One that is set up to always succeed and one that always fails.

Solution items

In the root directory of the solution you will find the files needed for building. The most important file is build.cake, which defines the build process. To run the Cake script, I also need the Cake dll, so the build.ps1 script is for getting the Cake NuGet package and using it to run build.cake.

What is happening in the build process?

To create a build process with Cake, you set up distinct tasks, and set dependencies between them to tell Cake which order to run the tasks. These are the tasks I have defined in build.cake:

  • Clean
    • Empty bin and obj directories
  • Test
    • Run all xUnit tests in all test projects
  • Publish
    • Build dll files and move them and static files to a directory ready for deployment
  • Pack
    • Use OctoPack to put the published files into a zip file with correct version number
  • Push
    • Send the generated zip file to Octopus Deploy

Ok, lets get into the details of each task.

Setup

This is the beginning of build.cake:

using System.Diagnostics; 
#tool "nuget:?package=OctopusTools" 

var target
    = Argument("target", "Default");
var build
    = Argument("build", "0");
var revision
    = Argument("revision", string.Empty);
var octopusUrl
    = Argument("octopusUrl", string.Empty);
var octopusApiKey
    = Argument("octopusApiKey", string.Empty); 

var configuration = "Release";
var dotnetcoreVersion = "2.1";

All the variables in the beginning can be overridden when running the build script from the command line, for example you can change value of target to run only a single task. You will see later in this guide how they are used by TeamCity.

Task Clean

Task("Clean")
    .WithCriteria(!BuildSystem.IsRunningOnTeamCity)
    .Does(() =>
    {
        var dirsToClean = 
            GetDirectories("./**/bin");        
        dirsToClean.Add(GetDirectories("./**/obj"));
        
        foreach(var dir in dirsToClean) {
            Console.WriteLine(dir);
        }
        
        CleanDirectories(dirsToClean);
    });

Delete all bin and obj directories in the solution. I have set up TeamCity to always clean the build directories before building, so this step is skipped by TeamCity.

Task Test

Task("Test")
	.IsDependentOn("Clean")
    .Does(() =>
    {
    	GetFiles("./tests/**/*.csproj")
        .ToList()
        .ForEach(file => 
            DotNetCoreTest(file.FullPath));
    });

This task finds all projects in directory tests and runs the xUnit tests found there.

Task Publish

Task("Publish")
    .IsDependentOn("Test")
    .Does(() =>
    {        
        DotNetCorePublish(".", 
            new DotNetCorePublishSettings {
                Configuration = configuration,
                EnvironmentVariables = 
                    new Dictionary<string, string> {
                        { "build", build },
                        { "revision", revision }
            }        
        });    
    });

The task uses dotnet publish to build and collect all necessary files for running the website on a server. Note that I’m inserting environment variables here. To be able to use these variables, I also need to add the following code to Web.csproj:

<PropertyGroup>    
    <TargetFramework>
        netcoreapp2.1
    </TargetFramework>    
    <Version>
        1.2.3
    </Version>    
    <Version Condition=" '$(Build)' != '' ">
        $(Version).$(Build)
    </Version>    
    <InformationalVersion>
        $(Version)
    </InformationalVersion>    
    <InformationalVersion Condition=" '$(Revision)' != '' ">
        $(InformationalVersion)-g$(Revision)
    </InformationalVersion>
</PropertyGroup>

The version number for this project will be 1.2.3, which is hardcoded into Web.csproj, followed by a number inserted during the build process, for example 1.2.3.254. In my build process, I insert the TeamCity build number here.

Task Pack

Task("Pack")    
    .IsDependentOn("Publish")    
    .Does(() =>     
    {            
        var projectName = "Web";        
        
        var basePath = 
            $"./src/{projectName}/bin" 
            + $"/{configuration}" 
            + $"/netcoreapp{dotnetcoreVersion}/publish";
        
        var version = FileVersionInfo
            .GetVersionInfo($"{basePath}/{projectName}.dll")
            .FileVersion;   
        
        OctoPack($"CakeXunitDemo.{projectName}", 
            new OctopusPackSettings {
                BasePath = basePath,            
                Format = OctopusPackFormat.Zip,            
                Version = version,            
                OutFolder = new DirectoryPath(".")        
            }
        );
        
    });

Octopus Deploy accepts both zip and NuGet files for deployment, but it seemed easiest to go with zip here. The trickiest bit was to get the correct version number (generated in the previous publish step). I ended up getting it from the generated dll file.

Task OctoPush

Task("OctoPush")    
    .IsDependentOn("Pack")    
    .WithCriteria(
        BuildSystem.IsRunningOnTeamCity)    
    .WithCriteria(
        !string.IsNullOrEmpty(octopusUrl))    
    .WithCriteria(
        !string.IsNullOrEmpty(octopusApiKey))    
    .Does(() =>    
    {        
    
        var packagePathCollection = 
            new FilePathCollection(
                System.IO.Directory
                .GetFiles(".", "CakeXunitDemo.*.zip")
                .Select(filePath => 
                    new FilePath(filePath)
                ),
                new PathComparer(false)
            );
                    
        OctoPush(
            octopusUrl,
            octopusApiKey,
            packagePathCollection,
            new OctopusPushSettings { 
                ReplaceExisting = true 
            }
        );
        
    });

To make sure I don’t accidentally deploy the package, this step only runs on TeamCity, not if the script is run locally. Again, the trickiest bit is that I don’t really know the version number, so I’m just pushing any zip file in the project directory of the format ProjectName.VersionNumber.zip.

Task Default and method RunTarget()

Task("Default")  
    .IsDependentOn("OctoPush"); 
    
RunTarget(target);

As you can see, the tasks in a Cake script have dependencies to each other. When you call the method RunTarget(“Default”), the Cake program will go up the chain of dependencies and start running the script from the top. The task you put in the RunTarget() method will always be run last. For example if you execute RunTarget(“Test”), Cake will run task Clean, then Test and then stop.

Build.ps1

To actually run the Cake script, you need the Cake NuGet package. You can download it manually, but instead of doing that, I download it and run the Cake script using a PowerShell script:

[CmdletBinding()]Param(    
    [string]$target = "Default",     
    [string]$build = "0",     
    [string]$revision = "",     
    [string]$octopusUrl = "",     
    [string]$octopusApiKey = "") 
    
If (Test-Path tools) { 
    Remove-Item tools -Force -Recurse 
}

mkdir tools 
    
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>netcoreapp2.1</TargetFramework></PropertyGroup></Project>' > tools\build.csproj
    
dotnet add tools/build.csproj package Cake.CoreCLR --package-directory tools 

$pathToCakeDll = (Get-Item tools/cake.coreclr/*/Cake.dll).FullName 

dotnet $pathToCakeDll build.cake -target="$target" -build="$build" -revision="$revision" -octopusUrl="$octopusUrl" -octopusApiKey="$octopusApiKey"

First I set up all the variables I want to insert from the command line or from TeamCity during the build. To make sure I don’t have several versions of the Cake NuGet package, I delete the tools directory and recreate it.In the tools directory I create an absolutely minimal dotnet core project file and insert the latest version of the Cake NuGet package into the project structure. This makes dotnet core download Cake.dll Finally, I run build.cake using the Cake NuGet package.

Set up TeamCity to run build.ps1

In TeamCity, I have a project that automatically fetches new commits from a GitHub repository. The project contains a single build step, a PowerShell runner, with these settings: Runner type: PowerShell Format stderr output as: error Script: Source code Script execution mode: Execute .ps1 from external file Script source:

.\build.ps1 -target Default -build %build.counter% -revision %build.vcs.number% -octopusUrl %OctopusUrl% -octopusApiKey %OctopusApiKey%
exit $LASTEXITCODE

Project parameters: OctopusApiKey OctopusUrl Get the values of the parameters from your Octopus Deploy installation.

Display test results in TeamCity

Out of the box, TeamCity will run the xUnit tests, and will stop the build process if one of the tests fail. But you will only get a generic error message. To get a detailed view of which tests have passed and which have failed under the Tests tab in TeamCity, you need to install NuGet package TeamCity.VSTest.TestAdapter in the test project.