Automating NuGet Package Publishing with GitHub Actions
Publishing a NuGet package manually sounds simple enough. Bump the version. Run dotnet pack. Run dotnet nuget push. Done.
Until it isn't.
When you maintain one package, the manual workflow is manageable. When you maintain five, or ten, or you're on a team where multiple people could cut releases -- the process becomes a liability. Someone forgets to update the version. Someone pushes from a dirty working tree. Someone accidentally publishes a debug build. These mistakes are embarrassing at best and breaking at worst.
That's why setting up a github actions nuget publish pipeline is one of the smartest investments you can make for a .NET library. You get consistency, traceability, and confidence in every release -- free of charge. Once the workflow is wired up, every push to a release tag triggers the pipeline automatically. No one needs to remember any steps. If you're still doing this by hand today, Publishing to NuGet.org describes exactly what this workflow replaces. For the full picture of building a package in the first place, see The Complete Guide to Creating NuGet Packages in .NET.
This article walks you through building a production-ready ci cd nuget package workflow from scratch -- complete YAML, version injection from git tags, multi-job safety gates, and the common errors you'll hit along the way.
Workflow Overview
Before diving into YAML, it helps to understand the shape of the pipeline at a high level.
A well-structured github actions nuget publish workflow has five stages that run in sequence:
- Trigger -- a push to a tag matching
v*.*.*kicks off the workflow - Build -- restore dependencies, compile in Release configuration
- Test -- run the full test suite before packing anything
- Pack -- produce the
.nupkgfile with the version derived from the tag - Push -- upload to NuGet.org using a stored API key
The key insight is that packing and pushing happen last, after validation. If your tests fail, you never produce a package. If the package doesn't get produced, nothing gets pushed. The pipeline enforces quality by design.
This guide splits the work across two jobs: build (steps 1-4) and publish (step 5). The publish job only runs if build succeeds and only when triggered by a tag push. That separation between building and shipping is what makes the ci cd nuget package approach reliable.
Setting Up the GitHub Secret for NuGet API Key
Before writing a single line of YAML, you need a NuGet API key stored securely in GitHub.
Here's how to get one from NuGet.org:
- Sign in at nuget.org and navigate to your account settings
- Go to API Keys and create a new key
- Scope it to specific packages using glob patterns, or use
*for all packages you own - Copy the key immediately -- you won't see it again
Now store it in GitHub:
- Navigate to your repository on GitHub
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Name it
NUGET_API_KEY - Paste the API key value and save
That's it. The secret is now available in workflows as ${{ secrets.NUGET_API_KEY }}. GitHub masks it in log output automatically -- it will never appear in plaintext, even if a step echoes environment variables.
One naming note: the secret name is case-sensitive. NUGET_API_KEY and NuGet_Api_Key are treated as different secrets. Use exactly NUGET_API_KEY to match the workflow YAML in this article.
Preparing Your .csproj for CI-Friendly Version Injection
Before writing the workflow, make sure your project file is structured to accept a version from the outside. The right property to use is VersionPrefix -- it gives you a sensible default for local builds while letting CI override it via -p:PackageVersion at pack time.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>MyAwesomeLibrary</PackageId>
<!-- Fallback for local development builds -->
<!-- CI overrides this via -p:PackageVersion at pack time -->
<VersionPrefix>1.0.0</VersionPrefix>
<Authors>Your Name</Authors>
<Description>A short description of what this library does.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/yourusername/my-awesome-library</RepositoryUrl>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
The CI pipeline overrides the version at pack time using -p:PackageVersion=1.2.3. The VersionPrefix in the file just gives dotnet pack a fallback when running locally.
Do not hardcode a full <Version> element here. If you do, the -p:PackageVersion flag will still override it -- but hardcoding it invites the mistake of committing a version bump alongside unrelated code changes. VersionPrefix keeps the local experience clean while letting the git tag drive the real version.
The Complete GitHub Actions Workflow
Here's the full, production-ready YAML for automating NuGet package publishing with GitHub Actions. Paste this into .github/workflows/publish.yml in your repository.
name: Publish NuGet Package
on:
push:
tags:
- 'v*.*.*'
env:
DOTNET_VERSION: '8.x'
PROJECT_PATH: 'src/MyLibrary/MyLibrary.csproj'
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Run tests
run: dotnet test --no-build -c Release --verbosity normal
- name: Extract version from tag
id: version
run: |
# Strip the leading 'v' prefix: v1.2.3 -> 1.2.3
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Packaging version: $VERSION"
- name: Pack NuGet package
run: |
dotnet pack ${{ env.PROJECT_PATH }}
--no-build
-c Release
-p:PackageVersion=${{ steps.version.outputs.version }}
--output ./artifacts
- name: List artifacts
run: ls -la ./artifacts/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: nuget-package
path: ./artifacts/*.nupkg
retention-days: 7
publish:
name: Publish to NuGet.org
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
environment: nuget-release
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: nuget-package
path: ./artifacts
- name: List packages to publish
run: ls -la ./artifacts/
- name: Push to NuGet.org
run: |
dotnet nuget push ./artifacts/*.nupkg
--api-key ${{ secrets.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json
--skip-duplicate
A few decisions worth explaining in this github actions nuget publish workflow.
Two jobs, not one. The build job runs tests and produces the artifact. The publish job downloads that artifact and pushes it. This means the build evidence -- the test run, the actual .nupkg -- is the exact same artifact that gets shipped. No risk of a second build producing a different output.
--no-restore and --no-build flags. Each step builds on the previous one. dotnet build --no-restore skips re-running restore. dotnet pack --no-build skips re-running the build. This avoids redundant work and guarantees you're packing exactly what was tested.
environment: nuget-release. GitHub Environments let you define protection rules for deployment targets. Configure this environment in your repository settings to require approvals from specific reviewers before the publish job runs. It's optional, but it adds a human gate between tagging and going live -- strongly recommended for packages with real users.
Triggering on Git Tags
The workflow triggers on push events matching the pattern v*.*.*. Only tags like v1.0.0, v2.3.1, or v1.0.0-beta.1 will fire the pipeline. A regular commit push to main does nothing.
To cut a release, you create and push a tag:
# Commit your changes first
git add src/
git commit -m "feat: add new feature for v1.2.0 release"
# Tag the commit
git tag v1.2.0
# Push the commit and the tag separately
git push origin main
git push origin v1.2.0
When GitHub receives that tag push, the workflow starts automatically.
One important detail: the pattern v*.*.* requires at least two dots after the v -- a minimum of three parts. A tag like v1 or v1.0 will not trigger the workflow, but pre-release tags like v1.0.0-beta.1 will, since * matches any characters. This is intentional -- it enforces semantic versioning discipline at the trigger level, before any code runs.
Reading the Version from the Git Tag
The tag name (v1.2.3) flows into the workflow as the environment variable GITHUB_REF_NAME. The problem is that NuGet versions don't start with a v -- dotnet pack expects 1.2.3, not v1.2.3.
The version extraction step handles this with a simple Bash substitution:
# Strip the leading 'v' from the tag name
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
The ${GITHUB_REF_NAME#v} syntax removes the shortest prefix matching v from the start of the string. So v1.2.3 becomes 1.2.3. The result is stored as a step output using the $GITHUB_OUTPUT mechanism and then consumed by the pack command:
dotnet pack src/MyLibrary/MyLibrary.csproj
--no-build
-c Release
-p:PackageVersion=${{ steps.version.outputs.version }}
--output ./artifacts
The -p:PackageVersion= flag overrides whatever version is set in the .csproj. The git tag becomes the single source of truth. No editing files before a release. No forgetting to bump a property. Tag it, push it, it ships with that exact version.
For a deeper look at how semantic versioning maps to NuGet versioning conventions -- including pre-release labels, build metadata, and range resolution -- see NuGet Versioning and SemVer.
Validating Before Pushing
The --skip-duplicate flag on the push command deserves a specific explanation. NuGet.org rejects an attempt to push a package that already exists at the same version -- it responds with a 409 Conflict. Without --skip-duplicate, that response causes the workflow to fail with an error exit code.
With --skip-duplicate, a 409 Conflict is treated as a non-fatal skip. The push command exits successfully. This is useful in two scenarios:
- Retry scenarios: If the publish job fails partway through pushing multiple packages (symbol packages, for example) and you re-run it, already-published packages won't block the retry.
- Multi-package repositories: If your repo produces more than one
.nupkgin the same run, a partial publish won't leave subsequent packages stranded.
The ls -la ./artifacts/ line before the push is a simple but effective validation step. It prints every file that's about to be pushed. If you see unexpected files -- wrong package name, wrong version number, multiple packages when you expected one -- you know something went wrong before anything hits NuGet.org.
Uploading the artifacts in the build job with actions/upload-artifact@v4 also serves as a validation layer. The exact .nupkg that gets published is preserved in the Actions UI for 7 days. If something looks wrong after a release, you can download and inspect the artifact directly.
Multi-Job Workflow for Safety
The two-job structure (build → publish) provides a critical safety property: the publish job only runs if build and test succeed.
This is enforced by needs: build in the publish job definition:
publish:
name: Publish to NuGet.org
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
The needs: build directive means the publish job won't start until build completes successfully. If any test fails, the entire pipeline stops. Nothing gets published. The if: condition is defensive future-proofing -- if a workflow_dispatch or other trigger is ever added to this workflow, the publish job won't execute unless the event is a tag push. With the current tag-only trigger it is redundant, but it costs nothing and documents intent.
This pattern mirrors the discipline you'd apply to any deployment pipeline. A failing test suite might or might not block a PR merge (that's a different debate), but it absolutely should block a release. Having tests in CI before packing is foundational. For guidance on structuring your test suite, Testing Feature Slices in C#: Unit Tests, Integration Tests, and What to Test covers the approach in depth. If you're setting up xUnit for your library project specifically, xUnit in ASP.NET Core -- What You Need to Know to Start walks through the setup from scratch.
Common Errors and Fixes
Even a clean workflow hits a few predictable problems. Here's what to look for.
401 Unauthorized
The API key is wrong, expired, or missing. Verify the secret is named exactly NUGET_API_KEY in your repository settings -- the name is case-sensitive. If the key expired, regenerate it on NuGet.org and update the secret.
403 Forbidden
The API key doesn't have permission to push the package. On NuGet.org, keys can be scoped to specific package IDs using glob patterns. Check that the key's scope includes the PackageId you're pushing.
409 Conflict (without --skip-duplicate)
You're trying to push a version that already exists on NuGet.org. Package versions are immutable -- once published, they cannot be overwritten. Add --skip-duplicate to handle this gracefully, or make sure you're always tagging a new version.
Version format error
If the extracted version string contains unexpected characters, check your tag name. A tag like v1.0.0-rc.1 is valid for pre-releases. A tag like v1.0 only has two segments and will not trigger the workflow (the v*.*.* pattern requires three). Double-check the tag name if the workflow doesn't fire.
Package not found in ./artifacts/
The dotnet pack command ran but produced nothing in the expected folder. Verify that PROJECT_PATH matches the actual relative path to your .csproj. Also confirm that the project includes <PackageId> in its PropertyGroup -- projects without it may produce output at an unexpected path.
actions/setup-dotnet@v4 fails
Make sure DOTNET_VERSION matches a supported .NET SDK version. The 8.x value installs the latest patch of the .NET 8 SDK. Pin to a specific patch version like 8.0.404 if you need fully reproducible builds across workflow runs.
Wrapping Up
Setting up a github actions nuget publish workflow is a one-time investment that pays off every single release. Tag a commit. Push the tag. The pipeline handles the rest -- building, testing, packing, and pushing to NuGet.org with no manual intervention.
The approach covered here gives you a production-ready ci cd nuget package pipeline with clear separation between build and publish jobs, version injection from git tags, artifact preservation for inspection, and --skip-duplicate safety on the push. You can extend it further with GitHub Environments for manual approval gates, pre-release support via suffix tags, or symbol package publishing for better debugging support.
For a broader view of everything that goes into a well-structured NuGet package -- from project setup to metadata and packaging conventions -- start with The Complete Guide to Creating NuGet Packages in .NET. And if you want to understand the versioning strategy that pairs well with this tag-based workflow, NuGet Versioning and SemVer covers the full picture.
Automation makes releases boring. Boring releases are good releases.
Frequently Asked Questions
Can I trigger the github actions nuget publish workflow on every commit to main instead of on tags?
You can, but it's not recommended. Publishing a new package version on every commit forces you to bump the version constantly or risk 409 Conflict errors. Tags give you an explicit, intentional trigger -- you decide when a release is ready by creating a tag. Using tags keeps your NuGet version history clean and meaningful.
What happens if I push a tag by mistake and need to stop the publish?
If you catch it before the publish job finishes, cancel the workflow run from the GitHub Actions UI. If environment: nuget-release is configured with required reviewers, you can reject the deployment before the push executes. If the package was already published, you can unlist it on NuGet.org to hide it from search results -- but you cannot delete a published version.
Does this workflow support pre-release versions like 1.0.0-beta.1?
Yes. A tag like v1.0.0-beta.1 produces version 1.0.0-beta.1 after stripping the v prefix. NuGet.org accepts pre-release versions following SemVer 2.0.0 format. Consumers see pre-release versions only when they explicitly opt in -- they won't receive them through normal update channels.
How do I handle multiple packages in a single repository?
The dotnet nuget push ./artifacts/*.nupkg glob pushes every .nupkg in the folder. If you pack multiple projects to ./artifacts/, they all get pushed in one step. For more granular control -- separate versions per package, or separate publish gates -- use a matrix strategy to run each project's pack and push steps independently.
Why are there two jobs instead of running everything in one job?
Two jobs provide a hard dependency boundary: the publish job cannot run until the build job succeeds. In a single job, a failed test step would still block publishing -- but the artifact would never be uploaded, and you'd lose the ability to inspect what was built. The two-job approach also enables adding deployment protection rules via GitHub Environments on the publish job specifically, without affecting the build job.
Should I pin actions/setup-dotnet to a specific version?
Pinning to @v4 is the standard practice. It gets patch updates automatically, which is important for security fixes in the action itself. If you need strict reproducibility, you can pin to a full SHA like actions/setup-dotnet@a18d801b..., but that requires manual updates. For most library projects, @v4 is the right balance.
What is the environment: nuget-release setting and do I need it?
It's optional but highly recommended. A GitHub Environment lets you add protection rules -- like requiring approval from a maintainer -- before the publish job runs. Without it, every tag push immediately publishes to NuGet.org. With it, you get a manual gate between tagging and shipping. Set it up in Settings → Environments → New environment → Add required reviewers.

