Post

GitHub DevOps — Part 3: Build and Publish NuGet Packages with GitHub Actions

GitHub DevOps — Part 3: Build and Publish NuGet Packages with GitHub Actions

Series Overview

  1. Multiple GitHub Accounts with SSH — Configure SSH for personal and work accounts
  2. Semantic Versioning with GitVersion — Automated versioning using GitFlow
  3. GitHub Actions Workflows (this article) — Build, version, and publish NuGet packages

What We’re Building

In this article, we’ll create a complete CI/CD pipeline that:

  1. Builds a .NET class library on every push
  2. Calculates the version number automatically using GitVersion (from Part 2)
  3. Packs it as a NuGet package
  4. Publishes it to GitHub’s NuGet package registry
  5. Creates a GitHub Release with the package attached

The entire pipeline runs on GitHub Actions with no manual version management.

Step 1: Create the Project

1
2
3
4
5
6
7
8
mkdir Greetings.Nuget.Demo
cd Greetings.Nuget.Demo
git init --initial-branch=main
dotnet new classlib -f net10.0 -o src/Greetings.Nuget.Demo
rm src/Greetings.Nuget.Demo/Class1.cs
dotnet new gitignore
dotnet new sln
dotnet sln add $(find . -name "*.csproj")

Step 2: Add the Library Code

Create src/Greetings.Nuget.Demo/Greetings.cs:

1
2
3
4
5
6
7
8
namespace Greetings.Nuget.Demo;

public class Greetings
{
    public void SayHello() => Console.WriteLine("Hello from Greetings.Nuget.Demo");
    public void SayGoodMorning() => Console.WriteLine("Good morning from Greetings.Nuget.Demo");
    public void SayGreetings() => Console.WriteLine("Greetings from Greetings.Nuget.Demo");
}

Step 3: Configure NuGet Package Metadata

Update src/Greetings.Nuget.Demo/Greetings.Nuget.Demo.csproj:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <!-- NuGet metadata -->
    <PackageId>Greetings.Nuget.Demo</PackageId>
    <Authors>Your Name</Authors>
    <Company>Your Company</Company>
    <Description>A demo NuGet package for greeting messages</Description>
    <RepositoryUrl>https://github.com/your-org/Greetings.Nuget.Demo.git</RepositoryUrl>
    <PackageTags>Demo;NuGet;Greetings</PackageTags>
    <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
  </PropertyGroup>

</Project>

Update Authors, Company, and RepositoryUrl to match your GitHub account.

Step 4: Set Up GitVersion

Initialise GitVersion with GitFlow and continuous delivery mode (see Part 2 for details):

1
dotnet-gitversion init

Select: Getting started wizardGitFlowContinuous delivery modeSave and exit.

This creates GitVersion.yml:

1
2
3
4
5
mode: ContinuousDelivery
branches: {}
ignore:
  sha: []
merge-message-formats: {}

Step 5: Create the GitHub Actions Workflow

Create .github/workflows/build-and-publish.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
name: Build & Publish NuGet

on:
  push:
    branches: [main]
    paths-ignore:
      - "**/*.md"

env:
  PROJECT_PATH: "src/Greetings.Nuget.Demo/"
  NUGET_REGISTRY: "https://nuget.pkg.github.com/$/index.json"

permissions:
  contents: write
  packages: write

jobs:
  build:
    name: Build & Version
    runs-on: ubuntu-latest
    outputs:
      version: $
      commits: $

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history required for GitVersion

      - name: Install GitVersion
        uses: gittools/actions/gitversion/setup@v3
        with:
          versionSpec: '6.x'

      - name: Calculate version
        uses: gittools/actions/gitversion/execute@v3
        id: gitversion

      - name: Display version
        run: |
          echo "SemVer: $"
          echo "Commits since last version: $"

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Build and pack
        run: >
          dotnet pack $
          -p:Version='$'
          -c Release

      - name: Upload package artifact
        uses: actions/upload-artifact@v4
        with:
          name: nuget-package
          path: $bin/Release/*.nupkg

  release:
    name: Publish & Release
    runs-on: ubuntu-latest
    needs: build
    if: needs.build.outputs.commits > 0

    steps:
      - name: Download package
        uses: actions/download-artifact@v4
        with:
          name: nuget-package
          path: package

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Add GitHub NuGet source
        run: >
          dotnet nuget add source
          --username $
          --password $
          --store-password-in-clear-text
          --name github
          "$"

      - name: Push to GitHub Packages
        run: >
          dotnet nuget push package/*.nupkg
          --api-key $
          --source github

      - name: Create GitHub Release
        uses: ncipollo/release-action@v1
        with:
          tag: $
          name: Release $
          artifacts: "package/*"
          token: $

Understanding the Workflow

Two Jobs: Build and Release

The workflow is split into two jobs for clarity and safety:

Build job:

  1. Checks out the full Git history (fetch-depth: 0 — required for GitVersion)
  2. Installs and runs GitVersion to calculate the version
  3. Builds and packs the NuGet package with the calculated version
  4. Uploads the .nupkg as an artifact

Release job:

  1. Only runs if there are new commits (commitsSinceVersionSource > 0)
  2. Downloads the package artifact
  3. Pushes it to GitHub’s NuGet package registry
  4. Creates a tagged GitHub Release with the package attached

Authentication with GITHUB_TOKEN

The workflow uses secrets.GITHUB_TOKEN — a token that GitHub automatically creates for each workflow run. It requires the packages: write and contents: write permissions, declared at the top of the workflow.

Troubleshooting: If the token fails to push the package, create a Personal Access Token (classic) with the write:packages scope. Add it as a repository secret (e.g., NUGET_TOKEN) and replace $ with $.

Version Flow

The version number flows through the pipeline:

1
Git history → GitVersion → dotnet pack -p:Version=X.Y.Z → NuGet push → GitHub Release tag

No manual version bumping anywhere.

Step 6: Commit and Push

1
2
3
git add .
git commit -m 'initial setup with CI/CD'
git push -u origin main

The workflow triggers automatically. Check the Actions tab in your repository to see it run.

Consuming the Package

Once published, other projects can consume your package from GitHub’s registry.

Add the GitHub NuGet Source

1
2
3
4
5
6
dotnet nuget add source \
  --username YOUR_USERNAME \
  --password YOUR_GITHUB_TOKEN \
  --store-password-in-clear-text \
  --name github \
  "https://nuget.pkg.github.com/YOUR_ORG/index.json"

Install the Package

1
dotnet add package Greetings.Nuget.Demo

Use in a NuGet.config File

For team projects, add a NuGet.config at the solution root:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="github" value="https://nuget.pkg.github.com/YOUR_ORG/index.json" />
  </packageSources>
  <packageSourceCredentials>
    <github>
      <add key="Username" value="YOUR_USERNAME" />
      <add key="ClearTextPassword" value="%GITHUB_TOKEN%" />
    </github>
  </packageSourceCredentials>
</configuration>

Extending the Workflow

Add Tests

Insert a test step before packing:

1
2
- name: Run tests
  run: dotnet test --configuration Release --no-build

Publish to nuget.org

Add a step to push to the public NuGet registry:

1
2
3
4
5
- name: Push to nuget.org
  run: >
    dotnet nuget push package/*.nupkg
    --api-key $
    --source https://api.nuget.org/v3/index.json

Build on Pull Requests

Add PR triggers for validation without publishing:

1
2
3
4
5
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Then gate the release job:

1
if: github.ref == 'refs/heads/main' && needs.build.outputs.commits > 0

Key Takeaways

  1. GitVersion + GitHub Actions = automatic versioning. No manual version bumping, ever.
  2. fetch-depth: 0 is non-negotiable. GitVersion needs the full Git history to calculate versions correctly.
  3. Split build and release into separate jobs. Build on every push, release only when there are new commits on main.
  4. GITHUB_TOKEN handles authentication. Declare the required permissions in the workflow file.
  5. The version flows end-to-end. From Git history → calculated version → package version → release tag.

Series Conclusion

Over this 3-part series we’ve built a complete GitHub DevOps foundation:

  1. Multiple accounts — SSH aliases for seamless multi-account workflows
  2. Automatic versioning — GitVersion derives version numbers from your branching strategy
  3. CI/CD pipelines — GitHub Actions builds, versions, and publishes packages with zero manual intervention

Together, these form the backbone of a professional .NET development workflow on GitHub.

References

This post is licensed under CC BY 4.0 by the author.