Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions src/release-notes/GitHubReleaseClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

namespace ReleaseNotes;
Expand All @@ -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.
/// </remarks>
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"));
Expand All @@ -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)
/// <summary>Creates the release for <paramref name="tagName"/>, or updates it in place if it already exists.</summary>
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<long?> 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)
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -94,7 +131,16 @@ internal sealed class GitHubNewLabelRequest
public required string Color { get; set; }
}

/// <summary>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.</summary>
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;
15 changes: 5 additions & 10 deletions src/release-notes/ReleaseNotesRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?> LocateOldVersion(ReleaseNotesConfig config, GitHubClient client)
Expand Down
Loading