The second time I set up CI/CD for a NuGet package, I copied the workflows from the first one — and spent an afternoon fixing everything that didn't transfer cleanly. I didn't want every new package to start with the same afternoon of cleanup.
dotnet-nuget-release-template is the result: a public GitHub template repo for packing and publishing multi-target .NET NuGet packages. It's MIT licensed, verified end-to-end with a real publish, and has since scaffolded a real package — DGates.Identity.NotificationProviders, now stable on NuGet.org and consumed in production by angular-dotnet-auth-template.
Here's what's in it and the decisions behind it.
No API keys anywhere
The release workflow publishes via NuGet's Trusted Publishing, using NuGet/login@v1. The workflow requests id-token: write permission and exchanges a short-lived OIDC token for a NuGet API key at publish time. The only repo secret is NUGET_USER — your NuGet.org profile name, not a credential.
That means nothing long-lived is stored in the repo. No key to rotate, no key to leak, no key that quietly expires and breaks your release six months from now. Setup is two steps: register the repo and workflow as a trusted publisher on NuGet.org, add the NUGET_USER secret. Done.
The important part of the authentication flow is just this:
publish:
needs: build-and-test
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
# checkout, .NET setup, and pack steps omitted
- name: NuGet login (OIDC)
uses: NuGet/login@v1
id: login
with:
user: ${{ secrets.NUGET_USER }}
- name: Publish to NuGet.org
run: |
dotnet nuget push ./artifacts/*.nupkg \
--api-key ${{ steps.login.outputs.NUGET_API_KEY }} \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate
id-token: write is what lets the job request the OIDC token in the first place; NuGet/login@v1 trades it for a temporary API key that expires in about an hour — nothing outlives the release.
The version comes from the tag
Pushing a tag matching v* (say, v1.2.0) triggers the release workflow, which packs and publishes with the version taken from the tag itself. Nothing is hardcoded in the .csproj.
This removes an entire class of mistakes — the tag says one thing, the project file says another, and NuGet.org now has a version you didn't intend. The tag is the single source of truth, and the release process is:
git tag v1.2.0 <sha>
git push origin v1.2.0
One release discipline matters here: create the tag first, let CI validate that exact tag, then create the GitHub Release by selecting the tag that already exists. It's the difference between a release tag backed by a successful build and a tag that exists only because someone typed it into a release form.
One wrinkle worth knowing: the template repo itself needs version tags, but those tags must not trigger the publish workflow — the example library is deliberately disposable and shouldn't land on NuGet.org. The template's own releases use a template-v* prefix (template-v1.0.0), which does not match the v* release trigger. Repos generated from the template use plain v* tags and publish normally. Small convention, but it's the kind of thing that only bites you once you've already published something by accident.
TODO(template) — every edit point is grep-able
Every placeholder and decision point in the repo — package metadata, license copyright, DOTNET_VERSION, the Mono step, the LocalStack block — is marked with a TODO(template) comment. Generating a repo and running one grep gives you the complete customization checklist:
$ grep -rn "TODO(template)" .
.github/workflows/release.yml:6: # TODO(template): update to match your TargetFrameworks.
.github/workflows/release.yml:16: # TODO(template): ExampleLibrary multi-targets net48;net10.0 ...
.github/workflows/release.yml:24: # TODO(template): LocalStack block is required for this
template's own example (S3NoteStore). Optional once you replace ExampleLibrary ...
src/ExampleLibrary/ExampleLibrary.csproj:4: <!-- TODO(template): package metadata below -->
No hunting through workflow YAML wondering whether some value is a placeholder or load-bearing — work the list top to bottom and you're done.
Template repos have a property that libraries don't: the source code is the pitch. Nobody reads a library's internals before installing it, but everybody reads a template before adopting it, because they're about to own a copy. Unexplained hacks and unmarked placeholders cost more here than anywhere else.
An integration testing pattern, not just unit tests
Most CI templates demonstrate unit tests and stop there. Real packages often have dependencies — databases, queues, object storage — and that's where the CI story usually gets hand-wavy, leaving you to figure out the container story yourself.
The example library ships with two components on purpose:
-
NoteValidator— pure logic, covered by ordinary unit tests -
S3NoteStore— talks to S3, covered by integration tests backed by LocalStack
CI runs both. The LocalStack block (docker-compose.yml plus a seed script) demonstrates the shape of "my package touches an external service and I want CI to actually exercise that" — and once you replace the example library with your own, it's entirely optional. Keep it, point it at a different container (anything Testcontainers supports works the same way), or delete it.
Multi-targeting, including the awkward part
The example library multi-targets net48;net10.0. The net10.0 half is unremarkable. The net48 half is there because plenty of real-world .NET libraries still need to support .NET Framework — my own DGates.AwsSecretsManager targets 4.8 — and running net48 tests on a Linux runner means installing Mono to host the test run.
That step is clearly marked, and the README says exactly when to delete it: if you don't target a pre-.NET-Core framework, it's dead weight.
Verified the boring way
Before tagging template-v1.0.0, I ran the whole pipeline for real: generated a repo from the template, pushed a v* tag, published an actual throwaway package to NuGet.org, confirmed it, and unlisted it. Some publishing failures — especially around packaging or platform-specific behavior — only appear during a real publish, so "it should work" wasn't good enough for a repo whose entire job is publishing.
Since then, the template has done its actual job. DGates.Identity.NotificationProviders was scaffolded from it and shipped to a stable 1.0.0, and that package is consumed as a real dependency by a released project — not a demo pipeline, a working one.
That's the test that mattered: whether the second package could reuse the pipeline without me re-explaining anything the first one had taught me the hard way.
Use it
Click "Use this template" on the repo, grep for TODO(template), replace the example library, set up Trusted Publishing, push a tag. The README walks through each step, including the repo Ruleset configuration I'd recommend replicating.
The goal isn't to be the only way to publish NuGet packages. It's to remove the repeated setup work every package author ends up doing. If you hit something the TODO(template) markers didn't cover, open an issue — that's a gap in the template, and I'd like to close it.
Top comments (0)