How to publish a NuGet package with GitHub Actions in 2026
A few years ago, I wrote about publishing a NuGet package with CircleCI. The essential steps have not changed much: restore the repository, build and test the code, create a package, and publish it when a release is ready.
The surrounding tooling has changed considerably.
A modern GitHub Actions workflow can derive package versions from Git tags, annotate failed tests directly in the workflow UI, collect coverage, avoid rebuilding during publication, and authenticate to NuGet.org without storing a long-lived API key.
The interesting part is no longer the final dotnet nuget push command. It is building a trustworthy path from a source commit to the exact package eventually published.
In this post, we will build that path using:
- MinVer for tag-based package versions;
- reproducible builds and Source Link information;
- NuGet and .NET tool caching;
dotnet formatfor style validation;- GitHub-native test diagnostics;
dotnet-coveragefor coverage collection;- workflow artifacts to pass packages between jobs;
- NuGet trusted publishing through GitHub OIDC.
The starting repository
I will assume the repository already contains a library and a test project:
src/
SampleLibrary/
tests/
SampleLibrary.Tests/
.github/
workflows/
.config/
dotnet-tools.json
Directory.Build.props
Directory.Packages.props
global.json
SampleLibrary.slnx
The project names are intentionally generic. The same workflow can be adapted to a library containing one package or to a repository that packs several related projects.
Deriving versions from Git tags
The package version should come from the repository history rather than from a value duplicated in the workflow.
MinVer integrates with MSBuild and derives a version from Git tags. Add it as a private dependency:
<ItemGroup>
<PackageReference Include="MinVer" PrivateAssets="all" />
</ItemGroup>
With Central Package Management, its version can live in Directory.Packages.props:
<ItemGroup>
<PackageVersion Include="MinVer" Version="7.0.0" />
</ItemGroup>
Configure the convention in Directory.Build.props:
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
<MinVerDefaultPreReleaseIdentifiers>preview</MinVerDefaultPreReleaseIdentifiers>
<MinVerAutoIncrement>minor</MinVerAutoIncrement>
</PropertyGroup>
A tag such as v1.2.0 produces package version 1.2.0. Commits after that tag receive a derived prerelease version until the next release tag is created.
MinVer needs the repository tags, so the workflow must fetch the full history:
- uses: actions/checkout@v6
with:
fetch-depth: 0
A shallow checkout is faster, but it removes the information the versioning strategy depends on.
Configuring reproducible package builds
Versioning is only one part of producing a trustworthy package. The build should also preserve enough information to trace the compiled assembly back to its source and to debug it after installation.
The sample references DotNet.ReproducibleBuilds. Together with the .NET SDK, the package applies the wider build conventions needed for reproducibility across development machines and CI environments.
Keep the package next to the MinVer convention in Directory.Build.props:
<Project>
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
<MinVerDefaultPreReleaseIdentifiers>preview</MinVerDefaultPreReleaseIdentifiers>
<MinVerAutoIncrement>minor</MinVerAutoIncrement>
<DebugType>portable</DebugType>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNet.ReproducibleBuilds"
Version="2.0.5"
PrivateAssets="all" />
<PackageReference Include="MinVer"
Version="7.0.0"
PrivateAssets="all" />
</ItemGroup>
</Project>
We only override the symbol-related settings deliberately:
DotNet.ReproducibleBuildsdefaults to embedded symbols;DebugType=portablecreates a separate portable PDB;IncludeSymbolsandSymbolPackageFormat=snupkgplace that PDB in the symbol package accepted by NuGet.org.
The SDK and DotNet.ReproducibleBuilds take care of the other important build settings, including deterministic compilation, CI path normalization, and repository metadata. Repeating those properties in the project would obscure which component owns the configuration.
Source Link support has been included in the .NET SDK since .NET 8. When the repository is built with a recent SDK, the library can target an earlier runtime without needing a separate Microsoft.SourceLink.GitHub package. The SDK uses the repository information from the checkout and embeds the Source Link map in the portable PDB. Consumers can then step into the exact source revision represented by the package.
Package-specific metadata remains in the library project itself:
<PropertyGroup>
<PackageId>Example.SampleLibrary</PackageId>
<Description>A short description of the library.</Description>
<Authors>Your Name</Authors>
<PackageTags>dotnet;sample-library</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/example/SampleLibrary</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EnablePackageValidation>true</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<None Include="../../README.md"
Pack="true"
PackagePath="/" />
</ItemGroup>
GenerateDocumentationFile includes the XML documentation alongside the assembly. EnablePackageValidation makes the SDK inspect the package produced by the build. It checks that the package exposes a consistent public API across its target frameworks and that its assemblies are compatible with the frameworks they claim to support. Once a stable version has been published, PackageValidationBaselineVersion can also compare the new package with that earlier release and report source- or binary-incompatible API changes before publication.
Metadata such as the package description, tags, README, license, and repository URL is not build machinery, but it is part of publishing a usable package. NuGet.org surfaces it to consumers, while the repository information and portable symbols connect the package back to the source that produced it.
Declaring local tools
We will use dotnet-coverage as a repository-local tool rather than installing it globally in every workflow run:
dotnet new tool-manifest
dotnet tool install dotnet-coverage
The dotnet new tool-manifest command creates .config/dotnet-tools.json. Keeping the manifest in the repository makes the tool version explicit and lets contributors run the same command locally.
Packages used by the setup
The publishing setup adds three dependencies that are independent of the library’s actual behavior:
MinVerderives package versions from Git tags during the MSBuild process;DotNet.ReproducibleBuildsapplies reproducible-build and repository-information conventions;GitHubActionsTestLoggerturns test failures into GitHub Actions annotations.
dotnet-coverage serves the same supporting role, but it is installed as a repository-local .NET tool rather than as a PackageReference. The test framework and test SDK are deliberately omitted because they depend on the application’s testing choices rather than on the publishing setup. None of the packages above is part of the library’s runtime API, and build-only dependencies such as MinVer and DotNet.ReproducibleBuilds are marked with PrivateAssets="all" so they do not flow to consumers.
Creating the workflow
Create .github/workflows/publish.yml.
The workflow validates every pull request and every push to the default branch. Publishing only happens when a GitHub release is published:
name: Build and publish
on:
push:
branches: [master]
pull_request:
branches: [master]
release:
types: [published]
concurrency:
group: nuget-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'release' }}
Cancelling an older CI run is useful when a branch receives another commit. A release run is different: once publication starts, a newer run should not silently cancel it.
Caching packages and tools
Restoring dependencies is necessary for correctness, but downloading the same packages on every run is not.
Cache the global NuGet package directory and build the key from files that can change the dependency graph:
- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: >-
${{ runner.os }}-nuget-
${{ hashFiles(
'global.json',
'NuGet.config',
'Directory.Packages.props',
'Directory.Build.props',
'.config/dotnet-tools.json',
'**/*.csproj'
) }}
restore-keys: |
${{ runner.os }}-nuget-
Both ordinary package restore and local tool restore use NuGet packages, so they benefit from the same cache.
The cache remains an optimization. The workflow must still restore explicitly and work correctly after a cache miss:
- name: Restore tools
run: dotnet tool restore
- name: Restore packages
run: dotnet restore
Formatting, building, and testing
Run formatting as its own validation step:
- name: Verify formatting
run: dotnet format --verify-no-changes --no-restore
Then build once in Release configuration:
- name: Build
run: >
dotnet build
--configuration Release
--no-restore
--warnaserror
The explicit restore and build phases keep later steps from doing hidden work. Tests and packing will reuse this build output.
Add GitHubActionsTestLogger to the test projects:
<ItemGroup>
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
</ItemGroup>
The logger creates workflow annotations for failed tests. Run the tests through dotnet-coverage so one test execution produces both diagnostics and coverage:
- name: Test and collect coverage
run: >
dotnet tool run dotnet-coverage collect
"dotnet test --configuration Release --no-build --logger GitHubActions"
--output-format cobertura
--output ./artifacts/coverage.cobertura.xml
Upload the report so it remains available after the runner is discarded:
- name: Upload coverage report
uses: actions/upload-artifact@v7
with:
name: coverage
path: ./artifacts/coverage.cobertura.xml
if-no-files-found: error
An external coverage service can be added later. It is not required to build a dependable package-publishing workflow.
Packing the library once
After validation succeeds, create the NuGet package:
- name: Pack
run: >
dotnet pack
./src/SampleLibrary/SampleLibrary.csproj
--configuration Release
--no-build
--output ./artifacts/packages
MinVer supplies the package version from the checked-out Git history. The project configuration supplies the package metadata, reproducible-build conventions, portable symbols, Source Link information, and package validation.
Upload both package files as a workflow artifact:
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: nuget-packages
path: |
./artifacts/packages/*.nupkg
./artifacts/packages/*.snupkg
if-no-files-found: error
The workflow now has a concrete handoff point:
source commit
↓
restore → format → build → test → coverage → pack
↓
workflow artifact
↓
publish to NuGet.org
The publishing job will download these files. It will not rebuild or repack the repository.
Validating the release
A GitHub release provides a deliberate publication boundary, but the workflow should still verify that its metadata is coherent.
The publishing job starts only for release events and depends on the successful build job:
publish:
name: Publish
if: github.event_name == 'release'
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
Before publishing, validate the tag:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate release metadata
env:
RELEASE_TAG: $
RELEASE_IS_PRERELEASE: $
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Unsupported release tag: $RELEASE_TAG" >&2
exit 1
fi
release_commit="$(git rev-parse "$RELEASE_TAG^{commit}")"
head_commit="$(git rev-parse HEAD)"
if [[ "$release_commit" != "$head_commit" ]]; then
echo "The release tag does not point to the checked-out commit." >&2
exit 1
fi
if [[ "$RELEASE_TAG" == *-* ]]; then
test "$RELEASE_IS_PRERELEASE" = "true"
else
test "$RELEASE_IS_PRERELEASE" = "false"
fi
This prevents a stable tag from being published as a GitHub prerelease, a prerelease tag from being presented as stable, or a release from unexpectedly targeting another commit.
Downloading the validated package
Download the package produced by the build job:
- name: Download packages
uses: actions/download-artifact@v8
with:
name: nuget-packages
path: ./artifacts
The files in ./artifacts are the files that already passed through the validation job.
Publishing without a long-lived API key
The traditional setup stores a NuGet API key as a GitHub secret. That works, but the credential is long-lived and must be protected and rotated.
NuGet trusted publishing uses GitHub’s OpenID Connect identity instead. NuGet.org trusts a specific repository and workflow, and the workflow exchanges its identity token for a short-lived credential.
After configuring the trusted publishing policy on NuGet.org, authenticate with nuget/login:
- name: Login to NuGet.org
id: nuget-login
uses: nuget/login@v1
with:
user: YourNuGetUser
The publishing job needs id-token: write so the action can request the OIDC token. No permanent NuGet API key is stored in the repository.
Push the downloaded package:
- name: Publish to NuGet.org
run: >
dotnet nuget push
"./artifacts/*.nupkg"
--source https://api.nuget.org/v3/index.json
--api-key "$"
--skip-duplicate
dotnet nuget push still calls the argument --api-key, but the value is the temporary credential returned by the login action.
Attaching the same files to the GitHub release
The packages can also be attached to the release that triggered the workflow:
- name: Attach packages to the GitHub release
env:
GH_TOKEN: $
run: |
gh release upload "$" \
./artifacts/*.nupkg \
./artifacts/*.snupkg \
--repo "$"
NuGet.org and the GitHub release now receive the same package files.
The complete workflow
Putting everything together gives us:
name: Build and publish
on:
push:
branches: [master]
pull_request:
branches: [master]
release:
types: [published]
concurrency:
group: nuget-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'release' }}
jobs:
build:
name: Build, test, and pack
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: >-
${{ runner.os }}-nuget-
${{ hashFiles(
'global.json',
'NuGet.config',
'Directory.Packages.props',
'Directory.Build.props',
'.config/dotnet-tools.json',
'**/*.csproj'
) }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore tools
run: dotnet tool restore
- name: Restore packages
run: dotnet restore
- name: Verify formatting
run: dotnet format --verify-no-changes --no-restore
- name: Build
run: >
dotnet build
--configuration Release
--no-restore
--warnaserror
- name: Test and collect coverage
run: >
dotnet tool run dotnet-coverage collect
"dotnet test --configuration Release --no-build --logger GitHubActions"
--output-format cobertura
--output ./artifacts/coverage.cobertura.xml
- name: Pack
run: >
dotnet pack
./src/SampleLibrary/SampleLibrary.csproj
--configuration Release
--no-build
--output ./artifacts/packages
- name: Upload coverage report
uses: actions/upload-artifact@v7
with:
name: coverage
path: ./artifacts/coverage.cobertura.xml
if-no-files-found: error
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: nuget-packages
path: |
./artifacts/packages/*.nupkg
./artifacts/packages/*.snupkg
if-no-files-found: error
publish:
name: Publish
if: github.event_name == 'release'
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate release metadata
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Unsupported release tag: $RELEASE_TAG" >&2
exit 1
fi
release_commit="$(git rev-parse "$RELEASE_TAG^{commit}")"
head_commit="$(git rev-parse HEAD)"
if [[ "$release_commit" != "$head_commit" ]]; then
echo "The release tag does not point to the checked-out commit." >&2
exit 1
fi
if [[ "$RELEASE_TAG" == *-* ]]; then
test "$RELEASE_IS_PRERELEASE" = "true"
else
test "$RELEASE_IS_PRERELEASE" = "false"
fi
- name: Download packages
uses: actions/download-artifact@v8
with:
name: nuget-packages
path: ./artifacts
- name: Login to NuGet.org
id: nuget-login
uses: nuget/login@v1
with:
user: YourNuGetUser
- name: Publish to NuGet.org
run: >
dotnet nuget push
"./artifacts/*.nupkg"
--source https://api.nuget.org/v3/index.json
--api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}"
--skip-duplicate
- name: Attach packages to the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${{ github.event.release.tag_name }}" \
./artifacts/*.nupkg \
./artifacts/*.snupkg \
--repo "${{ github.repository }}"
Publishing a release
The human part of the release process remains small:
- Merge the intended changes into the default branch.
- Create a tag such as
v1.2.0. - Create and publish a GitHub release for that tag.
- Let the workflow validate the release and publish the package.
A prerelease tag can include a suffix:
v1.3.0-preview.1
It should be published as a GitHub prerelease. The workflow rejects a mismatch between the tag and the release type.
Optional: manually publishing a preview package
Sometimes it is useful to consume a package from a real feed before creating a public release.
Add workflow_dispatch to the triggers. A manually dispatched run has no exact release tag, so MinVer produces a derived prerelease version from the repository history.
GitHub Packages can be used as the preview destination:
preview:
name: Publish preview
if: github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download packages
uses: actions/download-artifact@v8
with:
name: nuget-packages
path: ./artifacts
- name: Publish to GitHub Packages
run: |
dotnet nuget push "./artifacts/*.nupkg" \
--source "https://nuget.pkg.github.com/$/index.json" \
--api-key "$" \
--skip-duplicate
This keeps manual previews separate from public NuGet.org releases. It also preserves the same build-once rule: the preview job downloads the package produced by the validation job instead of packing it again.
The final publishing command is still straightforward. Most of the value comes from the path leading to it: Git tags determine the version; the SDK and DotNet.ReproducibleBuilds establish reproducible-build and Source Link conventions; formatting, compilation, tests, coverage, package validation, and portable symbols validate and describe the source; and the package is created only once.
That exact .nupkg and .snupkg pair is passed between jobs as a workflow artifact, published to NuGet.org with a short-lived OIDC credential, and attached to the GitHub release. Manual preview publishing follows the same build-once rule but targets GitHub Packages instead. The result is a release process in which every published file can be traced back to the commit that produced and validated it.
What changed since the CircleCI workflow
The older workflow and this one solve the same problem, but the defaults have moved:
| Earlier approach | 2026 approach |
|---|---|
| CircleCI | GitHub Actions |
| Explicit version calculation in CI | MinVer integrated with MSBuild |
| Basic package output | Reproducible builds, Source Link, portable symbols, and package validation |
| Permanent NuGet API key | Trusted publishing through GitHub OIDC |
| Test output in logs | GitHub-native test annotations |
| Coverage as an external addition | Coverage produced by the workflow |
| Rebuild during publication | Package once and publish the same artifact |
| Download every dependency on every run | NuGet package and tool caching |
| Tag alone as the release boundary | Validated GitHub release |
| GitHub Packages as a default second destination | Optional manual preview feed |
Recap
In this post, we started from an ordinary library repository and built a complete GitHub Actions release path around it. We used MinVer to derive versions from Git tags, configured reproducible packages with Source Link and portable symbols, pinned local tooling, cached restores, and validated formatting, compilation, tests, coverage, and package compatibility.
We then packed the library once, transferred the resulting files between jobs as a workflow artifact, validated the GitHub release, and published to NuGet.org through trusted publishing instead of a permanent API key. Finally, we reused the same package files for the GitHub release and added an optional manual preview path through GitHub Packages.
That is the workflow I have gradually converged on while modernising my own open-source .NET projects.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!