diff --git a/src/release-notes/GitHubReleaseClient.cs b/src/release-notes/GitHubReleaseClient.cs index 6f163f9..a4ccce9 100644 --- a/src/release-notes/GitHubReleaseClient.cs +++ b/src/release-notes/GitHubReleaseClient.cs @@ -4,43 +4,70 @@ namespace ReleaseNotes; /// -/// Minimal, source-generated-JSON client for creating a GitHub release directly against the REST API. +/// Minimal, source-generated-JSON client for the GitHub REST calls that write data (create/update releases, +/// create labels), bypassing Octokit for those specific calls. /// /// -/// Octokit.Repository.Release.Create can't be used here: Octokit's SimpleJsonSerializer serializes request -/// bodies via raw, unannotated reflection (no [DynamicallyAccessedMembers] anywhere in Octokit/SimpleJson.cs), -/// and under Native AOT trimming the linker strips getter metadata for any property whose accessors have -/// mixed visibility. NewRelease.TagName is "public get; private set;" - the only such property among every -/// request model this tool sends - so in an AOT-published build tag_name silently vanishes from the outgoing -/// JSON while every other field (all plain public get/set) still serializes fine. GitHub then rejects the -/// request with 422 "tag_name" wasn't supplied, even though the C# call site clearly set it. Everything else -/// this tool does through Octokit (GetLatest/Get, ReleaseUpdate, labels) uses only plain public get/set -/// models, so those aren't affected - only the Create path needs this workaround. +/// Octokit's SimpleJsonSerializer serializes request bodies via raw, unannotated reflection (no +/// [DynamicallyAccessedMembers] anywhere in Octokit/SimpleJson.cs). Under Native AOT, the ILC compiler's +/// reflection analysis decides - per member, based on a whole-program heuristic with no annotations to guide +/// it - which property getters get an invocable reflection stub; it isn't simply "properties with a +/// non-public accessor get dropped". We first hit this on NewRelease.TagName (public get, private set: 422 +/// "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. /// internal static class GitHubReleaseClient { - public static async Task CreateRelease(HttpClient httpClient, string owner, string repository, string tagName, string? body, string? token) + private static HttpRequestMessage BuildRequest(HttpMethod method, string owner, string repository, string path, HttpContent content, string? token) { - using var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.github.com/repos/{owner}/{repository}/releases") - { - Content = System.Net.Http.Json.JsonContent.Create( - new GitHubNewReleaseRequest { TagName = tagName, Body = body }, - GitHubReleaseJsonContext.Default.GitHubNewReleaseRequest) - }; + 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")); request.Headers.UserAgent.Add(new ProductInfoHeaderValue("ReleaseNotesGenerator", "1.0")); request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); if (token is { Length: > 0 }) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return request; + } + private static async Task Send(HttpClient httpClient, HttpRequestMessage request, string errorContext) + { using var response = await httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { var responseBody = await response.Content.ReadAsStringAsync(); - throw new InvalidOperationException( - $"Failed to create GitHub release for tag '{tagName}' on {owner}/{repository}: {(int)response.StatusCode} {response.StatusCode}\n{responseBody}"); + 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) + { + 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}"); + } + + public static Task UpdateRelease(HttpClient httpClient, string owner, string repository, long releaseId, string? body, string? token) + { + var content = System.Net.Http.Json.JsonContent.Create( + new GitHubReleaseUpdateRequest { Body = body }, + GitHubJsonContext.Default.GitHubReleaseUpdateRequest); + var request = BuildRequest(HttpMethod.Patch, owner, repository, $"releases/{releaseId}", content, token); + return Send(httpClient, request, $"Failed to update GitHub release {releaseId} on {owner}/{repository}"); + } + + public static Task CreateLabel(HttpClient httpClient, string owner, string repository, string name, string color, string? token) + { + var content = System.Net.Http.Json.JsonContent.Create( + 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}"); + } } internal sealed class GitHubNewReleaseRequest @@ -52,5 +79,22 @@ internal sealed class GitHubNewReleaseRequest public string? Body { get; set; } } +internal sealed class GitHubReleaseUpdateRequest +{ + [JsonPropertyName("body")] + public string? Body { get; set; } +} + +internal sealed class GitHubNewLabelRequest +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("color")] + public required string Color { get; set; } +} + [JsonSerializable(typeof(GitHubNewReleaseRequest))] -internal partial class GitHubReleaseJsonContext : JsonSerializerContext; +[JsonSerializable(typeof(GitHubReleaseUpdateRequest))] +[JsonSerializable(typeof(GitHubNewLabelRequest))] +internal partial class GitHubJsonContext : JsonSerializerContext; diff --git a/src/release-notes/Labeler.cs b/src/release-notes/Labeler.cs index 7378531..1b063e4 100644 --- a/src/release-notes/Labeler.cs +++ b/src/release-notes/Labeler.cs @@ -20,7 +20,10 @@ private static async Task Create(ReleaseNotesConfig config, GitHubClient client, // Label does not exist yet; fall through and create it. } - await client.Issue.Labels.Create(config.GitHub.Owner, config.GitHub.Repository, new NewLabel(label, config.LabelColor)); + // Not client.Issue.Labels.Create() - see GitHubReleaseClient's remarks for why Octokit's own request + // serialization is unreliable under Native AOT. + using var httpClient = new HttpClient(); + await GitHubReleaseClient.CreateLabel(httpClient, config.GitHub.Owner, config.GitHub.Repository, label, config.LabelColor, config.Token); } private static async Task ExistsBranch(ReleaseNotesConfig config, GitHubClient client, string branch) diff --git a/src/release-notes/ReleaseNotesRunner.cs b/src/release-notes/ReleaseNotesRunner.cs index 233426f..9058ec1 100644 --- a/src/release-notes/ReleaseNotesRunner.cs +++ b/src/release-notes/ReleaseNotesRunner.cs @@ -44,19 +44,17 @@ 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. + using var httpClient = new HttpClient(); var existing = await ReleaseExists(config, client, config.Version); if (existing is not null) { Console.WriteLine("Found release"); - await client.Repository.Release.Edit(config.GitHub.Owner, config.GitHub.Repository, existing.Id, new ReleaseUpdate { Body = body.ToString() }); + await GitHubReleaseClient.UpdateRelease(httpClient, config.GitHub.Owner, config.GitHub.Repository, existing.Id, body.ToString(), config.Token); } else - { - // Not client.Repository.Release.Create() - see GitHubReleaseClient's remarks for why Octokit's - // own NewRelease serialization silently drops tag_name under Native AOT. - using var httpClient = new HttpClient(); await GitHubReleaseClient.CreateRelease(httpClient, config.GitHub.Owner, config.GitHub.Repository, config.Version, body.ToString(), config.Token); - } } private static async Task LocateOldVersion(ReleaseNotesConfig config, GitHubClient client)