Building a NuGet Package Publishing Pipeline

Publishing a NuGet package manually works for a one-off release, but any library that's actively maintained needs an automated pipeline. This article walks through setting up a complete build-test-pack-publish workflow using GitHub Actions.

Package Metadata

Start with proper metadata in your .csproj. NuGet.org uses this to display your package:

config.xml
<PropertyGroup>
  <PackageId>MyCompany.MyLibrary</PackageId>
  <Description>A brief description of what this library does.</Description>
  <Authors>Your Name</Authors>
  <PackageLicenseExpression>MIT</PackageLicenseExpression>
  <PackageProjectUrl>https://github.com/mycompany/mylibrary</PackageProjectUrl>
  <RepositoryUrl>https://github.com/mycompany/mylibrary</RepositoryUrl>
  <PackageTags>dotnet;utilities</PackageTags>
  <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
  <None Include="../../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

The PackageReadmeFile property tells NuGet to include your README in the package and display it on NuGet.org. It's one of the most impactful things you can do for discoverability.

Local Packing and Testing

Before automating, verify your package builds correctly:

terminal
dotnet pack src/MyLibrary/MyLibrary.csproj -c Release -o ./nupkgs

Inspect the resulting .nupkg file (it's just a zip) to check that the right assemblies and metadata are included. You can also push to a local feed for testing:

terminal
dotnet nuget add source ./nupkgs --name local
dotnet add package MyCompany.MyLibrary --source local

The CI/CD Workflow

Here's a complete GitHub Actions workflow that builds, tests, packs, and publishes:

.github/workflows/nuget-publish.yml
name: NuGet Publish

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Extract version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build -c Release --no-restore -p:Version=${{ steps.version.outputs.VERSION }}

      - name: Test
        run: dotnet test -c Release --no-build

      - name: Pack
        run: dotnet pack -c Release --no-build -p:PackageVersion=${{ steps.version.outputs.VERSION }} -o ./nupkgs

      - name: Push to NuGet
        run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate

The workflow triggers on version tags (e.g., v1.2.3). It extracts the version number from the tag and passes it through to the build and pack steps. The --skip-duplicate flag prevents failures if you accidentally re-push a version.

Publishing Pre-Release Packages

For pre-release testing, publish packages from pull requests or development branches to a GitHub Packages feed:

config.yaml
  publish-prerelease:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Build and pack
        run: |
          VERSION="0.0.0-preview.$(date +%Y%m%d%H%M%S)"
          dotnet pack -c Release -p:PackageVersion=$VERSION -o ./nupkgs

      - name: Push to GitHub Packages
        run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.GITHUB_TOKEN }} --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json

This gives consumers a way to test unreleased changes without waiting for a formal release.

Signing Packages

For trusted packages, sign them before publishing:

terminal
dotnet nuget sign ./nupkgs/*.nupkg \
    --certificate-path cert.pfx \
    --certificate-password $CERT_PASSWORD \
    --timestamper http://timestamp.digicert.com

NuGet.org validates signatures and marks signed packages in the UI. The timestamper ensures the signature remains valid after the certificate expires.

Multi-Targeting

If your library supports multiple frameworks, specify them in the project file:

config.xml
<PropertyGroup>
  <TargetFrameworks>net8.0;net9.0;netstandard2.0</TargetFrameworks>
</PropertyGroup>

The dotnet pack command automatically includes all target framework assemblies in the package. Consumers get the best matching version for their project.

API Key Security

Never commit your NuGet API key. Store it as a GitHub repository secret (NUGET_API_KEY). On NuGet.org, scope your API keys to specific packages with push-only permissions, and set an expiry date. Rotate keys regularly.

Summary

A NuGet publishing pipeline removes human error from the release process. Tag a commit, and the package is built, tested, packed, and published automatically. Use pre-release feeds for testing, sign your packages for trust, and always let CI handle the actual publish step.