How to publish a NuGet package with GitHub Actions in 2026

by Renato Golia

Audio version created with Paper2Audio.

Original source: https://renatogolia.com/2026/08/25/publish-nuget-package-with-github-actions-in-2026/

Listen on Paper2Audio

How to publish a NuGet package with GitHub Actions in 2026
Renato Golia, renatogolia dot com
Audio by Paper2Audio
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 U.I, collect coverage, avoid rebuilding during publication, and authenticate to NuGet dot org without storing a long-lived A.P.I 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 dot net tool caching;
- dotnet format for style validation;
- GitHub-native test diagnostics;
- dotnet-coverage for coverage collection;
- workflow artifacts to pass packages between jobs;
- NuGet trusted publishing through GitHub O.I.D.C.

The starting repository

I will assume the repository already contains a library and a test project:
Code summary: This file structure defines the layout of a starting repository for a .NET library. It organizes the codebase into a source directory for the SampleLibrary and a separate tests directory for SampleLibrary.Tests. It also includes GitHub workflow configurations, project-wide build and package properties, and tool configuration files to support a standardized build and deployment pipeline.
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 M.S.Build and derives a version from Git tags. Add it as a private dependency:
Code summary: This configuration snippet adds MinVer as a package reference to a project. By setting PrivateAssets to all, it ensures that the tool remains a build-time dependency used to derive package versions from Git tags without leaking the dependency into the final compiled output.
With Central Package Management, its version can live in Directory.Packages.props:
Code summary: This configuration snippet uses Central Package Management to define version 7.0.0 of the MinVer package, ensuring a consistent version is used across the project to facilitate deriving package versions from Git tags.
Configure the convention in Directory.Build.props:
: This configuration block defines the versioning conventions for MinVer, specifying that Git tags must start with the prefix v, that pre-release versions should be identified as preview, and that the minor version should be automatically incremented to derive the project version.
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:
Code summary: This GitHub Actions step uses actions/checkout@v6 to pull the repository source code. By setting fetch-depth to 0, it disables shallow cloning to retrieve the full commit history, which is required for the MinVer tool to correctly determine the package version based on repository tags.
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 dot net S.D.K, the package applies the wider build conventions needed for reproducibility across development machines and C.I environments.
Keep the package next to the MinVer convention in Directory.Build.props:
Code summary: Configuring reproducible package builds establishes a standardized environment for package versioning and debuggability. It integrates MinVer to automate versioning with a minor increment strategy and v-prefixed tags, while utilizing DotNet.ReproducibleBuilds to enforce deterministic compilation and consistent metadata across environments. The configuration explicitly enables portable debug symbols and packages them into snupkg files to ensure that compiled assemblies remain traceable and debuggable after deployment.
We only override the symbol-related settings deliberately:
- DotNet.ReproducibleBuilds defaults to embedded symbols;
- DebugType=portable creates a separate portable P.D.B;
- IncludeSymbols and SymbolPackageFormat=snupkg place that P.D.B in the symbol package accepted by NuGet dot org.
The S.D.K and DotNet.ReproducibleBuilds take care of the other important build settings, including deterministic compilation, C.I 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 dot net S.D.K since dot net 8. When the repository is built with a recent S.D.K, the library can target an earlier runtime without needing a separate Microsoft.SourceLink.GitHub package. The S.D.K uses the repository information from the checkout and embeds the Source Link map in the portable P.D.B. Consumers can then step into the exact source revision represented by the package.
Package-specific metadata remains in the library project itself:
Code summary: This XML project configuration defines the package-specific metadata for Example.SampleLibrary. It establishes essential publishing details including the package identity, authorship, MIT license, and repository links to ensure the library is discoverable and traceable on NuGet.org. The configuration also enables documentation generation and package validation to maintain API consistency across frameworks, while explicitly including the README.md file in the final package payload.
GenerateDocumentationFile includes the X.M.L documentation alongside the assembly. EnablePackageValidation makes the S.D.K inspect the package produced by the build. It checks that the package exposes a consistent public A.P.I 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 A.P.I changes before publication.
Metadata such as the package description, tags, readme, license, and repository U.R.L is not build machinery, but it is part of publishing a usable package. NuGet dot 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:
Code summary: This setup initializes a repository-local tool environment by creating a tool manifest file and installing the dotnet-coverage tool. This ensures that the specific tool version is tracked within the repository, allowing all contributors to use a consistent environment.
The dotnet new tool-manifest command creates dot config slash dotnet tools dot 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:
- MinVer derives package versions from Git tags during the M.S.Build process;
- DotNet.ReproducibleBuilds applies reproducible-build and repository-information conventions;
- GitHubActionsTestLogger turns test failures into GitHub Actions annotations.
dotnet-coverage serves the same supporting role, but it is installed as a repository local dot net tool rather than as a PackageReference. The test framework and test S.D.K 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 A.P.I, 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 dot github slash workflows slash publish dot yml
The workflow validates every pull request and every push to the default branch. Publishing only happens when a GitHub release is published:
Code summary: Build and publish defines a GitHub Actions workflow triggered by pushes or pull requests to the master branch for validation, and by published releases to initiate the publishing process. It includes a concurrency management phase that groups runs by workflow and reference to prevent redundant builds by cancelling in-progress runs, specifically ensuring that release publications are never interrupted.
Cancelling an older C.I 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:
: Code summary: Cache NuGet packages optimizes build times by persisting the global NuGet package directory. It generates a unique cache key based on the runner's operating system and a hash of all project and configuration files that define the dependency graph, ensuring the cache is invalidated whenever dependencies change. If an exact match isn't found, it uses a restore-key fallback to retrieve the most recent compatible cache, reducing the amount of data that must be downloaded during the subsequent restore steps.
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:
Code summary: This procedure ensures all necessary dependencies are available by explicitly restoring both local .NET tools and project packages. These steps are required to guarantee correct build execution regardless of whether a previous cache hit provided the necessary binaries.

Formatting, building, and testing

Run formatting as its own validation step:
Code summary: Verify formatting uses the dotnet format tool to ensure the codebase adheres to style guidelines. By using the verify-no-changes flag, it acts as a validation step that fails if formatting updates are needed, and the no-restore flag avoids redundant package operations to maintain workflow efficiency.
Then build once in Release configuration:
Code summary: Build, compiles the project using the dotnet build command in Release configuration. It employs the no-restore flag to avoid redundant package retrieval and treats warnings as errors to ensure strict code quality before subsequent testing and packing phases.
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:
Code summary: This XML snippet adds the GitHubActionsTestLogger package to a test project as a private asset, enabling the generation of workflow annotations for failed tests within a GitHub Actions CI environment.
The logger creates workflow annotations for failed tests. Run the tests through dotnet-coverage so one test execution produces both diagnostics and coverage:
Code summary: Test and collect coverage executes a suite of Release configuration tests using the GitHubActions logger to produce diagnostic annotations. It wraps the test execution within the dotnet-coverage tool to simultaneously collect code coverage data, which is then exported in Cobertura format to a specified artifacts file.
Upload the report so it remains available after the runner is discarded:
Code summary: Upload coverage report uses the upload-artifact action to persist the Cobertura XML coverage report by moving it from the temporary runner's artifacts directory to a named GitHub artifact called coverage, ensuring the report is available for review after the workflow completes and triggering an error if the file is missing.
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:
Code summary: Pack uses the dotnet pack command to create a NuGet package from the SampleLibrary project. It specifies a Release configuration and leverages previously built binaries via the no-build flag to efficiently output the resulting package files into a dedicated artifacts directory.
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:
Code summary: Upload packages uses the GitHub actions/upload-artifact action to preserve the build output by saving all .nupkg and .snupkg files from the artifacts directory as a named workflow artifact. This creates a concrete handoff point, ensuring that subsequent publishing jobs can use the exact same compiled binaries without needing to rebuild or repack the repository, and it is configured to trigger an error if no files are found.
The workflow now has a concrete handoff point:
Code summary: This CI/CD pipeline defines the lifecycle of a source commit from development to distribution. It begins with a sequence of quality gates—restore, format, build, test, and coverage—to ensure code stability before the pack phase creates the package. These packages are then archived as a workflow artifact, serving as a concrete handoff point that allows the final phase to publish the validated assets to NuGet.org without requiring a rebuild.
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:
Code summary: publish defines a GitHub Actions job that manages the distribution of a package. To ensure stability and security, it only triggers on release events and requires the build job to complete successfully. The configuration specifies an ubuntu-latest runner and grants the necessary write permissions for contents and id-tokens to authorize the publishing process.
Before publishing, validate the tag:
Code summary: Validate release metadata ensures the integrity of a GitHub release before publication. It first retrieves the full git history to enable commit verification. Then, it performs three critical checks: it validates the release tag against a semantic versioning regex, verifies that the release tag points exactly to the current checked-out commit to prevent targeting the wrong code, and confirms that the prerelease status in the metadata matches whether the tag contains a prerelease suffix. If any of these conditions fail, the process exits with an error to prevent inconsistent releases.
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:
Code summary: Download packages retrieves the nuget-packages artifact using the actions/download-artifact version 8 action, saving the validated build files into the ./artifacts directory for use in subsequent publishing steps.
The files in ./artifacts are the files that already passed through the validation job.

Publishing without a long-lived A.P.I key

The traditional setup stores a NuGet A.P.I 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 dot 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 dot org, authenticate with nuget/login:
Code summary: Login to NuGet.org uses the nuget/login action to authenticate a workflow via OpenID Connect. By providing a NuGet username, the process exchanges a GitHub identity token for a short-lived credential, eliminating the need to store and rotate a permanent API key as a secret.
The publishing job needs id-token: write so the action can request the O.I.D.C token. No permanent NuGet A.P.I key is stored in the repository.
Push the downloaded package:
Code summary: Publish to NuGet.org uses the dotnet nuget push command to upload all package files from the artifacts directory to the NuGet.org v3 API. It utilizes a temporary credential via the api-key argument and includes a skip-duplicate flag to prevent the workflow from failing if the package version already exists on the server.
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:
Code summary: Attach packages to the GitHub release uses the GitHub CLI to upload NuGet package files, including both standard nupkg and symbol snupkg files from the artifacts directory, directly to the release that triggered the workflow. It utilizes a GH_TOKEN for authentication and specifies the target repository and release tag to ensure the build artifacts are permanently attached to the versioned release.
NuGet dot org and the GitHub release now receive the same package files.

The complete workflow

Putting everything together gives us:
Code summary: Build and publish, is a GitHub Actions workflow that automates the continuous integration and delivery of a .NET library. The process is split into two primary jobs. First, the build job triggers on pushes, pull requests to the master branch, or published releases; it sets up a .NET 10 environment, caches NuGet packages for efficiency, verifies code formatting, builds the project in Release configuration, and runs tests to collect coverage. It concludes by packing the library into NuGet packages and uploading both the coverage report and packages as artifacts. Second, the publish job runs only when a GitHub release is published and depends on the build job's success. It validates the release tag and commit consistency, then downloads the previously created packages to publish them to NuGet.org and attach them to the GitHub release. The workflow includes a concurrency group to cancel in-progress runs for non-release events, ensuring only the latest relevant build is active.

Publishing a release

The human part of the release process remains small:
1. Merge the intended changes into the default branch.
2. Create a tag such as v1.2.0.
3. Create and publish a GitHub release for that tag.
4. Let the workflow validate the release and publish the package.
A prerelease tag can include a suffix:
: Code summary: v1.3.0-preview.1 is an example of a prerelease tag format used to trigger a GitHub workflow to validate and publish a preview package.
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:
Code summary: Publish preview is a GitHub Actions job designed to manually publish prerelease packages to GitHub Packages when triggered by a workflow dispatch event. To maintain a build-once integrity rule, the job first downloads previously built nuget-packages artifacts produced by a preceding build job. It then uses the dotnet nuget push command to upload these packages to the GitHub Packages registry, utilizing the skip-duplicate flag to ensure only new versions are added.
This keeps manual previews separate from public NuGet dot 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 S.D.K 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 dot nupkg and dot snupkg pair is passed between jobs as a workflow artifact, published to NuGet dot org with a short-lived O.I.D.C 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:
Table summary: The modernization of the release process from an earlier approach using CircleCI to a 2026 approach using GitHub Actions. Key improvements include shifting from permanent NuGet API keys to trusted publishing through GitHub OIDC, replacing explicit version calculation with MinVer integrated with MSBuild, and moving from basic package output to reproducible builds with Source Link, portable symbols, and package validation. The new workflow also optimizes efficiency by caching NuGet packages and tools and adopting a build-once rule where the same artifact is published rather than rebuilding during publication. Other upgrades include using GitHub-native test annotations, integrating coverage directly into the workflow, and utilizing validated GitHub releases instead of relying on tags alone.

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 dot org through trusted publishing instead of a permanent A.P.I 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 dot net projects.