Updated 14 June 2026
If you’re maintaining a .NET library, automating your build and publish pipeline isn’t optional. GitHub Actions makes this straightforward, giving you a CI/CD workflow that builds, tests, packages, and publishes your NuGet packages on every release.
In this guide, I’ll walk you through a complete workflow for publishing NuGet packages with GitHub Actions, from authenticating with NuGet.org using Trusted Publishing to a production-ready YAML pipeline. I’ve been using this approach across my open-source projects for years, and it’s evolved significantly since I first set it up. The latest evolution? Ditching long-lived API keys entirely in favour of keyless OIDC authentication.
What you’ll learn
- Authenticate with NuGet.org using Trusted Publishing (OIDC)
- Fall back to API keys when Trusted Publishing isn’t available
- Set up a CI workflow that builds, tests, and validates on every PR
- Add a CD workflow that packs and publishes to NuGet.org on release tags
- Configure Source Link, symbol packages, and modern versioning
Authenticate with NuGet.org
Trusted Publishing (Recommended)
Trusted Publishing is the modern way to publish NuGet packages. Instead of managing long-lived API keys, your GitHub Actions workflow uses OIDC to request a short-lived token directly from NuGet.org — no secrets to store, rotate, or worry about leaking.
Here’s how to set it up:
- Log in to NuGet.org and click your username → Trusted Publishing
- Add a new trusted publishing policy with your repository details:
- Repository Owner: your GitHub username or organization (e.g.,
jamesmcroft) - Repository: the repo name (e.g.,
my-sdk) - Workflow File: just the file name, not the full path (e.g.,
ci-cd.yml) - Environment: optional, use if your workflow specifies
environment: release
- Repository Owner: your GitHub username or organization (e.g.,
- Store your NuGet.org username (your profile name, not your email) as a GitHub secret called
NUGET_USER
The policy links your specific repository and workflow to your NuGet.org account. When the workflow runs, GitHub issues a cryptographically signed OIDC token that NuGet.org validates and exchanges for a temporary API key, valid for just one hour.
Note: Trusted Publishing is rolling out gradually. If you don’t see the option in your NuGet.org account yet, use the API key approach below.
API key (Fallback)
If Trusted Publishing isn’t available to you yet, the traditional API key approach still works. Navigate to your NuGet.org account and generate a key with these settings:
- Key Name: something memorable like
github-actions-publish - Expires In: up to 365 days (note: NuGet will remind you before expiry)
- Scopes: select Push (that’s all you need for publishing)
- Glob Pattern: scope to your package name pattern for extra security (e.g.,
MyOrg.*)
Copy the generated key immediately, you won’t be able to see it again.
In your GitHub repository, go to Settings → Secrets and variables → Actions and create a new repository secret:
- Name:
NUGET_API_KEY - Value: paste your API key
Tip: Scope your API keys to specific packages using glob patterns. A key that can only push MyOrg.* limits the blast radius if it’s ever compromised.
Prepare your project for packaging
Configure your .csproj with NuGet metadata, Source Link, and symbol packages:
<PropertyGroup>
<!-- Your Package Setup -->
<!-- Package metadata -->
<PackageId>YourLibrary</PackageId>
<Authors>Your Name</Authors>
<Description>A clear description of what your library does</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/you/your-library</PackageProjectUrl>
<RepositoryUrl>https://github.com/you/your-library</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Source Link — lets consumers step into your source while debugging -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Deterministic builds for CI -->
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203" PrivateAssets="All" />
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
A few things worth highlighting:
- Source Link enables consumers to step into your library’s source code while debugging. It costs you nothing to set up and dramatically improves the debugging experience for your users.
- Symbol packages (
.snupkg) publish alongside your main package to NuGet.org’s symbol server, no separate hosting needed. - Deterministic builds ensure the same source always produces the same binary, which matters for supply chain security and reproducibility.
- Note that GitHub Actions sets
CI=trueautomatically, so theContinuousIntegrationBuildcondition works out of the box.
- Note that GitHub Actions sets
Set up your CI/CD workflow
Here’s the complete GitHub Actions workflow I use for my .NET libraries. It handles both CI (build + test on every PR and push) and CD (pack + publish on release tags). This version uses Trusted Publishing (see the fallback section below if you need API keys instead):
How this workflow works
CI (every push and PR):
- Checks out the code with full git history (
fetch-depth: 0is important for versioning tools) - Sets up .NET 10
- Restores, builds, and runs tests
- If any step fails, the PR is blocked
CD (only on version tags):
- The
Packstep only runs when you push a tag starting withv(e.g.,v2.1.0) - The version is extracted from the tag name, e.g.
v2.1.0becomes version2.1.0 - The
NuGet loginstep requests a short-lived OIDC token from GitHub and exchanges it with NuGet.org for a temporary API key (valid for 1 hour) - The
Publishstep pushes all.nupkgfiles to NuGet.org using that temporary key --skip-duplicateprevents errors if you re-run the workflow on an existing version
Using an API key instead
If Trusted Publishing isn’t available to you yet, replace the NuGet login step with a direct push using your stored API key:
- name: Publish to NuGet.org
if: startsWith(github.ref, 'refs/tags/v')
run: >
dotnet nuget push ./nupkg/*.nupkg
--api-key ${{ secrets.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json
--skip-duplicate
You can also remove id-token: write from the permissions block since OIDC isn’t needed. Everything else in the workflow stays the same.
Versioning your packages
The pipeline extracts the version from git tags. When you’re ready to release:
git tag v2.1.0
git push origin v2.1.0
For prereleases, use semantic versioning:
git tag v2.1.0-preview.1
git push origin v2.1.0-preview.1
Modern versioning alternatives
If you prefer automated versioning, the same tools work with Azure Pipelines:
- MinVer – minimal, convention-based versioning from git tags
- GitVersion – automatic semantic versioning from git history
- Nerdbank.GitVersioning – version stamping from a
version.jsonfile
Publishing to GitHub Packages
If you want to publish packages to GitHub Packages as well as (or instead of) NuGet.org, add this step:
- name: Publish to GitHub Packages
if: startsWith(github.ref, 'refs/tags/v')
run: >
dotnet nuget push ./nupkg/*.nupkg
--api-key ${{ secrets.GITHUB_TOKEN }}
--source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json"
--skip-duplicate
No extra secrets needed as GITHUB_TOKEN is automatically available in every workflow. This is particularly useful for internal packages or when you want your packages hosted alongside your source code.
Common mistakes to avoid
Not setting fetch-depth: 0. Without full git history, versioning tools like MinVer and GitVersion can’t calculate the correct version. You’ll get 0.0.0 or similar default versions.
Hardcoding versions in .csproj. If your version is <Version>1.0.0</Version> in the project file, you’ll forget to update it. Let your CI pipeline inject the version from tags or a versioning tool.
Missing --skip-duplicate. Without this flag, re-running a workflow on an already-published version will fail. The flag makes your workflow idempotent.
Not enabling Trusted Publishing. If it’s available in your NuGet.org account, use it. It eliminates the entire class of problems around secret management.
Forgetting id-token: write. Trusted Publishing requires the id-token: write permission in your workflow. Without it, the OIDC token request will silently fail and the NuGet login step won’t get a valid temporary key.
Expiring API keys (if using the fallback approach). NuGet.org keys expire after a maximum of 365 days. Set a calendar reminder to regenerate and update your GitHub secret before it expires.
Summary
With this workflow, every tag push produces a tested, packed, and published NuGet package with Trusted Publishing for keyless authentication, Source Link for debugging, symbol packages for the symbol server, and deterministic builds for reproducibility. The entire process is a few lines of YAML, a NuGet.org publishing policy, and some .csproj metadata. No long-lived secrets required.
Here’s the release checklist:
- Set up a Trusted Publishing policy on NuGet.org (one-time setup)
- Merge your changes to
main - Create and push a version tag:
git tag v2.1.0 && git push origin v2.1.0 - Watch the workflow run
- Your package appears on NuGet.org within minutes
For the Azure DevOps equivalent, check out my guide on publishing NuGet packages with Azure Pipelines. And if your library targets multiple frameworks, read my guide on multi-targeting .NET libraries for maximum compatibility.
This article was originally published in April 2021 and has been updated to reflect Trusted Publishing, current GitHub Actions versions, .NET 10, modern versioning approaches, and Source Link best practices.
Discover more from James Croft
Subscribe to get the latest posts sent to your email.



Hi James and thanks for sharing!
I follow your content and find plenty of useful and life-saving stuff on your blog.
Anyway I’m trying to follow the above but I get the following error:
Line |
2 | Import-Module .\build\GetBuildVersion.psm1
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| The specified module ‘.\build\GetBuildVersion.psm1’ was not loaded
| because no valid module file was found in any module directory.
Looks the ps module isn’t found on the GH module list. Can you please advidse?
You’ll need to grab this PowerShell module and include that under a
buildfolder at the root of your project.https://github.com/MADE-Apps/MADE.NET/blob/main/build/GetBuildVersion.psm1