diff --git a/src/release-notes/GitHubReleaseClient.cs b/src/release-notes/GitHubReleaseClient.cs
index a4ccce9..7b59f1f 100644
--- a/src/release-notes/GitHubReleaseClient.cs
+++ b/src/release-notes/GitHubReleaseClient.cs
@@ -1,4 +1,5 @@
using System.Net.Http.Headers;
+using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace ReleaseNotes;
@@ -16,12 +17,20 @@ namespace ReleaseNotes;
/// "tag_name" wasn't supplied), then on NewLabel.Name, a perfectly ordinary public get/set property on an
/// unrelated model: 422 "name" wasn't supplied. Since there's no reliable per-property rule to work around,
/// and Octokit ships no JsonSerializerContext or other AOT-safe serialization path, every Octokit call that
-/// serializes a request body is suspect. Read-only calls (Get/GetLatest, label/branch lookups) only need
-/// deserialization, which hasn't shown this failure mode, so those keep using Octokit's GitHubClient.
+/// serializes a request body is suspect.
+///
+/// Deserialization turned out to be just as unreliable: the pre-check that's supposed to detect "release
+/// already exists" (client.Repository.Release.Get, deserializing Octokit's own Release model the same
+/// reflection way) silently failed under Native AOT too, so CreateRelease got called unconditionally on a
+/// tag that already had a release and got a 422 "already_exists" conflict from GitHub. Rather than chase
+/// which specific property breaks deserialization next, CreateRelease/CreateLabel are written as idempotent
+/// upserts: always attempt the write, and treat GitHub's "already exists" response as success (falling back
+/// to a minimal, source-generated-only GET to resolve the existing release's id for the update) instead of
+/// depending on a separate, unreliable existence check beforehand.
///
internal static class GitHubReleaseClient
{
- private static HttpRequestMessage BuildRequest(HttpMethod method, string owner, string repository, string path, HttpContent content, string? token)
+ private static HttpRequestMessage BuildRequest(HttpMethod method, string owner, string repository, string path, HttpContent? content, string? token)
{
var request = new HttpRequestMessage(method, $"https://api.github.com/repos/{owner}/{repository}/{path}") { Content = content };
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
@@ -32,23 +41,51 @@ private static HttpRequestMessage BuildRequest(HttpMethod method, string owner,
return request;
}
- private static async Task Send(HttpClient httpClient, HttpRequestMessage request, string errorContext)
+ private static async Task Send(HttpClient httpClient, HttpRequestMessage request, string errorContext, bool ignoreAlreadyExists = false)
{
using var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
+ // Labeler.Create's own existence check (Get-then-Create) is inherently racy against concurrent
+ // runs (e.g. a "push to master" build and a tag-triggered release both ensuring the same
+ // "next major version" label exists at nearly the same time) - treat GitHub's 422 for a label
+ // that already exists as success rather than failing the whole release over harmless bookkeeping.
+ if (ignoreAlreadyExists && response.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity && responseBody.Contains("already_exists"))
+ return;
throw new InvalidOperationException($"{errorContext}: {(int)response.StatusCode} {response.StatusCode}\n{responseBody}");
}
}
- public static Task CreateRelease(HttpClient httpClient, string owner, string repository, string tagName, string? body, string? token)
+ /// Creates the release for , or updates it in place if it already exists.
+ public static async Task CreateOrUpdateRelease(HttpClient httpClient, string owner, string repository, string tagName, string? body, string? token)
{
var content = System.Net.Http.Json.JsonContent.Create(
new GitHubNewReleaseRequest { TagName = tagName, Body = body },
GitHubJsonContext.Default.GitHubNewReleaseRequest);
var request = BuildRequest(HttpMethod.Post, owner, repository, "releases", content, token);
- return Send(httpClient, request, $"Failed to create GitHub release for tag '{tagName}' on {owner}/{repository}");
+ using var response = await httpClient.SendAsync(request);
+ if (response.IsSuccessStatusCode)
+ return;
+
+ var responseBody = await response.Content.ReadAsStringAsync();
+ if (response.StatusCode != System.Net.HttpStatusCode.UnprocessableEntity || !responseBody.Contains("already_exists"))
+ throw new InvalidOperationException($"Failed to create GitHub release for tag '{tagName}' on {owner}/{repository}: {(int)response.StatusCode} {response.StatusCode}\n{responseBody}");
+
+ var releaseId = await GetReleaseIdByTag(httpClient, owner, repository, tagName, token)
+ ?? throw new InvalidOperationException($"GitHub reports release for tag '{tagName}' on {owner}/{repository} already exists, but it couldn't be found by tag to update its body.");
+ await UpdateRelease(httpClient, owner, repository, releaseId, body, token);
+ }
+
+ private static async Task GetReleaseIdByTag(HttpClient httpClient, string owner, string repository, string tagName, string? token)
+ {
+ var request = BuildRequest(HttpMethod.Get, owner, repository, $"releases/tags/{tagName}", content: null, token);
+ using var response = await httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ var release = await response.Content.ReadFromJsonAsync(GitHubJsonContext.Default.GitHubReleaseSummary);
+ return release?.Id;
}
public static Task UpdateRelease(HttpClient httpClient, string owner, string repository, long releaseId, string? body, string? token)
@@ -66,7 +103,7 @@ public static Task CreateLabel(HttpClient httpClient, string owner, string repos
new GitHubNewLabelRequest { Name = name, Color = color },
GitHubJsonContext.Default.GitHubNewLabelRequest);
var request = BuildRequest(HttpMethod.Post, owner, repository, "labels", content, token);
- return Send(httpClient, request, $"Failed to create GitHub label '{name}' on {owner}/{repository}");
+ return Send(httpClient, request, $"Failed to create GitHub label '{name}' on {owner}/{repository}", ignoreAlreadyExists: true);
}
}
@@ -94,7 +131,16 @@ internal sealed class GitHubNewLabelRequest
public required string Color { get; set; }
}
+/// Just enough of GitHub's release response to resolve an id from a tag name - deliberately not
+/// Octokit's own Release model, to avoid its unreliable-under-AOT deserialization.
+internal sealed class GitHubReleaseSummary
+{
+ [JsonPropertyName("id")]
+ public long Id { get; set; }
+}
+
[JsonSerializable(typeof(GitHubNewReleaseRequest))]
[JsonSerializable(typeof(GitHubReleaseUpdateRequest))]
[JsonSerializable(typeof(GitHubNewLabelRequest))]
+[JsonSerializable(typeof(GitHubReleaseSummary))]
internal partial class GitHubJsonContext : JsonSerializerContext;
diff --git a/src/release-notes/ReleaseNotesRunner.cs b/src/release-notes/ReleaseNotesRunner.cs
index 9058ec1..c91e903 100644
--- a/src/release-notes/ReleaseNotesRunner.cs
+++ b/src/release-notes/ReleaseNotesRunner.cs
@@ -44,17 +44,12 @@ private static async Task CreateRelease(ReleaseNotesConfig config, GitHubClient
foreach (var f in files)
body.AppendLine(await File.ReadAllTextAsync(f));
- // Not client.Repository.Release.Create()/Edit() - see GitHubReleaseClient's remarks for why Octokit's
- // own request serialization is unreliable under Native AOT.
+ // Not client.Repository.Release.Get()/Create()/Edit() - see GitHubReleaseClient's remarks for why
+ // both Octokit's request serialization and its response deserialization are unreliable under Native
+ // AOT. CreateOrUpdateRelease is a self-contained upsert; it doesn't need (or trust) a separate
+ // existence check first.
using var httpClient = new HttpClient();
- var existing = await ReleaseExists(config, client, config.Version);
- if (existing is not null)
- {
- Console.WriteLine("Found release");
- await GitHubReleaseClient.UpdateRelease(httpClient, config.GitHub.Owner, config.GitHub.Repository, existing.Id, body.ToString(), config.Token);
- }
- else
- await GitHubReleaseClient.CreateRelease(httpClient, config.GitHub.Owner, config.GitHub.Repository, config.Version, body.ToString(), config.Token);
+ await GitHubReleaseClient.CreateOrUpdateRelease(httpClient, config.GitHub.Owner, config.GitHub.Repository, config.Version, body.ToString(), config.Token);
}
private static async Task LocateOldVersion(ReleaseNotesConfig config, GitHubClient client)