8 minutes read

Publishing NuGet packages using GitHub Actions

Manual deployment is a drag. If you find yourself doing the same tasks over and over, it’s time to automate. Let's dive into how GitHub Actions can simplify the process of building, testing, and publishing your NuGet package.

Scrabble words reading "Order" and "Chaos"

Automating your CI/CD pipeline is the best thing since sliced bread. Sure, you could do everything manually. But, if you're anything like me, you don't publish or deploy often enough to remember all the steps. Adding team members only highlights the importance of automation.

In this article, we'll explore deploying NuGet packages to any NuGet feed using GitHub Actions.

Why Build Your CI/CD Using GitHub Actions

GitHub Actions are one of the easiest ways to create a CI/CD pipeline from scratch. Most of us already host our projects on GitHub, one of the most popular software development platforms. It makes sense to use its features rather than looking for third-party solutions. GitHub Actions can automate more than just CI/CD; they handle workflows like issues, discussions, project boards, and more!

GitHub Actions offers generous pricing, being completely free for all public repositories. Even private repositories get 2000 minutes per month on the GitHub Free Account type!

Additionally, there's ample support for GitHub Actions. Most popular platforms and products either have a specific GitHub Action, support through open-source Actions, or tutorials specifically for GitHub Actions. GitHub itself even hosts a repository with over 60 actions you can integrate into your workflows: https://github.com/actions.

The Anatomy of a NuGet Package

We all know we can use dotnet new classlib -o MyPackage to create a new Class Library. But how do we make it suitable for NuGet?

You only need to run dotnet pack .\MyPackage to produce the desired .nupkg. However, without any configuration, we end up with some default metadata that might not make sense.


_10
<id>MyPackage</id>
_10
<version>1.0.0</version>
_10
<authors>MyPackage</authors>
_10
<description>Package Description</description>

At the very least, we’ll need to set the following properties in our .csproj:


_12
<Project Sdk="Microsoft.NET.Sdk">
_12
<PropertyGroup>
_12
<!-- Other properties -->
_12
<PackageId>My.Package</PackageId>
_12
<Version>0.0.1</Version>
_12
<Authors>Lars</Authors>
_12
<Company>Weekenddive</Company>
_12
<Description>
_12
My awesome package
_12
</Description>
_12
</PropertyGroup>
_12
</Project>

Now, when we pack our project, we get sensible metadata. You can find all other properties related to package authoring here.

Should You Use .csproj or .nuspec

When diving deeper into package authoring, you'll find two ways to define metadata:

  • Using MSBuild properties in your .csproj file, as we’ve done above
  • Using a NuGet-specific file called .nuspec

It's possible to use them interchangeably or even together (using the <NuspecFile> MSBuild property). However, for most use cases, Microsoft advises using the MSBuild approach for SDK-style projects and .nuspec for everything else.

Packaging a CLI Tool

NuGet packages can be used for more than frameworks and libraries. If you’ve used EFCore Code First migrations, you've probably installed the dotnet-ef tool with dotnet tool install.

A "Tool" is just a NuGet package that contains a .NET Console application. By including the following properties in your .csproj, the package is marked as a tool and can easily be installed with dotnet tool install MyPackage:


_10
<PackAsTool>true</PackAsTool>
_10
<ToolCommandName>mypackage</ToolCommandName>

When you install this package as a tool, you can invoke it in the command line using the name defined in the ToolCommandName.

Build, Test, and Pack Using GitHub Actions

Now that we know how to properly author a NuGet package, let's dive into GitHub Actions.

Before doing anything .NET related, we need to define a new workflow. A workflow is the actual automated process that can run one or more jobs and is configured using YAML.

First, we create a new .yml file in .github/workflows. Since our workflow automates our release process, let's call it release.yml. A workflow should include a trigger and a job at the very least, like so:


_11
#Workflow triggered when a tag is pushed that looks like 'v1.0.0'
_11
on:
_11
push:
_11
tags:
_11
- "v[0-9]+.[0-9]+.[0-9]+"
_11
#Build job with 'ubuntu-latest' as environment to run on
_11
jobs:
_11
build:
_11
runs-on: ubuntu-latest
_11
steps:
_11
- run: echo "Hello World"

Valid workflows require at least the following properties:

  • A trigger, defined by the on key. Docs: Trigger a workflow
  • A job that defines the test runner with runs-on, here I picked Ubuntu latest since Ubuntu is the cheapest instance. And has at least one step that does something. In this case, we only write "Hello World" to the console.

Required Steps

Now that we have a valid workflow, let’s make it do something useful.

Before we can use the build, test, and pack dotnet commands effectively, we need to prepare the environment. Our test runner starts with a clean Ubuntu image that doesn't have our code or the .NET SDK installed. The first steps, then, are to pull our code and install the .NET SDK:


_12
jobs:
_12
build:
_12
runs-on: ubuntu-latest
_12
steps:
_12
# Pull code from the repository
_12
- name: Checkout
_12
uses: actions/checkout@v4
_12
# Setup .NET 8
_12
- name: Setup dotnet
_12
uses: actions/setup-dotnet@v4
_12
with:
_12
dotnet-version: 8

We use the uses key instead of run when we want to run an action as part of our step. An action is a reusable unit of code, something either made by us or available on the GitHub Marketplace for Actions. Be sure to check out the documentation for uses for additional best practices.

With our environment set up, it's finally time to build, test, and pack our package. I’m sure you’ve guessed that this is as easy as defining a couple of steps that will run the specific dotnet commands like this:


_10
- name: Build
_10
run: dotnet build --configuration Release
_10
- name: Test
_10
run: dotnet test --configuration Release --no-build
_10
- name: Pack
_10
run: dotnet pack --configuration Release --no-build --output nupkgs

Since we start off with the dotnet build command, the build steps executed by the test and pack commands can be omitted by using the --no-build flag to speed up the workflow. Finally, we can configure the output directory by specifying the --output flag followed by the desired folder.

Versioning

As you might remember, our package is currently set to version 0.0.1. While this might be fine during development, our packages should be properly versioned.

Our workflow gets triggered when we push a tag that looks like a semver version, allowing us to set the correct version number for our package.

The following step extracts the version number from the tag and stores it in an environment variable:


_10
- name: Set VERSION_NUMBER variable from tag
_10
run: echo "VERSION_NUMBER=${GITHUB_REF_NAME/v/}" >> $GITHUB_ENV

Next, we need to set the version in all three dotnet commands like this:


_10
- name: Build
_10
run: dotnet build --configuration Release /p:Version=${VERSION_NUMBER}
_10
- name: Test
_10
run: dotnet test --configuration Release /p:Version=${VERSION_NUMBER} --no-build
_10
- name: Pack
_10
run: dotnet pack --configuration Release /p:Version=${VERSION_NUMBER} --no-build --output nupkgs

Pushing the Package to GitHub Package Registry

With all this preparation, there's not much left to do before our workflow can push the package to a NuGet registry. All we have to do is authenticate ourselves against a NuGet feed, and then we're good to go!

In this guide, I choose the GPR (GitHub Package Registry), but this should work for the official NuGet or any other NuGet registry as well.

Luckily, with the setup-dotnet action we used previously, authenticating a NuGet feed is as easy as adding two additional properties to the action like this:


_10
- name: Setup dotnet
_10
uses: actions/setup-dotnet@v4
_10
with:
_10
dotnet-version: 8
_10
source-url: <https://nuget.pkg.github.com/><owner>/index.json
_10
env:
_10
NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} #Use the github action provided token

Finally, we can push the package with dotnet nuget push like so:


_10
- name: Push
_10
run: dotnet nuget push nupkgs/${PACKAGE_ID}.${VERSION_NUMBER}.nupkg
_10
env:
_10
PACKAGE_ID: "My.Package"

Now your package should be available in the GitHub Package Registry. You can find it either in your repo, under "packages" or on your profile at https://github.com/<username>?tab=packages

Conclusion

While publishing a NuGet package does only take a couple of dotnet commands, automating adds some nice layers of efficiency and reliability.

This workflow doesn't just automate the deployment; it runs all tests to ensure that each release meets quality standards before it’s even packaged. If a test fails, the workflow fails, preventing flawed software from accidentally getting released. This not only streamlines the process but also significantly reduces the risk of human error, enhancing both quality and version control automatically, without further input required from us!

Be sure to check GitHub’s documentation for GitHub Actions, as there is still a lot more you can do with them!