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)