From 20a02f2b146fd3bd88a2e6266de978b4bee7a6a9 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 3 Sep 2026 12:35:10 +0200 Subject: [PATCH] Also bypass Octokit for label creation/release update under Native AOT The previous fix (#32) assumed the AOT-unsafe Octokit serialization bug was specific to NewRelease.TagName's mixed get/private-set visibility. It isn't: the very next release run hit the identical failure mode on NewLabel.Name - a perfectly ordinary public get/set property on a completely unrelated model - creating version labels: Octokit.ApiValidationException: "name" wasn't supplied There's no reliable per-property rule to work around here: Octokit's SimpleJsonSerializer has zero AOT/trimming annotations, so which members the Native AOT compiler's reflection analysis keeps invocable is effectively unpredictable from the source alone. Given that, the only robust fix is to stop depending on Octokit's request serialization entirely for every write call this tool makes, not just release creation. Extends GitHubReleaseClient with UpdateRelease and CreateLabel alongside the existing CreateRelease, all backed by source-generated System.Text.Json (AOT-safe by construction). Read-only Octokit calls (GetLatest/Get, label/branch lookups) are untouched - those only need deserialization, which hasn't shown this failure mode. Verified both request shapes end-to-end against a local HTTP listener from a real native-AOT linux-x64 build: SERVER RECEIVED: {"tag_name":"0.14.2-aot-test","body":"hello"} SERVER RECEIVED: {"name":"v1.0.0","color":"e3e3e3"} Co-authored-by: Cursor --- src/release-notes/GitHubReleaseClient.cs | 84 ++++++++++++++++++------ src/release-notes/Labeler.cs | 5 +- src/release-notes/ReleaseNotesRunner.cs | 10 ++- 3 files changed, 72 insertions(+), 27 deletions(-) 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)