Skip to content

Publish to nuget.org with trusted publishing instead of an API key - #350

Merged
lahma merged 1 commit into
mainfrom
trusted-publishing
Aug 9, 2026
Merged

Publish to nuget.org with trusted publishing instead of an API key#350
lahma merged 1 commit into
mainfrom
trusted-publishing

Conversation

@lahma

@lahma lahma commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Replaces the long-lived NUGET_API_KEY repo secret with NuGet Trusted Publishing: the publish job requests a GitHub OIDC token and the build exchanges it at https://www.nuget.org/api/v2/token for an API key that lives 15–60 minutes.

The stored key never expires and can push to all 45 packages the spring-net organization owns. After this there is no durable secret to leak or rotate.

Changes

  • .github/workflows/publish.yml — adds permissions: id-token: write, environment: nuget, and drops the NuGetApiKey env. Triggers are untouched (still tag-only, v*.*.*, no paths: filter).
  • build-support/build/Build.Publish.cs — performs the token exchange, drops .Requires(() => NuGetApiKey), and collapses the three no-op Configure<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 splitting publish.yml breaks 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 publish as 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 the Publish target 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.yml was already tag-only and separate from ci.yml, which is what stops an ordinary CI run from being able to mint a key. The nuget environment adds a second claim to match.

The User-Agent header is required. nuget.org's token endpoint sits behind Azure Front Door and returns 400 A User-Agent header is required. when there is none — and a bare HttpClient sends none, which the JS-based NuGet/login action 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).

NuGetApiKey survives 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

  • Live probe of the token endpoint confirmed the 400-vs-401 request-shape behaviour above.
  • dotnet build build-support/build/_build.csproj -c Debug — clean, 0 warnings.
  • On a detached HEAD at a throwaway v9.9.9-rc.1 tag with GITHUB_ACTIONS=true, dotnet fallout Publish --skip reports Tagged build: True and fails with ArgumentException: GitHub OIDC is unavailable, proving the tagged path reaches the exchange. The same command with --nuget-api-key dummy bypasses the exchange and succeeds, proving the override.

Out of scope

Assembly versions stay at the SDK default 1.0.0.0 — no AssemblyVersion attribute or VersionPrefix exists 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 for Pack, 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 nuget environment must exist on this repo. The NUGET_API_KEY secret should be deleted only after a green release — until then it is the rollback path.

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
lahma force-pushed the trusted-publishing branch from df42ebf to 843b83a Compare August 9, 2026 09:33
@lahma
lahma merged commit 3173ccd into main Aug 9, 2026
3 checks passed
@lahma
lahma deleted the trusted-publishing branch August 9, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant