Skip to content

Commit 711b630

Browse files
authored
Merge pull request #4729 from arturcic/feature/trusted-publishing
publish nuget packages using Trusted Publishing
2 parents 164f981 + 211c840 commit 711b630

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)