Skip to content

How to use Cake with Github Actions

Regular readers of my blog will know that I am a fan of Cake a free and open source cross-platform build automation system with a C# DSL and have written several posts including How to write a Cake Build script for ASP.net core project and How to use Cake with Rider.

I have also recently been making more use of GitHub Actions for my CI/CD pipelines, an approach I have discussed in How to use Github actions to build & deploy Github nuget packages.

In this post I will explain how to use these two cool pieces of technology together to improve your CI/CD pipelines and your Commit Dance, I'll also discuss some really useful bits of software I have found and now use to make my life easier.

The project I use in this example is my Threenine.ApiResponse a project I use to standardise the Response objects in Web API projects and specifically my Threenine.ApiProject to Implement Vertical Slice Architecture in Web API projects. All the code for this post can be seen in the repo.

Dotnet on Linux

To manage expectations this post details and recommends tools which may only work as expected in this article on Linux.

For those interested on developing dotnet on Linux can check Why I use Linux as my developer platform

What are Github actions

GitHub Actions are continuous integration and continuous delivery (CI/CD) platform that enables developers to automate their build, test, and deployment pipelines. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production.  If you're unfamiliar with Github Actions it's well worth reading Understanding GitHub Actions to get familiar with them.

You only need a GitHub repository to create and run a GitHub Actions workflow, and you could be literally up and running using them in no time. I recommend the Quickstart for Github actions to get up and running and getting a basic understanding if you want to follow on with the rest of this tutorial.

It is entirely possible to develop really good Workflows directly in your Github actions without the need for any other tools etc. However, in my use case I generally always use Cake to write a Build and Test automation scripts for development. For the majority of the work I do we generally use Jetbrains Space and TeamCity, which I predominanly runs our Cake Scripts for CI/CD.

I generally use Github for my open source projects and have become a fan of using Github actions.

Why Use Cake

I mentioned previously that Cake open source cross-platform build automation system with a C# DSL, which basically enables you to write your Build Automation scripts in C#. So if you're a .net based development team it makes sense to use, because it helps to democratise your devops processes, enabling all developers to get access to and understand your build automation scripts.

If you've worked on large software projects that use tools like Jenkins or some such where all the build tasks are written as custom Jenkins tasks which undoubtedly become a large tangled gnarly mess of a number languages such as Groovy, Python and Bash Scripts which only those who work with it on a day to day basis really understand. So whenever, issues in the build pipelines arise your developers can never really contribute to resolving it because they simply don't know how best to get involved and tasks.

These issues can really hamper velocity and productivity. Lee Richardson provides further colour to the issues and reason Why Use Cake? 4 Reasons.

Using Github actions and Cake together makes more sense when want to ensure that your developers are able to fully embrace the ethos of devops.

Github actions are great and I have been using them ever since they were initially released on projects, they quickly replaced my other previous favourite alternatives like appveyor or circleci, for quick and easy build and deployment scripts. However they can get a bit unruly and difficult to edit or even read when development teams start introducing more complex tasks etc.i.e. Bulding and publishing docker images or packages etc.

They can also be difficult to test and debug, and often times you may need to do this several times before you can get things working 100%. An additional, point is that although they are only YAML files and in theory can be easily understood by most developers , they can get overly complex once logic is introduced and simple bugs can be included just by a column being out etc.

Prerequisites

There are a few tools that I have grown to appreciate and usually use when developing that are probably worth mentioning before we dive into the code aspect of this article.

GitVersion

We want to be able to provide a Semantic Version number for our Nuget Package. Semantic Versioning is a versioning scheme for using meaningful version numbers,  Semantic Versioning works by structuring each version identifier into three parts, MAJORMINOR, and PATCH, and them putting these together using the familiar MAJOR.MINOR.PATCH notation. Each of these parts is managed as a number and incremented according to the following rules:

  • PATCH is incremented for bug fixes, or other changes that do not change the behaviour of the library.
  • MINOR is incremented for backward-compatible changes of the library, meaning that existing consumers can safely ignore such a version change.
  • MAJOR is incremented for breaking changes, i.e. for changes that are not within the backwards compatibility scope. Existing consumers have to adapt to the new library, very likely by adapting their code.

GitVersion is a tool that generates a Semantic Version number based on your Git history. The version number generated from GitVersion can then be used for various different purposes, such as:

  1. Stamping a version number on artifacts (packages) produced during build.
  2. Exposing the version number to the build server to version the build itself.
  3. Patching AssemblyInfo.cs (and similar) files with the version number during the build, so the version number is embedded within the compiled binaries themselves.

Check out Gitversion

act

Run your GitHub Actions locally! Why would you want to do this? Two reasons:

  • Fast Feedback - Rather than having to commit/push every time you want to test out the changes you are making to your .github/workflows/ files (or for any changes to embedded GitHub actions), you can use act to run the actions locally. The environment variables and filesystem are all configured to match what GitHub provides.
  • Local Task Runner - I love make. However, I also hate repeating myself. With act, you can use the GitHub Actions defined in your .github/workflows/ to replace your Makefile!

Check out Act

Github CLI

The Github CLI enables the interaction with Github without ever having to open up a browser and directly from your terminal. I have previously discussed how to install Github CLI on Linux.

Rider

Frequent readers of my posts will know I'm a big fan of Rider the Fast & powerful cross-platform .NET IDE and always recommend others to use it.

Cake for Rider

The Cake for rider plugin is great way to add support for the Cake build tool in Rider. Checkout Cake for rider.

I do breakdown further how to use cake in rider and provide further instructions how to write Cake Build Scripts, discussing the basics of writing cake build scripts.

So that is all the prerequisites out of the way. I make regular use of all these tools as part of my day to day development tasks and regularly recommend all of them.

Developing the Cake Script

The cake script we are going develop essentially does 5 things :

  1. Determine Semantic version number
  2. Build the project
  3. Run Unit tests
  4. Package the project to Nuget package
  5. Publish the package to Nuget
  6. Publish the package to Github Packages

We will aim to accomplish all this tasks in 1 file which we will create in the root of project directory and name it build.cake

1 . Determine Semantic Version Number

The GitVersion Tool, is highly configurable tool and relies on a GitVersion.yml which should be in the root of your project directory.

mode: Mainline
branches:
  main:
    tag: ''
    increment: inherit
    source-branches:
      - main
    is-release-branch: true
  feature:
    increment: Minor
    tag: alpha
  release:
    increment: Minor
    tag: beta
    is-release-branch: false
  hotfix:
    increment: Patch
    tag: beta
ignore:
  sha: [ ]

You can make use of the gitversion init to launch a configuration tool which can help you to configure GitVersion the way you want. However, in my case I will make use of the simple configuration above for my purposes.

We can now go ahead and configure our build.cake to make use of GitVersion

#tool "dotnet:?package=GitVersion.Tool&version=5.10.3"

We can now add our first task to our file which will extend functionality provided by the tool. In our case we simply want to derive a neat Semantic Version for our library and GitVersion provides a number of Version Variables we can use. The NugetVersionV2 output variable will suit our use case quite nicely.

We will also create a variable of version to store the output so we can reuse the value later in our script

string version = String.Empty;

Task("Version")
    .Does(() => {
   var result = GitVersion(new GitVersionSettings {
        UpdateAssemblyInfo = true
    });
    
    version = result.NuGetVersionV2;
    Information($"Version: { version }");
});

We can use Cake for Rider to execute our task and the result will be displayed in our terminal window.

Advice

Gitversion is a great tool and I urge you to read the documentation to understand this tool in depth because it provides a lot of great versioning capabilities and I have only touched on the absolute basics here.

Cake Build Script

I will not go into the in depth discussion of every aspect of the Cake Build script because for the most part the Cake Documentation covers it in detail and it should also be fairly self explanatory just reading the code. I have tried to Keep It Simple Stupid (KISS) primarily because this how I prefer to code and also as John Ousterhout explains in his book Philosophy of software design the primary job of software developer is to fight complexity

A Philosophy of Software Design

how to decompose complex software systems into modules that can be implemented relatively independently.

The Cake Script I generally use for all my Nuget Package and deploy scripts looks similar to the one below. There are 1 or 2 points worth discussing. The keen eyes will notice that I publish the package both to Nuget and Github package repositories and the only real difference there is that we make use of two different Keys to enable publishing. These keys are passed into Cake script using environment variables, we will see how we do this when we get to the Github action section of this post.

The second point is you'll also notice that we check explicitly if we're running on Github before we execute these tasks. The primary reason for that is so we can run and package this Nuget package locally using the Cake script and even install it to local projects to test that it works. I've touched on this capability in How to install and test nuget template packages locally

You'll notice we Package the contents to a .artifacts folder which should be excluded from your git repository using .gitignore

#tool "dotnet:?package=GitVersion.Tool&version=5.10.3"

var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
string version = String.Empty;
//////////////////////////////////////////////////////////////////////
// TASKS
//////////////////////////////////////////////////////////////////////

Task("Clean")
    .Does(() => {
    DotNetClean("./");
});

Task("Restore")
    .IsDependentOn("Clean")
    .Description("Restoring the solution dependencies")
    .Does(() => {
           var projects = GetFiles("./**/*.csproj");

              foreach(var project in projects )
              {
                  Information($"Building { project.ToString()}");
                  DotNetRestore(project.ToString());
              } 
});

Task("Version")
    .Does(() => {
   var result = GitVersion(new GitVersionSettings {
        UpdateAssemblyInfo = true
    });
    
    version = result.NuGetVersionV2;
    Information($"Version: { version }");
});

Task("Build")
    .IsDependentOn("Version")
    .Does(() => {
     var buildSettings = new DotNetBuildSettings {
                        Configuration = configuration,
                        MSBuildSettings = new DotNetMSBuildSettings()
                                                      .WithProperty("Version", version)
                                                      .WithProperty("AssemblyVersion", version)
                                                      .WithProperty("FileVersion", version)
                       };
     var projects = GetFiles("./**/*.csproj");
     foreach(var project in projects )
     {
         Information($"Building {project.ToString()}");
         DotNetBuild(project.ToString(),buildSettings);
     }
});

Task("Test")
    .IsDependentOn("Build")
    .Does(() => {

       var testSettings = new DotNetTestSettings  {
                                  Configuration = configuration,
                                  NoBuild = true,
                              };
     var projects = GetFiles("./tests/*/*.csproj");
     foreach(var project in projects )
     {
       Information($"Running Tests : { project.ToString()}");
       DotNetTest(project.ToString(), testSettings );
     }
});

Task("Pack")
 .IsDependentOn("Test")
 .Does(() => {
 
   var settings = new DotNetPackSettings
    {
        Configuration = configuration,
        OutputDirectory = "./.artifacts",
        NoBuild = true,
        NoRestore = true,
        MSBuildSettings = new DotNetMSBuildSettings()
                        .WithProperty("PackageVersion", version)
                        .WithProperty("Copyright", $"Copyright threenine.co.uk {DateTime.Now.Year}")
                        .WithProperty("Version", version)
    };
    
    DotNetPack("./ApiResponse.sln", settings);
 });
Task("PublishNuget")
 .IsDependentOn("Pack")
 .Does(context => {
   if (BuildSystem.GitHubActions.IsRunningOnGitHubActions)
   {
     foreach(var file in GetFiles("./.artifacts/*.nupkg"))
     {
       Information("Publishing {0}...", file.GetFilename().FullPath);
       DotNetNuGetPush(file, new DotNetNuGetPushSettings {
          ApiKey = context.EnvironmentVariable("NUGET_API_KEY"),
          Source = "https://api.nuget.org/v3/index.json"
       });
     }
   }
 }); 
 
 Task("PublishGithub")
  .IsDependentOn("Pack")
  .Does(context => {
  if (BuildSystem.GitHubActions.IsRunningOnGitHubActions)
   {
      foreach(var file in GetFiles("./.artifacts/*.nupkg"))
      {
        Information("Publishing {0}...", file.GetFilename().FullPath);
        DotNetNuGetPush(file, new DotNetNuGetPushSettings {
              ApiKey = EnvironmentVariable("GITHUB_TOKEN"),
              Source = "https://nuget.pkg.github.com/threenine/index.json"
        });
      } 
   } 
 }); 



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

Task("Default")
       .IsDependentOn("Clean")
       .IsDependentOn("Restore")
       .IsDependentOn("Version")
       .IsDependentOn("Build")
       .IsDependentOn("Test")
       .IsDependentOn("Pack")
       .IsDependentOn("PublishNuget")
       .IsDependentOn("PublishGithub");

RunTarget(target);

Github action

We now get to creating our really simple Build and Deploy Github action script. The script itself just ensures we have create an build environment based on .net 6, we then install Cake into the environment and then execute our Cake Build script.

You'll notice that we setup the two environment variables we discussed earlier int he post that will enable us to post to our various repositories.

NUGET_API_KEY we set up as an organisational secret that is available to all repositories in our Github organisation account and can simply be accessed by the secrets. and the Github Token for the organisation can be accessed simply using the github.token

name: nuget-build-deploy
on:
  push:
    branches:
      - main
  pull_request:
    branches: 
      - main
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: Setup .NET
      uses: actions/setup-dotnet@v1
      with:
          dotnet-version: 6.0.x 
    - name: Check out Code
      uses: actions/checkout@v2
      with:
         fetch-depth: 0
    - name: Run cake
      shell : bash
      env:
        NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
        GITHUB_TOKEN: ${{ github.token }}
      run: |
        dotnet new tool-manifest
        dotnet tool install Cake.Tool
        dotnet tool restore
        dotnet cake

How to use Cake in Bitbucket Pipelines

For those development teams out there that are using Bitbucket, my first question is why 🙂 but then I do realise that a lot of these decisions are taken out of the hands of development teams and typically involve project management. However, you can still use the approach out lined above using Bitbucket.

Obviously the mechansims of passing in Environment variables etc will change but the concept of calling your Cake.build script is the same

image: mcr.microsoft.com/dotnet/sdk:6.0
clone:
  lfs: true
  depth: full
pipelines:
  default:
    - step:
        name: Build
        services:
          - docker
        script:
          - dotnet new tool-manifest
          - dotnet tool install Cake.Tool
          - dotnet cake

How to use Cake in Azure Devops

You can quite easily use Cake in your Azure DevOps and the configuration process is similar to the above. All that is required is to you add your azure-pipelines.yml

trigger:
    - main

pool:
    vmImage: ubuntu-latest

steps:
    - script: echo Running Dotnet cake
      displayName: 'Running dotnet cake'

    - task: UseDotNet@2
      displayName: 'Install .NET Core SDK'
      inputs:
          version: 7.x
          performMultiLevelLookup: true

    - script: |
          dotnet new tool-manifest
          dotnet tool install Cake.Tool
          dotnet tool restore
          dotnet tool run dotnet-cake ./solution.cake

The only issue I have experienced is that if you attempt to run DotnetClean on azure pipelines it tends to cause an issue particularly if there is nothing to clean. I by passed this by adding a conditional in the Clean step to detect if it is running on Azure Devops and not preform the step

Task("Clean")
    .Does(() => {
    
    if (BuildSystem.AzurePipelines.IsRunningOnAzurePipelines)
    {
        BuildSystem.AzurePipelines.Commands.WriteWarning(
                " Nothing to clean on Azure Pipelines.");
    }
    else
    {
        DotNetClean("./your-solution-file.sln");
    }
    
   
});

Conclusion

That's it! We've covered a lot of ground in this post and I am sure I will have to provide additional updates as I go through the continual editorial process, when I find additional areas I could provide my detail on, but I feel this post provides enough information for you to get started with using Cake to build and deploy your Nuget packages.

Gary Woodfine
Latest posts by Gary Woodfine (see all)