Automate Your C# Library Deployment: Publishing to NuGet and GitHub Packages with GitHub Actions

So you’ve created a cool C# library that you want to share with your fellow developers, but don’t know how? Maybe you know you could do that with NuGet packages, but don’t know how to create one?

This post showcases how you can make this amazing library you’ve created publicly available so that others can enjoy working with it!

What is a NuGet package?

A NuGet package is a single, packaged library or set of libraries, complemented by metadata and dependencies, designed to easily be distributed and integrated into projects in .NET.

This saves time for the developers by avoiding rewriting or duplicating code, as we promote reusability by installing these packages and making use of their functionality.

Sample library code

We are going to be a very simple logger class that just prints a message to the console. This printing will evolve to showcase that new changes in our library code can be automatically deployed to the package feeds, but we will come to this in a second.

  • First, let’s create a class library project. We can do so via de command line:
dotnet new classlib -n Something

The outcome should look something like:

We can now change the directory to our project folder:

cd MyAmazingLogger

Another thing we can do is to remove the default Class1.cs file.

  • Then, let’s create a class that represents the functionality we want to share. In this case, a logger class. Here’s some sample code:
public static class MyCustomLogger
{
     public static void LogMyMessage(string message) => Console.WriteLine($"v1: {message}");
}

Now that we have this base code class let’s make this shareable across the world :)

Configuring the continuous integration (ci) workflow file

If you want your GitHub actions to trigger whenever an event occurs, as an example a commit is made in main.

To configure our GitHub actions we need to place the yaml files inside a specific folder structure: .githubworkflowsci.yaml (this can be named anything)

This ci.yaml file contains the code that will package our library and publish it to the corresponding package source. This file can do many more things, like building the app, running tests, generating docker images, and so on, we are just using a very small fraction of the abilities this gives us.

Now let’s see how this file can look depending on the target package source. For each one of them, I’ll first introduce the final version of the ci.yaml file and then explain the file in detail.

Publishing to GitHub Packages with GitHub Actions

Here’s the CI manifest file to publish your NuGet packages to GitHub packages, via Github actions:

name: ci

on:
  push:
    branches: [main]

jobs:
  generate-version:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v2

      - name: GitHub Tag Bump
        id: tab_bump
        uses: anothrNick/github-tag-action@1.71.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          INITIAL_VERSION: 1.0.0
          DEFAULT_BUMP: patch

    outputs:
      new_version: ${{ steps.tab_bump.outputs.new_tag }}

  package-and-publish-lib:
    runs-on: ubuntu-latest
    needs: generate-version

    steps:
      - uses: actions/checkout@v2

      - name: Setup .NET
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 8.0.x

      - name: Generate NuGet package
        run: |
          dotnet pack MyAmazingLogger/ \
          --configuration Release \
          -p:PackageVersion=${{ needs.generate-version.outputs.new_version }} \
          -p:RepositoryUrl=https://github.com/RafaelJCamara/YT-Nuget-Pkg-Release \
          -o packages

      - name: Publish NuGet package
        run: dotnet nuget push packages/*.nupkg --api-key ${{ secrets.PUSH_NUGET }} --source https://nuget.pkg.github.com/RafaelJCamara/index.json

Now let’s analyze the important bits.

When to trigger a package publish

Based on what we have in our manifest:

on:
  push:
    branches: [main]

Packages are going to be published whenever we have some code merged to main.

What steps are involved

We know that our packages need to have a version. This version follows a format called semantic versioning. This is where the job called generate-version comes in. The output of this job is a version number that will be used when we publish our package in the package-and-publish-lib job.

So, as we can see, we have two main high-level overview steps: one to generate a package version and another to use that generated version in the package to be published.

Analyzing the generate-version job

This job only has one step and that step’s only responsibility is to generate a package version.

To help us on this task we are using an action called anothrNick/github-tag-action.

As you can see from our manifest file, we have the following environment variables necessary for this action to properly work:

  • GITHUB_TOKEN: This token is required for permissioning the tag in the repo. We can use a built-in token called secrets.GITHUB_TOKEN, you don’t have to configure anything to access this token. This token is automatically generated for your repo by GitHub and allows you to safely interact with GitHub’s Apis and your repository, allowing you to do things like publishing packages. As of the writing of this post, this token only has read permissions, therefore we can’t write anything. This is why we have this section at the top of the generate-version job:
    permissions:
      contents: write

This will give our token enough permission to write the tags.

If you don’t give write permissions, this is the error you will get:

There’s another way to solve this problem which is to not use that GitHub token and create your own token with read and write permissions. We will see how we can do this when we speak about the token usage for publishing packages.

  • INITIAL_VERSION: This presents an initial version we might have. Keep in mind that the notation followed is the semantic versioning one and that the first run of this will increment upon this default value. As an example, if the default value is 1.0.0 with default_bump patch and we run our GitHub action, the end value will be 1.0.1, and not 1.0.0 on the first run.

  • DEFAULT_BUMP: This represents how the increments will happen. The value we are using is patch, meaning that it will have increments like this: 1.0.5 to 1.0.6. We can have other bump strategies, choose one that best fits your use case.

For more information on these configurations, and possibly others you can enable, please take a look at the action’s documentation.

As you may have noticed, we have defined an output from the job. This is useful so that we can use the produced tag number in other jobs, which is what we want.

    outputs:
      new_version: ${{ steps.tab_bump.outputs.new_tag }}

As we can see from the snippet, we are defining an output with key new_version, which is pretty much just returning the value from the GitHub Tag Bump step. Just note that we had to give an ID to the step (tab_bump) so that we could target the step’s output.

Analyzing the package-and-publish-lib job

The two main responsibilities of this job are:

  1. Package our code into a NuGet package (*.nupkg)

  2. Publish this package into a package feed (in this case GitHub packages)

This job has 3 main steps:

  1. Choosing the appropriate .NET version (step Setup .NET)

  2. Generating the NuGet package (step Generate NuGet package)

  3. Publishing this NuGet package (step Publish NuGet package)

Let’s analyze each step with care.

  1. Choosing the appropriate .NET version (step Setup .NET)

This step simply uses the action actions/setup-dotnet@v1 to set up our .NET version (in this case .NET 8).

  1. Generating the NuGet package (step Generate NuGet package)

Let’s bring the package command closer to us so that we can analyze it better:

 dotnet pack MyAmazingLogger/ \
          --configuration Release \
          -p:PackageVersion=${{ needs.generate-version.outputs.new_version }} \
          -p:RepositoryUrl=https://github.com/RafaelJCamara/YT-Nuget-Pkg-Release \
          -o packages

First, we need to understand that we use the dotnet pack command to generate our NuGet package. In our case, we are using a couple of flags:

  • --configuration Release: we want to use the Release configuration because it’s more performant and tends to generate bundles with lower storage requirements (smaller size bundles).

  • -p:PackageVersion=${{ needs.generate-version.outputs.new_version }}: here we are specifying the NuGet package version. As you remember, we generated this version on the previous job (generate-version) and we need to access this output. To do so we can use the needs keyword, then specify the job we want to get the output from (generate-version) and then access the outputs property of this job and then the key of our output (which we named new_version). When you do this, you can get the generated version!

  • -p:RepositoryUrl=https://github.com/RafaelJCamara/YT-Nuget-Pkg-Release: What this is doing is adding the repository URL metadata to the published NuGet package. There is another way of doing this, which we will cover shortly.

  • -o packages: Specifies the directory where the generated NuGet package should be placed. In this case, the output will be stored in the packages folder.

After this step, we have our NuGet package ready to be published!

  1. Publishing this NuGet package (step Publish NuGet package)

Let’s take a closer look at the publish command:

dotnet nuget push packages/*.nupkg --api-key ${{ secrets.PUSH_NUGET }} --source https://nuget.pkg.github.com/RafaelJCamara/index.json

To publish our package we need to use the dotnet nuget push command. We need to provide 3 important values to it:

  • Where the NuGet package is located. We do so by passing the packages/*.nupkg parameter. This would normally match with what you’ve inserted as the output of your dotnet pack command.

  • Where the package feed/repository is located. In this case, our source is https://nuget.pkg.github.com/RafaelJCamara/index.json. Since we are using the GitHub package repository we have a special source structure: https://nuget.pkg.github.com/{github-username}/index.json. This means that if your username is LostJohn1234 your source would be something like https://nuget.pkg.github.com/LostJohn1234/index.json.

  • The api-key to access the feed. As you can see, we are passing a token from the GitHub secrets. This means we need to create one token that has permission to write packages. In the next section, I’ll show you how to do it.

Creating a GitHub secret

Let’s see how we can create a secret token that has permission to write NuGet packages to our GitHub package feed.

First, let’s go to your profile Settings page.

Go to the Developer Settings section, and select Tokens (classic) inside the Personal access tokens. Generate a classic token.

Make sure you have something like this:

When you generate the token, make sure you copy it because now we must add that token as a secret for our repository.

In your repository, click Settings. Inside the Security section, there is a subsection called Secrets and variables and under that there’s Actions. Make sure to click Actions. On Actions, create a new repository secret.

In the Name field, you need to put the secret name that matches what we have in the manifest file. This secret name was called PUSH_NUGET, but you can call it whatever you want with the condition that it must match what you wrote in the manifest.

In the Secret field, you must place the token value you got from the previous step.

Publishing to NuGet Feed with GitHub Actions

Here’s the CI manifest file to publish your NuGet packages to the NuGet feed, via Github actions:

name: ci

on:
  push:
    branches: [main]

jobs:
  generate-version:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v2

      - name: GitHub Tag Bump
        id: tab_bump
        uses: anothrNick/github-tag-action@1.71.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          INITIAL_VERSION: 1.0.0
          DEFAULT_BUMP: patch

    outputs:
      new_version: ${{ steps.tab_bump.outputs.new_tag }}

  package-and-publish-lib:
    runs-on: ubuntu-latest
    needs: generate-version

    steps:
      - uses: actions/checkout@v2

      - name: Setup .NET
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 8.0.x

      - name: Generate NuGet package
        run: |
          dotnet pack MyAmazingLogger/ \
          --configuration Release \
          -p:PackageVersion=${{ needs.generate-version.outputs.new_version }} \
          -p:RepositoryUrl=https://github.com/RafaelJCamara/YT-Nuget-Pkg-Release \
          -o packages

      - name: Publish NuGet package
        run: dotnet nuget push packages/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json

The difference between this manifest file and the previous one for GitHub packages is only two: the --source flag in the Publish NuGet package step and an adjustment we need to make in the api-key.

Before the source was the feed that was bound to your GitHub profile, but now the source needs to be the source of the NuGet feed: https://api.nuget.org/v3/index.json.

As far as the api-key, before our api-key was a secret that had a token that belonged to your GitHub profile, but now we must generate a token in nuget.org.

Every other step remains the same as before, so I advise you to take a read of what was written before and make the adjustments we are making in the section.

Creating a NuGet api-key

First of all, head out to nuget.org and do your registration.

After doing so successfully, click on your profile name and select the API Keys section. Click Create.

Select an expiration date that serves your interests and also make sure to put the correct scopes (you would need to add permission to push new packages and versions). You might also need to add the * attribute in the Global Pattern field.

Make sure you have something like this in the end:

Your key should be now visible:

Make sure to click Copy and take this value and put it as the Secret value in our PUSH_NUGET repository secret.

Ready. Set. Action.

After all of this setup, let's commit our code. When you do, by default, it’s going to do so in the main branch, which means that our workflow will trigger.

Now the outcome actually depends on which package source you are using.

If you are using GitHub’s Package repository, you should see your package on the right-hand side of your repository:

You will also notice that next to your commit you will have a success icon (or fail icon) if the underlying GitHub Action went fine:

If, on the other hand, you have used the nuget.org feed, this is what you should see in your published packages:

As you can see, you should be able to see the package you have published, with the correct version number attached to it.

Regardless of how many changes you now do on your package, all of these changes will now be automatically published to your chosen package feed!

How to use your custom NuGet packages

This step depends on which package repository you’ve used.

If you have used the default NuGet feed, you don’t need to do anything special to use your newly created package. Just go ahead and install it! It’s a plug-and-play because this feed comes by default with .NET. To check the current feeds you have, run the command dotnet nuget list source. If you haven’t added any feed, this is the outcome you should have:

If you have published your NuGet package to GitHub’s package repository, we must add it to our current list of sources. To do that we must run this command: dotnet nuget add source <your GitHub package feed> --name MyGitHubNuGetFeed. Normally the <your GitHub package feed> has a structure like https://nuget.pkg.github.com/{your GitHub username}/index.json.

In my case, the <your GitHub package feed> would be https://nuget.pkg.github.com/RafaelJCamara/index.json.

After adding your GitHub package feed, we can run the list source command again and see the newly added feed:

Now you can install your package freely!

Adding metadata to the published NuGet package

As I stated previously, we can set metadata related to our NuGet package in another way, and that another way is by adding metadata in the library .csproj file.

Each property specifies information that will be included in the package manifest, allowing others to understand the purpose, licensing, and source of the package when they download it from nuget.org or other sources. Here’s a breakdown of each property:

  1. <PackageId>: This is the unique identifier for your NuGet package (in this case, MyAmazingLogger). It’s what users will search for when they want to install your package.

  2. <Version>: Defines the version of your package, following semantic versioning (e.g., 1.0.0). This version appears in NuGet package listings and helps users identify updates or specific versions to install. Currently, we are not going to use this, due to our version being created in the GitHub Action.

  3. <Authors>: Specifies the package author(s). This can be a single name or a list of contributors and helps users know who created and maintains the package.

  4. <PackageDescription>: Provides a short description of your package. This text is displayed on nuget.org, where users can see what your package does.

  5. <PackageLicenseExpression>: Indicates the license type for your package, such as MIT, Apache-2.0, or GPL. NuGet uses this to show the licensing terms, which is important for clarity regarding the legal usage.

  6. <PackageTags>: These tags help improve discoverability on NuGet. For example, tagging it as Loggers, it makes it easier for users to find your package when they search for logging-related functionality.

  7. <RepositoryUrl>: This is the URL for the repository where the package source is hosted, often on GitHub. This allows users to view the source code, file issues, or contribute to the project.

When you package and publish the project to NuGet, these metadata values are included in the .nuspec file (the package manifest) and displayed on the package’s page on nuget.org. Don’t worry if some of this information does not showcase if you publish your package in the GitHub package repository, from my experience it tends to not show some of the metadata. Regardless, it’s interesting and important to have such metadata.

Here’s an example of how your metadata can look like in nuget.org:

Resources

Here’s the source code for this post.

If you are more of a visual learner, I’ve created a video to showcase the tutorial explained here.