diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index f6cad14a2b..b94e46b468 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -13,6 +13,10 @@ jobs: publish: name: ${{ matrix.taskName }} runs-on: windows-2025-vs2026 + permissions: + id-token: write + packages: write + contents: read strategy: fail-fast: false matrix: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31ecfb3096..4debf219d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,51 @@ We use Cake for our build and deployment process. The way the release process is and other distribution channels. 9. The issues and pull requests will get updated with message specifying in which release it was included. +### NuGet Trusted Publishing + +NuGet packages are published to nuget.org using [Trusted Publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing), +which replaces long-lived API keys with short-lived, identity-based tokens issued by GitHub Actions OIDC. + +**How it works:** + +1. The publish workflow requests a GitHub OIDC token scoped to `https://www.nuget.org`. +2. That token is exchanged with the nuget.org token service for a short-lived API key. +3. Packages are pushed using that short-lived key — no long-lived secret is stored or rotated. + +**One-time setup on nuget.org:** + +Trusted Publishing is configured once for the repository and workflow — not per package. A single trusted +publisher entry covers every package pushed by the same workflow run. + +1. Sign in to [nuget.org](https://www.nuget.org) as a package owner. +2. Go to **Account settings** → **Trusted Publishers** (or navigate to any of the + [GitVersion packages](https://www.nuget.org/profiles/GitTools) and open **Manage package** → **Settings** → + **Trusted Publishers**). +3. Click **Add trusted publisher** and fill in the following fields: + + | Field | Value | + |------------------------|-----------------| + | **Publisher type** | GitHub Actions | + | **Owner** | `GitTools` | + | **Repository** | `GitVersion` | + | **Workflow file name** | `ci.yml` | + | **Environment** | *(leave blank)* | + +4. Click **Add** to save the entry. + +> **Note:** nuget.org will only issue a short-lived key when the OIDC claims from the workflow run match *all* +> registered fields exactly. A mismatch on any field (e.g. wrong workflow file name) will cause the token +> exchange to fail and the publish step will fall back to the static `NUGET_API_KEY`. + +**Verification and troubleshooting:** + +- If the OIDC token exchange fails the workflow falls back to a static `NUGET_API_KEY` environment variable + loaded from 1Password via the `gittools/cicd/nuget-creds@v1` action. Check the "Publishing to Nuget.org" log + group for error details. +- The publish job requires `id-token: write` permission, which is declared in `.github/workflows/_publish.yml`. +- If a package fails to publish with a permissions error, verify that nuget.org Trusted Publishing is configured + and that the owner, repository, and workflow file name match exactly. + ## Code Style In order to apply the code style defined by by the `.editorconfig` file you can use [`dotnet-format`](https://github.com/dotnet/format). diff --git a/build/publish/Tasks/PublishNuget.cs b/build/publish/Tasks/PublishNuget.cs index f084a3a476..86899211e9 100644 --- a/build/publish/Tasks/PublishNuget.cs +++ b/build/publish/Tasks/PublishNuget.cs @@ -43,7 +43,17 @@ public override async Task RunAsync(BuildContext context) if (context.IsTaggedRelease || context.IsTaggedPreRelease) { context.StartGroup("Publishing to Nuget.org"); - var apiKey = context.Credentials?.Nuget?.ApiKey; + + // Prefer Trusted Publishing via OIDC token exchange (no long-lived API key required) + var apiKey = await GetNugetApiKey(context); + + // Fall back to a static API key when OIDC is not available + if (string.IsNullOrEmpty(apiKey)) + { + context.Information("OIDC token exchange unavailable; falling back to static NuGet API key."); + apiKey = context.Credentials?.Nuget?.ApiKey; + } + if (string.IsNullOrEmpty(apiKey)) { throw new InvalidOperationException("Could not resolve NuGet org API key."); @@ -52,8 +62,6 @@ public override async Task RunAsync(BuildContext context) PublishToNugetRepo(context, apiKey, Constants.NugetOrgUrl); context.EndGroup(); } - - await Task.CompletedTask; } private static void PublishToNugetRepo(BuildContext context, string apiKey, string apiUrl) @@ -85,17 +93,22 @@ private static void PublishToNugetRepo(BuildContext context, string apiKey, stri } catch (HttpRequestException ex) { - context.Error($"Network error while retrieving NuGet API key: {ex.Message}"); + context.Warning($"Network error while retrieving NuGet API key via OIDC: {ex.Message}"); return null; } catch (InvalidOperationException ex) { - context.Error($"Invalid operation while retrieving NuGet API key: {ex.Message}"); + context.Warning($"OIDC not available for NuGet API key retrieval: {ex.Message}"); return null; } catch (JsonException ex) { - context.Error($"JSON parsing error while retrieving NuGet API key: {ex.Message}"); + context.Warning($"JSON parsing error while retrieving NuGet API key via OIDC: {ex.Message}"); + return null; + } + catch (Exception ex) + { + context.Warning($"Unexpected error while retrieving NuGet API key via OIDC: {ex.Message}"); return null; } }