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.
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'_11on:_11 push:_11 tags:_11 - "v[0-9]+.[0-9]+.[0-9]+"_11#Build job with 'ubuntu-latest' as environment to run on_11jobs:_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:
_12jobs:_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!