Build C# Project with GitHub Actions

Continuous integration (CI) as done with GitHub Actions serves multiple purposes. You get to continuously check that newly integrated code still works correctly and when it comes to cross-platform development, it also allows you to ensure that new code doesn’t just “works on my machine”, but also on platforms you might not even have the hardware for.

While working on SFML.Net and later also for my C# Stream Deck Clockify plugin, I added GitHub Actions support for both. The focus for each was on cross-platform support, as it’s one of SFML’s main selling points and as I want to have my plugin run on both Windows and MacOS. Since .NET Framework would only work with Mono (to some extend), it’s out of scope here and I’ll focus on .NET Core and newer.

Foto from inside of Universal Studios in Singapore
From my visit of Universal Studios Singapore last year

The Basics

As with any GitHub Actions build you start out with a YAML file in the .github/workflows directory of your repository. There are countless guides, including the official one, so I won’t go over all the details.

You can have different triggers, I usually pick push and pull_request, so the pipeline runs for my own pushes to any branch as well as for all pull requests opened.

While we’re using GitHub Actions and .NET, both owned by Microsoft, it’s just good hygiene to disable telemetry for the dotnet command line tool via the opt-out environment variable.

name: CI

on: [push, pull_request]

env:
  DOTNET_CLI_TELEMETRY_OPTOUT: 1

...

The Matrix

Maybe there are better ways to setup GitHub Actions, but we’ve had great success with a matrix of platforms and other distinct options for our SFML pipeline, so I’ve copied this here, except instead of building shared or static libraries, I differentiate on the desired .NET version.

While .NET Core as well as .NET 5 are officially out of support lifecycle-wise, I’ve still listed them as examples below, but I recommend to not use them, unless you, for some reason, really need to still test those versions – the earlier you upgrade to the next supported .NET version, the easier it will be.

...

jobs:
  build:
    name: ${{ matrix.platform.name }} ${{ matrix.dotnet.name }}
    runs-on: ${{ matrix.platform.os }}
    
    strategy:
      fail-fast: false
      matrix:
        platform:
        - { name: Linux, os: ubuntu-22.04 }
        - { name: Windows VS2022, os: windows-2022 }
        - { name: MacOS, os: macos-12 }
        dotnet:
        - { name: .NET Core 3.1, version: '3.1' }
        - { name: .NET 5, version: '5.0.x' }
        - { name: .NET 6, version: '6.0.x' }
        - { name: .NET 7, version: '7.0.x' }

As you see, we build a matrix for all three major OS and four different .NET versions. To easily identify each build, we use that information in the name of the build, so you’ll end up with “Windows VS2022 .NET 7”, “MacOS .NET 6”, etc.

A quick word on fail-fast: This can be quite useful and important, if you’re paying for compute time, because this option allows you to fail the whole pipeline, in case there’s an error in just one of the builds, potentially saving you a lot of money. On the flip side, it can also make it more uncertain, whether the failure was just with one of the matrix configuration, or whether it would apply to all the builds. For open source projects, I recommend to set fail-fast to false, so you can see the results of all the builds and since it doesn’t cost you anything anyways…

The Work

Next follows the actual work of the pipeline. This can differ a lot depending on what your needs are. Here, we’ll just assume a simple restore, build and test of the project, but before we get there, we need to fetch the source and ensure we’re using the desired .NET SDK.

...

jobs:
  build:
    ...

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3
      - name: Setup .NET ${{ matrix.dotnet.version }} SDK
        id: setup-dotnet
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: ${{ matrix.dotnet.version }}
      - name: Enforce SDK Version
        run: dotnet new globaljson --sdk-version ${{ steps.setup-dotnet.outputs.dotnet-version }} --force
      - name: Verify SDK Installation
        run: dotnet --info

After checkout, we install/ensure that the wanted .NET SDK version is installed on the runner, and then also ensure, that this version is set as default, in case there are multiple SDK versions installed in parallel. Finally, we output the current configuration, so if there’s some issue with the build, we know the exact version installed.

You can have a look at both the checkout and setup-dotnet action for more detailed information and additional options.

Now, we’re ready to call the cross-platform dotnet CLI commands.

...

jobs:
  build:
    ...

    steps:
    ...

      - name: Restore
        run: dotnet restore
      - name: Build
        run: dotnet build --configuration Release --no-restore
      - name: Test
        run: dotnet test --no-restore --verbosity normal

Note: This assumes that you have a solution in the root directory of the repository. If that’s not the case, then you can add the relative path to the solution at the end of each dotnet command: dotnet restore source/Phoenix.sln

Of course you’re not limited to just restore, build, and test, but you can also publish the project, upload the artifact to later generate a release and anything else you need or want. For the Clockify plugin I went as far, as to collect the MacOS and Windows build artifacts, generate a complete plugin and make it available for download. This way I have a freshly built version for both Windows and MacOS with every push.

The Summary

Putting everything together you get cross-platform C# builds for Linux, Windows and MacOS and for different .NET versions, without requiring access to any additional hardware, nor fiddling with ancient build server software.

name: CI

on: [push, pull_request]

env:
  DOTNET_CLI_TELEMETRY_OPTOUT: 1

jobs:
  build:
    name: ${{ matrix.platform.name }} ${{ matrix.dotnet.name }}
    runs-on: ${{ matrix.platform.os }}
    
    strategy:
      fail-fast: false
      matrix:
        platform:
        - { name: Linux, os: ubuntu-22.04 }
        - { name: Windows VS2022, os: windows-2022 }
        - { name: MacOS, os: macos-12 }
        dotnet:
        - { name: .NET Core 3.1, version: '3.1' }
        - { name: .NET 5, version: '5.0.x' }
        - { name: .NET 6, version: '6.0.x' }
        - { name: .NET 7, version: '7.0.x' }

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3
      - name: Setup .NET ${{ matrix.dotnet.version }} SDK
        id: setup-dotnet
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: ${{ matrix.dotnet.version }}
      - name: Enforce SDK Version
        run: dotnet new globaljson --sdk-version ${{ steps.setup-dotnet.outputs.dotnet-version }} --force
      - name: Verify SDK Installation
        run: dotnet --info
      - name: Restore
        run: dotnet restore
      - name: Build
        run: dotnet build --configuration Release --no-restore
      - name: Test
        run: dotnet test --no-restore --verbosity normal

This is about as simple as it gets for setting up C# builds on GitHub Actions.

In case you run more complex integration tests, see my post on running Microsoft SQL Server on GitHub Actions.

One thought on “Build C# Project with GitHub Actions

Leave a Comment

Your email address will not be published. Required fields are marked *

 

This site uses Akismet to reduce spam. Learn how your comment data is processed.