Skip to content

Commit 211c840

Browse files
committed
publish nuget packages using Trusted Publiishing
uses OIDC token exchange for nuget api key retrieval, instead of storing the api key in github secrets.
1 parent 164f981 commit 211c840

File tree

2 files changed

+126
-12
lines changed

2 files changed

+126
-12
lines changed

.github/workflows/_publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
env:
55
DOTNET_INSTALL_DIR: "./.dotnet"
66
DOTNET_ROLL_FORWARD: "Major"
7-
7+
88
jobs:
99
publish:
1010
name: ${{ matrix.taskName }}
@@ -16,7 +16,6 @@ jobs:
1616

1717
env:
1818
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
2019
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
2120
steps:
2221
-
@@ -33,7 +32,8 @@ jobs:
3332
with:
3433
name: nuget
3534
path: ${{ github.workspace }}/artifacts/packages/nuget
35+
3636
-
3737
name: '[Publish]'
3838
shell: pwsh
39-
run: dotnet run/publish.dll --target=Publish${{ matrix.taskName }}
39+
run: dotnet run/publish.dll --target=Publish${{ matrix.taskName }}

build/publish/Tasks/PublishNuget.cs

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Net.Http.Headers;
2+
using System.Text.Json;
13
using Cake.Common.Tools.DotNet.NuGet.Push;
24
using Common.Utilities;
35

@@ -10,7 +12,7 @@ public class PublishNuget : FrostingTask<BuildContext>;
1012

1113
[TaskName(nameof(PublishNugetInternal))]
1214
[TaskDescription("Publish nuget packages")]
13-
public class PublishNugetInternal : FrostingTask<BuildContext>
15+
public class PublishNugetInternal : AsyncFrostingTask<BuildContext>
1416
{
1517
public override bool ShouldRun(BuildContext context)
1618
{
@@ -21,7 +23,7 @@ public override bool ShouldRun(BuildContext context)
2123
return shouldRun;
2224
}
2325

24-
public override void Run(BuildContext context)
26+
public override async Task RunAsync(BuildContext context)
2527
{
2628
// publish to github packages for commits on main and on original repo
2729
if (context.IsInternalPreRelease)
@@ -32,35 +34,147 @@ public override void Run(BuildContext context)
3234
{
3335
throw new InvalidOperationException("Could not resolve NuGet GitHub Packages API key.");
3436
}
37+
3538
PublishToNugetRepo(context, apiKey, Constants.GithubPackagesUrl);
3639
context.EndGroup();
3740
}
41+
3842
// publish to nuget.org for tagged releases
3943
if (context.IsStableRelease || context.IsTaggedPreRelease)
4044
{
4145
context.StartGroup("Publishing to Nuget.org");
42-
var apiKey = context.Credentials?.Nuget?.ApiKey;
46+
var apiKey = await GetNugetApiKey(context);
4347
if (string.IsNullOrEmpty(apiKey))
4448
{
4549
throw new InvalidOperationException("Could not resolve NuGet org API key.");
4650
}
51+
4752
PublishToNugetRepo(context, apiKey, Constants.NugetOrgUrl);
4853
context.EndGroup();
4954
}
5055
}
56+
5157
private static void PublishToNugetRepo(BuildContext context, string apiKey, string apiUrl)
5258
{
5359
ArgumentNullException.ThrowIfNull(context.Version);
5460
var nugetVersion = context.Version.NugetVersion;
5561
foreach (var (packageName, filePath, _) in context.Packages.Where(x => !x.IsChocoPackage))
5662
{
5763
context.Information($"Package {packageName}, version {nugetVersion} is being published.");
58-
context.DotNetNuGetPush(filePath.FullPath, new DotNetNuGetPushSettings
59-
{
60-
ApiKey = apiKey,
61-
Source = apiUrl,
62-
SkipDuplicate = true
63-
});
64+
context.DotNetNuGetPush(filePath.FullPath,
65+
new DotNetNuGetPushSettings
66+
{
67+
ApiKey = apiKey,
68+
Source = apiUrl,
69+
SkipDuplicate = true
70+
});
71+
}
72+
}
73+
74+
private static async Task<string?> GetNugetApiKey(BuildContext context)
75+
{
76+
try
77+
{
78+
var oidcToken = await GetGitHubOidcToken(context);
79+
var apiKey = await ExchangeOidcTokenForApiKey(oidcToken);
80+
81+
context.Information($"Successfully exchanged OIDC token for NuGet API key.");
82+
return apiKey;
83+
}
84+
catch (HttpRequestException ex)
85+
{
86+
context.Error($"Network error while retrieving NuGet API key: {ex.Message}");
87+
return null;
6488
}
89+
catch (InvalidOperationException ex)
90+
{
91+
context.Error($"Invalid operation while retrieving NuGet API key: {ex.Message}");
92+
return null;
93+
}
94+
catch (JsonException ex)
95+
{
96+
context.Error($"JSON parsing error while retrieving NuGet API key: {ex.Message}");
97+
return null;
98+
}
99+
}
100+
101+
private static async Task<string> GetGitHubOidcToken(BuildContext context)
102+
{
103+
const string nugetAudience = "https://www.nuget.org";
104+
105+
var oidcRequestToken = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
106+
var oidcRequestUrl = context.Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL");
107+
108+
if (string.IsNullOrEmpty(oidcRequestToken) || string.IsNullOrEmpty(oidcRequestUrl))
109+
throw new InvalidOperationException("Missing GitHub OIDC request environment variables.");
110+
111+
var tokenUrl = $"{oidcRequestUrl}&audience={Uri.EscapeDataString(nugetAudience)}";
112+
context.Information($"Requesting GitHub OIDC token from: {tokenUrl}");
113+
114+
using var http = new HttpClient();
115+
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcRequestToken);
116+
117+
var responseMessage = await http.GetAsync(tokenUrl);
118+
var tokenBody = await responseMessage.Content.ReadAsStringAsync();
119+
120+
if (!responseMessage.IsSuccessStatusCode)
121+
throw new Exception("Failed to retrieve OIDC token from GitHub.");
122+
123+
using var tokenDoc = JsonDocument.Parse(tokenBody);
124+
return ParseJsonProperty(tokenDoc, "value", "Failed to retrieve OIDC token from GitHub.");
125+
}
126+
127+
private static async Task<string> ExchangeOidcTokenForApiKey(string oidcToken)
128+
{
129+
const string nugetUsername = "gittoolsbot";
130+
const string nugetTokenServiceUrl = "https://www.nuget.org/api/v2/token";
131+
132+
var requestBody = JsonSerializer.Serialize(new { username = nugetUsername, tokenType = "ApiKey" });
133+
134+
using var tokenServiceHttp = new HttpClient();
135+
tokenServiceHttp.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", oidcToken);
136+
tokenServiceHttp.DefaultRequestHeaders.UserAgent.ParseAdd("nuget/login-action");
137+
using var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
138+
139+
var responseMessage = await tokenServiceHttp.PostAsync(nugetTokenServiceUrl, content);
140+
var exchangeBody = await responseMessage.Content.ReadAsStringAsync();
141+
142+
if (!responseMessage.IsSuccessStatusCode)
143+
{
144+
var errorMessage = BuildErrorMessage((int)responseMessage.StatusCode, exchangeBody);
145+
throw new Exception(errorMessage);
146+
}
147+
148+
using var respDoc = JsonDocument.Parse(exchangeBody);
149+
return ParseJsonProperty(respDoc, "apiKey", "Response did not contain \"apiKey\".");
150+
}
151+
152+
private static string ParseJsonProperty(JsonDocument document, string propertyName, string errorMessage)
153+
{
154+
if (!document.RootElement.TryGetProperty(propertyName, out var property) ||
155+
property.ValueKind != JsonValueKind.String)
156+
throw new Exception(errorMessage);
157+
158+
return property.GetString() ?? throw new Exception(errorMessage);
159+
}
160+
161+
private static string BuildErrorMessage(int statusCode, string responseBody)
162+
{
163+
var errorMessage = $"Token exchange failed ({statusCode})";
164+
try
165+
{
166+
using var errDoc = JsonDocument.Parse(responseBody);
167+
errorMessage +=
168+
errDoc.RootElement.TryGetProperty("error", out var errProp) &&
169+
errProp.ValueKind == JsonValueKind.String
170+
? $": {errProp.GetString()}"
171+
: $": {responseBody}";
172+
}
173+
catch (Exception)
174+
{
175+
errorMessage += $": {responseBody}";
176+
}
177+
178+
return errorMessage;
65179
}
66180
}

0 commit comments

Comments
 (0)