From a347de4eb7c7e7d463f38288cfd9ac6218bb65c9 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 3 Sep 2026 11:59:00 +0200 Subject: [PATCH] Fix GitHub release creation silently dropping tag_name under Native AOT Octokit's SimpleJsonSerializer serializes request bodies via raw, unannotated reflection (no [DynamicallyAccessedMembers] anywhere in Octokit/SimpleJson.cs). 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 the AOT-published tool tag_name silently vanished from the outgoing JSON while every other field (all plain public get/set) still serialized fine. GitHub then rejected the request with 422 "tag_name" wasn't supplied, even though the call site clearly set it. Confirmed by reproducing with a minimal repro: trimming Octokit throws a NullReferenceException resolving TagName's getter once its metadata is stripped; the fully AOT-compiled build degrades more gracefully and just omits the field instead of crashing. Bypasses Octokit only for the Create call - the one place a mixed-visibility property model gets serialized - via a direct HttpClient POST using a source-generated System.Text.Json context (fully AOT-safe by construction). Verified end-to-end against a local HTTP listener from a real native-AOT linux-x64 build: tag_name is now present in the request body. Everything else (GetLatest/Get, ReleaseUpdate, labels) keeps using Octokit since those models are all plain public get/set and aren't affected. Co-authored-by: Cursor --- src/release-notes/GitHubReleaseClient.cs | 56 ++++++++++++++++++++++++ src/release-notes/ReleaseNotesRunner.cs | 7 ++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/release-notes/GitHubReleaseClient.cs diff --git a/src/release-notes/GitHubReleaseClient.cs b/src/release-notes/GitHubReleaseClient.cs new file mode 100644 index 0000000..6f163f9 --- /dev/null +++ b/src/release-notes/GitHubReleaseClient.cs @@ -0,0 +1,56 @@ +using System.Net.Http.Headers; +using System.Text.Json.Serialization; + +namespace ReleaseNotes; + +/// +/// Minimal, source-generated-JSON client for creating a GitHub release directly against the REST API. +/// +/// +/// 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. +/// +internal static class GitHubReleaseClient +{ + public static async Task CreateRelease(HttpClient httpClient, string owner, string repository, string tagName, string? body, 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) + }; + 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); + + 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}"); + } + } +} + +internal sealed class GitHubNewReleaseRequest +{ + [JsonPropertyName("tag_name")] + public required string TagName { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } +} + +[JsonSerializable(typeof(GitHubNewReleaseRequest))] +internal partial class GitHubReleaseJsonContext : JsonSerializerContext; diff --git a/src/release-notes/ReleaseNotesRunner.cs b/src/release-notes/ReleaseNotesRunner.cs index 2412389..233426f 100644 --- a/src/release-notes/ReleaseNotesRunner.cs +++ b/src/release-notes/ReleaseNotesRunner.cs @@ -51,7 +51,12 @@ private static async Task CreateRelease(ReleaseNotesConfig config, GitHubClient await client.Repository.Release.Edit(config.GitHub.Owner, config.GitHub.Repository, existing.Id, new ReleaseUpdate { Body = body.ToString() }); } else - await client.Repository.Release.Create(config.GitHub.Owner, config.GitHub.Repository, new NewRelease(config.Version) { Body = body.ToString() }); + { + // 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)