Publishing Non-Trivial .NET with Github Actions

·

10 min read

Over the years I've used a few different CI solutions for my humble public projects: Travis, Appveyor, and eventually Azure Pipelines.

I initially liked the idea of separating the CI/CD to avoid the dreaded vendor lock-in and freeing me to change SCC provider at will, but I've never really liked the UX of always having to visit an external site. Github Actions is interesting for projects hosted on Github as it's an integrated solution similar to that offered by competitors like Gitlab.

Here I'm migrating one of my existing .NET projects to Github Actions, but only parts are .NET-specific and the majority should be equally applicable to other projects.

Basics

In one of your repositories you can click the Actions tab followed by the Set up this workflow button to initialize a .github/workflows/*.yml file based on the contents of the repo. Initial workflows come from templates in Github's actions/starter-workflows and look similar to this workflow for .NET:

name: .NET Core

# Trigger event
on:
  # Run on a push or pull request to default branch (usually master)
  push:
    branches: [ $default-branch ]
  pull_request:
    branches: [ $default-branch ]

# Jobs that run in parallel
jobs:
  build:

    runs-on: ubuntu-latest

    # Steps that run sequentially
    steps:
      # git checkout repo
    - uses: actions/checkout@v2
      # Install dotnet
    - name: Setup .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 3.1.101
      # Build and run tests
    - name: Build
      run: |
        dotnet restore
        dotnet build --configuration Release --no-restore
    - name: Test
      run: dotnet test --no-restore --verbosity normal

Workflows are triggered by events and run one or more jobs in parallel. Jobs are a set of sequential steps that are either a shell script or an action.

on: specifies the events that trigger the workflow. Events can be anything from pushing with a git client, creating a release on Github, periodic timers, and more.

runs-on: specifies the virtual environment hosting the workflow runner (i.e. Windows, Linux, or MacOS).

uses: specifies a reusable action (like setup-dotnet) to take care of boilerplate steps common to CI/CD scripts. These can be defined in: the same repo, public repos, or published docker images.

with: passes key-value parameters to actions.

run: executes command-lines on the shell. run: | (with a pipe) allows for multiple lines of commands.

Github provides an introduction and syntax documentation.

Multiple Platforms

One of the first changes I made was building and testing across multiple platforms:

jobs:
  build:
    strategy:
      matrix:
        os: [windows-2019, ubuntu-18.04, macos-10.15]
    runs-on: ${{ matrix.os }}
    env:
      DOTNET_NOLOGO: true
      DOTNET_CLI_TELEMETRY_OPTOUT: true
      DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
    steps:
    # ...
    - name: Code coverage
      if: ${{ matrix.os }} == 'ubuntu-18.04'
      uses: codecov/codecov-action@v1
      # ...

strategy: and matrix: enable you to create multiple jobs from a single definition. Here setting each of "windows", "ubuntu", and "macos" values from os: to ${{ matrix.os }} so runs-on: executes the job on all three OS's. It can similarly be used to test against multiple runtimes/frameworks/compilers, debug/release/profile configurations, and so on.

env: sets environment variables in all steps of a job.

if: enables a job or step only under the condition specified by an expression. ${{ }} is optional with if:, you can also write if: matrix.os == 'xxx'.

Code Coverage

I'd previously used OpenCover to instrument .NET projects. Microsoft has a nice article detailing code coverage for .NET unit tests. They use Coverlet which seems to now be the de-facto .NET solution given that Xunit test projects created with dotnet new xunit automatically add a reference to coverlet.collector.

Coverlet has 3 different ways to profile projects. The first approach integrates nicely by leveraging MSBuild (docs):

# Add package reference to coverlet.msbuild in test project
dotnet add tests/tests.csproj package coverlet.msbuild
# Run tests and generate `coverage.json`
dotnet test /p:CollectCoverage=true

The second approach uses a global tool:

# Install global tool
dotnet tool install --global coverlet.console
# Run tests
coverlet tests/bin/Debug/netcoreapp3.1/tests.dll --target dotnet --targetargs "test --no-build"

Make sure to include --no-build or the generated report will be empty. According to this blog coverlet instruments the existing assembly, so you don't want dotnet test rebuilding and replacing it.

There's a third approach where you use a "DataCollector" (as shown in the MS article). Coverlet provides information comparing the approaches as well as related drawbacks.

Now that you've got coverage data, you'll want to generate a report with something like ReportGenerator, or a cloud solution like Codecov as I will here.

Codecov provides codecov-action for easy integration with Github Actions:

    steps:
    # ...
    - name: Test
      run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov
    - name: Code coverage
      if: matrix.os == 'ubuntu-18.04'
      uses: codecov/codecov-action@v1
      with:
        files: ./tests/coverage.info
        flags: unittests

If Codecov reports "an error processing coverage reports" you'll be left staring at the unhelpful:

In my case, despite seemingly supporting json (the default produced by Coverlet) Codecov was unable to process it. Adding /p:CoverletOutputFormat=lcov to the test run fixed it.

Depending on the CI environment, testing framework, etc. codecov may be unable to process coverage reports without path fixing. This among other configuration options can be placed in codecov.yml (which can even be placed in .github/). See codecov.yml reference for full details.

Badges

Let's talk about flair. Badges provide an easy way to keep an eye on your project:

codecov

The easiest way to get a build badge is open a workflow file or run and then click ··· > Create status badge to generate markdown similar to:

![](https://github.com/USER/PROJECT/workflows/WORKFLOW_NAME/badge.svg)

Check the documentation for specifics and additional options.

For Codecov, open the repository then Settings > Badge. Alternatively, you can generate the badge directly from Actions.

Native Code

I also want Actions to produce some platform-specific native binaries. Even if you have no native code, this same approach can be used for multi-platform testing or to produce intermediary outputs/artifacts.

As before, we can use docker and QEmu on Linux to easily build platform-specific binaries without cross-compiling. The gist is create a Dockerfile like:

# Start with Debian for arm32
FROM multiarch/debian-debootstrap:armhf-buster AS arm32v7

# Install required software
RUN apt-get update && apt-get install -y \
    build-essential \
    clang \
    cmake

RUN mkdir -p build && cd build \
    # Build arm32 binary 
    && cmake -G "Unix Makefiles" .. \
    && make \
    # Copy out of container to host
    && cp libnng.so /runtimes

Rather than muddle my declarative Actions workflow with lots of branching and platform-specific shenanigans, I like to move logic out into a stand-alone script.

build_nng.ps1 takes care of these platform-specific details of building:

if ($IsLinux) {
    # Register QEmu to handle unsupported binaries
    docker run --rm --privileged multiarch/qemu-user-static:register
    # Build our docker image
    docker build -t build-nng Dockerfile
    # Mount `/runtimes` and run image
    docker run -i -t --rm -v "$PWD/nng.NETCore/runtimes:/runtimes" build-nng
}
else {
    if ($is_windows) {
        cmake -A $arch -G "Visual Studio 16 2019" -DBUILD_SHARED_LIBS=ON -DNNG_TESTS=OFF -DNNG_TOOLS=OFF ..
        cmake --build . --config Release
        $dll = "Release/nng.dll"
    } else {
        cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DNNG_TESTS=OFF -DNNG_TOOLS=OFF ..
        make -j2
        $dll = "libnng.dylib"
    }
    Copy-Item $dll "$runtimes/$path/native" -Force
}

Create another Github Action workflow:

on:
  workflow_dispatch:
    inputs:
      nng_tag:
        description: 'NNG version'
        required: true
jobs:
  build:
    strategy:
      matrix:
        os: [windows-2019, ubuntu-18.04, macos-10.15]
    runs-on: ${{ matrix.os }}
    steps:
    - name: Checkout nng.NET
      uses: actions/checkout@v2
    - name: Build
      run: |
        ./scripts/build_nng.ps1 -nng_tag ${{ github.event.inputs.nng_tag }}

This workflow is triggered by the workflow_dispatch event with inputs: parameters that can be manually run via Github UI:

github.event is part of the context about the workflow run. Amongst other values it contains nng_tag: from triggering event.

workflow_dispatch can also be triggered by HTTP:

curl \
  -X POST \
  -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/$USER/$REPO/actions/workflows/$WORKFLOW/dispatches \
  -d '{"inputs": {"nng_tag": "v1.3.0"}, "ref": "master"}' \
  # For authorization, must add one of:
  -u $USER:$TOKEN
  #OR
  -H "Authorization: Bearer $TOKEN"
  #OR
  -H "Authorization: Token $TOKEN"

The equivalent in powershell:

$SECURE_TOKEN=ConvertTo-SecureString -String $TOKEN -AsPlainText
Invoke-WebRequest `
    -Method Post `
    -Headers @{accept= 'application/vnd.github.v3+json'} `
    -Uri https://api.github.com/repos/$USER/$REPO/actions/workflows/$WORKFLOW/dispatches `
    -Body (ConvertTo-Json @{inputs=@{nng_tag="v1.3.0"}; ref= "master"}) `
    # For authorization, must add one of:
    -Authentication OAuth -Token $SECURE_TOKEN
    # OR
    -Authentication Bearer -Token $SECURE_TOKEN
    # OR by replacing above `-Headers`
    -Headers @{authorization= "Token $TOKEN"; accept= 'application/vnd.github.v3+json'}

See "Getting started with the REST API: Authentication" for more information and details on creating Github API tokens. It sounds like manual events should work even if the workflow isn't in the default branch, but appears to have issues

If there is an intermediary artifact that you want fed back into the project or build, actions/checkout@v2 makes it easy to work with git:

# ...
    steps:
      - run: |
          git config user.name ${{ username }}
          git config user.email ${{ email }}
          git add .
          git commit -m "generated"
          git push

After setting user.name and user.email, push the changes to a branch or do most anything else appropriate to your project.

You can also attach build artifacts with actions/upload-artifact:

      - name: Archive artifacts
        uses: actions/upload-artifact@v2
        with:
        path: |
            nng.NETCore/runtimes/**/*
            !nng.NETCore/runtimes/any/**/*
        if-no-files-found: error

Package and Publish

The final step is packaging and pushing a release to the nuget registry like we used to do manually.

Simplified script:

param(
    [string]$Version,
    [string]$NugetApiKey,
    [string]$CertBase64,
    [string]$CertPassword
)

$ErrorActionPreference = "Stop"

# Strip the leading "v".  E.g. "v1.3.2-rc0" => "1.3.2-rc0"
$Version = $Version -replace "^v",""

# Build nupkg
dotnet pack --configuration Release -p:Version=$Version
# Get list of build nupkgs
$packages = Get-ChildItem "./bin/Release/*.nupkg"

# Download nuget.exe
$Nuget = "./nuget.exe"
Invoke-WebRequest https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -OutFile $Nuget

# Create temporary code-signing certificate from base64-encoded string
$tempCert = New-TemporaryFile
[IO.File]::WriteAllBytes($tempCert.FullName, [Convert]::FromBase64String($CertBase64))

# Sign nupkg
foreach ($pkg in $packages) {
    & $Nuget sign $pkg -Timestamper http://sha256timestamp.ws.symantec.com/sha256/timestamp -CertificatePath $tempCert.FullName -CertificatePassword $CertPassword -Verbosity quiet
}

# Delete temporary code-signing certificate
$tempCert.Delete()

# Upload nupkg to nuget  
foreach ($pkg in $packages) {
    dotnet nuget push $pkg -k $NugetApiKey -s https://api.nuget.org/v3/index.json
}

There's a few options to control .NET versioning. I decided to set the entire version here at build time with dotnet pack -p:Version=$Version, but you could instead keep it in the project files under source control, only set the suffix with dotnet build --version-suffix <VERSION_SUFFIX>, or some combination of those.

To sign a package you still need nuget:

nuget.exe sign <Nupkg> -Timestamper http://sha256timestamp.ws.symantec.com/sha256/timestamp -CertificatePath <CodeSigningCert>

To supply a code-signing certificate to Actions you can base64 encode it and treat it as one of the build secrets (as shown below). That means we have to decode it to a temporary file before running nuget.

Additional resources related to .NET packaging/publishing:

The packaging workflow:

on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
        version:
          required: true

jobs:
  build:
    runs-on: windows-2019
    # ...
    - name: Package
      shell: pwsh
      run: |

        # Get the version
        $version = "${{ github.event_name }}" == "release" ? "${{ github.event.release.tag_name }}" : "${{ github.event.inputs.version }}"

        # Package and upload to nuget
        ./scripts/nupkg.ps1 -Version $version -CertBase64 ${{ secrets.CODE_SIGN_CERT_BASE64 }} -CertPassword ${{ secrets.CODE_SIGN_CERT_PASSWORD }} -NugetApiKey ${{ secrets.NUGET_API_KEY }}

Here I'm using on: release: to trigger the workflow on Github releases; publish a release and the packages appear in nuget.

I want $version to come from either: the git tag associated with the release, or from the supplied inputs.version when manually dispatched. We could use if: to provide alternative step implementations, but since it's just the value of one variable, it seems easier to resolve in script.

The secrets context provides access to values created in a Github repository with Settings > Secrets > New repository secret. The idea is to avoid storing sensitive information (e.g. passwords) in public places, and Actions takes the extra step to scrub them from logs.

Instead of (or in addition to) pushing your package to a registry, you can also upload it as a release asset attached to the release.

Fin

I think that serves as a good start on my migration to Github Actions. Really, I've barely scratched the surface as I've yet to look into community actions, writing my own custom actions, self-hosted runners, and much much more.