Publish to nuget.org with trusted publishing instead of an API key - #350
Merged
Conversation
The publish workflow injected a long-lived NUGET_API_KEY secret that never expires and can push to all 45 packages the spring-net nuget.org organization owns. Replace it with NuGet Trusted Publishing: the tag-only publish job requests a GitHub OIDC token and the build exchanges it at nuget.org for an API key that lives 15-60 minutes. The exchange happens inside the Publish target rather than via NuGet/login@v1 in a workflow step, because the job runs 'test publish' as one command and takes ~14 minutes end to end - a key minted before the test run would be right against the 15-minute floor of its lifetime by the time the push happens. Minting in-build puts it seconds before the push, and it is minted once per release since nuget.org rate-limits key creation. publish.yml already being a separate tag-only workflow is what makes this safe: a trusted publishing policy is scoped by repository plus workflow filename and has no branch or tag filter of its own, so no ordinary CI run can mint a key. NuGetApiKey survives as an optional override that wins when supplied, so a release can still be pushed by hand if the exchange fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marko Lahma <marko.lahma@gmail.com>
lahma
force-pushed
the
trusted-publishing
branch
from
August 9, 2026 09:33
df42ebf to
843b83a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the long-lived
NUGET_API_KEYrepo secret with NuGet Trusted Publishing: the publish job requests a GitHub OIDC token and the build exchanges it athttps://www.nuget.org/api/v2/tokenfor an API key that lives 15–60 minutes.The stored key never expires and can push to all 45 packages the
spring-netorganization owns. After this there is no durable secret to leak or rotate.Changes
.github/workflows/publish.yml— addspermissions: id-token: write,environment: nuget, and drops theNuGetApiKeyenv. Triggers are untouched (still tag-only,v*.*.*, nopaths:filter).build-support/build/Build.Publish.cs— performs the token exchange, drops.Requires(() => NuGetApiKey), and collapses the three no-opConfigure<DotNetNuGetPushSettings>hooks left over from the NUKE component shape.AGENTS.md— records that the nuget.org policy is keyed to the workflow filename, so renaming or splittingpublish.ymlbreaks publishing..fallout/build.schema.json— regenerated for the new parameter description.Notes
Why the exchange runs in the build rather than
NuGet/login@v1. The job runs./build.cmd test publishas a single step and the last real publish took ~14 minutes end to end. A key minted by a preceding workflow step would be ~14 minutes old at push time — right against the 15-minute floor of its lifetime. Minting inside thePublishtarget puts it seconds before the push. It is minted once per release into a local, since nuget.org allows one key per 30 seconds per user and one OIDC token mints exactly one key.Why this is safe with an existing separate workflow. A policy is scoped by repository + workflow filename and has no branch or tag filter of its own.
publish.ymlwas already tag-only and separate fromci.yml, which is what stops an ordinary CI run from being able to mint a key. Thenugetenvironment adds a second claim to match.The
User-Agentheader is required. nuget.org's token endpoint sits behind Azure Front Door and returns400 A User-Agent header is required.when there is none — and a bareHttpClientsends none, which the JS-basedNuGet/loginaction never hits. Verified against the live endpoint: no User-Agent →400 {"error":"A User-Agent header is required."}; with one →401 {"error":"The bearer token could not be parsed as a JSON web token."}(expected for the dummy token used to probe, and proof the request shape is accepted).NuGetApiKeysurvives as an optional override that wins when supplied, so a release can still be pushed by hand if the exchange fails. Nothing passes it in CI any more.Verification
dotnet build build-support/build/_build.csproj -c Debug— clean, 0 warnings.v9.9.9-rc.1tag withGITHUB_ACTIONS=true,dotnet fallout Publish --skipreportsTagged build: Trueand fails withArgumentException: GitHub OIDC is unavailable, proving the tagged path reaches the exchange. The same command with--nuget-api-key dummybypasses the exchange and succeeds, proving the override.Out of scope
Assembly versions stay at the SDK default
1.0.0.0— noAssemblyVersionattribute orVersionPrefixexists anywhere today, which for a strong-named framework is almost certainly deliberate (consumers never need binding redirects across 3.x). The tag is already the version authority forPack, so there is no props-file version that could drift out of sync with a tag.Before merging
The trusted publishing policy must exist on nuget.org before the first tag, and the
nugetenvironment must exist on this repo. TheNUGET_API_KEYsecret should be deleted only after a green release — until then it is the rollback path.