From 211c840d7e3d4f5f9e1ae1938f38eefe7de817d6 Mon Sep 17 00:00:00 2001 From: Artur Stolear Date: Sun, 2 Nov 2025 21:07:58 +0100 Subject: [PATCH] publish nuget packages using Trusted Publiishing uses OIDC token exchange for nuget api key retrieval, instead of storing the api key in github secrets. --- .github/workflows/_publish.yml | 6 +- build/publish/Tasks/PublishNuget.cs | 132 ++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 864431558b..79e8b87583 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -4,7 +4,7 @@ on: env: DOTNET_INSTALL_DIR: "./.dotnet" DOTNET_ROLL_FORWARD: "Major" - + jobs: publish: name: ${{ matrix.taskName }} @@ -16,7 +16,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} steps: - @@ -33,7 +32,8 @@ jobs: with: name: nuget path: ${{ github.workspace }}/artifacts/packages/nuget + - name: '[Publish]' shell: pwsh - run: dotnet run/publish.dll --target=Publish${{ matrix.taskName }} \ No newline at end of file + run: dotnet run/publish.dll --target=Publish${{ matrix.taskName }} diff --git a/build/publish/Tasks/PublishNuget.cs b/build/publish/Tasks/PublishNuget.cs index 59649a35af..a41002ab82 100644 --- a/build/publish/Tasks/PublishNuget.cs +++ b/build/publish/Tasks/PublishNuget.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Headers; +using System.Text.Json; using Cake.Common.Tools.DotNet.NuGet.Push; using Common.Utilities; @@ -10,7 +12,7 @@ public class PublishNuget : FrostingTask; [TaskName(nameof(PublishNugetInternal))] [TaskDescription("Publish nuget packages")] -public class PublishNugetInternal : FrostingTask +public class PublishNugetInternal : AsyncFrostingTask { public override bool ShouldRun(BuildContext context) { @@ -21,7 +23,7 @@ public override bool ShouldRun(BuildContext context) return shouldRun; } - public override void Run(BuildContext context) + public override async Task RunAsync(BuildContext context) { // publish to github packages for commits on main and on original repo if (context.IsInternalPreRelease) @@ -32,22 +34,26 @@ public override void Run(BuildContext context) { throw new InvalidOperationException("Could not resolve NuGet GitHub Packages API key."); } + PublishToNugetRepo(context, apiKey, Constants.GithubPackagesUrl); context.EndGroup(); } + // publish to nuget.org for tagged releases if (context.IsStableRelease || context.IsTaggedPreRelease) { context.StartGroup("Publishing to Nuget.org"); - var apiKey = context.Credentials?.Nuget?.ApiKey; + var apiKey = await GetNugetApiKey(context); if (string.IsNullOrEmpty(apiKey)) { throw new InvalidOperationException("Could not resolve NuGet org API key."); } + PublishToNugetRepo(context, apiKey, Constants.NugetOrgUrl); context.EndGroup(); } } + private static void PublishToNugetRepo(BuildContext context, string apiKey, string apiUrl) { ArgumentNullException.ThrowIfNull(context.Version); @@ -55,12 +61,120 @@ private static void PublishToNugetRepo(BuildContext context, string apiKey, stri foreach (var (packageName, filePath, _) in context.Packages.Where(x => !x.IsChocoPackage)) { context.Information($"Package {packageName}, version {nugetVersion} is being published."); - context.DotNetNuGetPush(filePath.FullPath, new DotNetNuGetPushSettings - { - ApiKey = apiKey, - Source = apiUrl, - SkipDuplicate = true - }); + context.DotNetNuGetPush(filePath.FullPath, + new DotNetNuGetPushSettings + { + ApiKey = apiKey, + Source = apiUrl, + SkipDuplicate = true + }); + } + } + + private static async Task GetNugetApiKey(BuildContext context) + { + try + { + var oidcToken = await GetGitHubOidcToken(context); + var apiKey = await ExchangeOidcTokenForApiKey(oidcToken); + + context.Information($"Successfully exchanged OIDC token for NuGet API key."); + return apiKey; + } + catch (HttpRequestException ex) + { + context.Error($"Network error while retrieving NuGet API key: {ex.Message}"); + return null; } + catch (InvalidOperationException ex) + { + context.Error($"Invalid operation while retrieving NuGet API key: {ex.Message}"); + return null; + } + catch (JsonException ex) + { + context.Error($"JSON parsing error while retrieving NuGet API key: {ex.Message}"); + return null; + } + } + + private static async Task GetGitHubOidcToken(BuildContext context) + { + const string nugetAudience = "https://www.nuget.org"; + + var oidcRequestToken = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN"); + var oidcRequestUrl = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL"); + + if (string.IsNullOrEmpty(oidcRequestToken) || string.IsNullOrEmpty(oidcRequestUrl)) + throw new InvalidOperationException("Missing GitHub OIDC request environment variables."); + + var tokenUrl = $"{oidcRequestUrl}&audience={Uri.EscapeDataString(nugetAudience)}"; + context.Information($"Requesting GitHub OIDC token from: {tokenUrl}"); + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcRequestToken); + + var responseMessage = await http.GetAsync(tokenUrl); + var tokenBody = await responseMessage.Content.ReadAsStringAsync(); + + if (!responseMessage.IsSuccessStatusCode) + throw new Exception("Failed to retrieve OIDC token from GitHub."); + + using var tokenDoc = JsonDocument.Parse(tokenBody); + return ParseJsonProperty(tokenDoc, "value", "Failed to retrieve OIDC token from GitHub."); + } + + private static async Task ExchangeOidcTokenForApiKey(string oidcToken) + { + const string nugetUsername = "gittoolsbot"; + const string nugetTokenServiceUrl = "https://www.nuget.org/api/v2/token"; + + var requestBody = JsonSerializer.Serialize(new { username = nugetUsername, tokenType = "ApiKey" }); + + using var tokenServiceHttp = new HttpClient(); + tokenServiceHttp.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcToken); + tokenServiceHttp.DefaultRequestHeaders.UserAgent.ParseAdd("nuget/login-action"); + using var content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + var responseMessage = await tokenServiceHttp.PostAsync(nugetTokenServiceUrl, content); + var exchangeBody = await responseMessage.Content.ReadAsStringAsync(); + + if (!responseMessage.IsSuccessStatusCode) + { + var errorMessage = BuildErrorMessage((int)responseMessage.StatusCode, exchangeBody); + throw new Exception(errorMessage); + } + + using var respDoc = JsonDocument.Parse(exchangeBody); + return ParseJsonProperty(respDoc, "apiKey", "Response did not contain \"apiKey\"."); + } + + private static string ParseJsonProperty(JsonDocument document, string propertyName, string errorMessage) + { + if (!document.RootElement.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.String) + throw new Exception(errorMessage); + + return property.GetString() ?? throw new Exception(errorMessage); + } + + private static string BuildErrorMessage(int statusCode, string responseBody) + { + var errorMessage = $"Token exchange failed ({statusCode})"; + try + { + using var errDoc = JsonDocument.Parse(responseBody); + errorMessage += + errDoc.RootElement.TryGetProperty("error", out var errProp) && + errProp.ValueKind == JsonValueKind.String + ? $": {errProp.GetString()}" + : $": {responseBody}"; + } + catch (Exception) + { + errorMessage += $": {responseBody}"; + } + + return errorMessage; } }