Skip to content

Fix GitHub release creation silently dropping tag_name under Native AOT - #32

Merged
Mpdreamz merged 1 commit into
masterfrom
fix/aot-safe-release-creation
Sep 3, 2026
Merged

Mpdreamz merged 1 commit into
masterfrom
fix/aot-safe-release-creation

Conversation

@Mpdreamz

@Mpdreamz Mpdreamz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

Creating a GitHub release (create-release) from the AOT-published release-notes tool has been failing on GitHub's side with:

Octokit.ApiValidationException: {"message":"Invalid request.\n\n\"tag_name\" wasn't supplied.","status":"422"}

even though the call site (new NewRelease(config.Version)) clearly sets it, and config.Version is confirmed non-empty.

Root cause

Octokit.Internal.SimpleJsonSerializer serializes request bodies via raw, completely unannotated reflection (type.GetRuntimeProperties(), no [DynamicallyAccessedMembers] anywhere in Octokit/SimpleJson.cs or its reflection helpers). Under Native AOT trimming, the linker can't statically prove which reflected members are needed, so it strips metadata for properties it can't safely classify — in practice, this hits any property with mixed accessor visibility.

NewRelease.TagName is public string TagName { get; private set; } — the only property, across every request model this tool sends (NewRelease, ReleaseUpdate, NewLabel, LabelUpdate), with a non-public accessor. Every other property involved is plain { get; set; } and survives trimming fine.

I confirmed the mechanism directly: publishing a minimal repro with IL trimming enabled and calling SimpleJsonSerializer.Serialize(new NewRelease(...)) throws:

System.NullReferenceException
   at Octokit.PropertyOrField..ctor(PropertyInfo propertyInfo)
   at Octokit.Internal.SimpleJsonSerializer.GitHubSerializerStrategy.GetterValueFactory(Type type)

because GetGetterMethodInfo(propertyInfo) returns null for TagName once its getter metadata is trimmed. The fully AOT-compiled build (as opposed to plain IL trimming) degrades more gracefully and just silently omits the field from the JSON instead of crashing — which is exactly what GitHub's 422 response showed.

This is unrelated to the Octokit version — 14.0.0 is already latest on NuGet, and the bug is structural (no JsonSerializerContext/source-gen support in Octokit's request serialization at all).

Fix

Bypass Octokit only for the Create call — the one place this tool serializes a model with a mixed-visibility property. GitHubReleaseClient.CreateRelease POSTs directly via HttpClient, using a source-generated System.Text.Json.JsonSerializerContext (fully AOT-safe by construction, no reflection at all).

Everything else keeps using Octokit's GitHubClient unchanged (GetLatest/Get for checking existing releases, Repository.Release.Edit with ReleaseUpdate, and the labeling calls) — those request/response models are all plain public get/set, so they aren't affected by this trimming behavior.

Verification

Built a standalone repro of the exact JsonContent.Create(..., JsonSerializerContext) pattern used here, published it fully native-AOT for linux-x64 in a clean mcr.microsoft.com/dotnet/sdk:10.0 container, and posted it against a local HttpListener:

SERVER RECEIVED: {"tag_name":"0.14.1-aot-test","body":"hello"}
STATUS: Created

confirming tag_name now survives AOT compilation end-to-end.

Also included

Rebased onto current master, which already carries the .any-package generateApiChanges fix from a previous PR (turned out cherry-picking it here was a no-op — it's already there).

Made with Cursor

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 <cursoragent@cursor.com>
@Mpdreamz
Mpdreamz merged commit 4582844 into master Sep 3, 2026
2 checks passed
@Mpdreamz
Mpdreamz deleted the fix/aot-safe-release-creation branch September 3, 2026 10:03
Mpdreamz added a commit that referenced this pull request Sep 3, 2026
#33)

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 <cursoragent@cursor.com>
Mpdreamz added a commit that referenced this pull request Sep 3, 2026
…#34)

The 'does this release already exist' pre-check (client.Repository.Release.Get,
deserializing Octokit's own Release model via the same unannotated reflection
covered in #32/#33) is just as unreliable under Native AOT as the write side
was. In the very next real release after #33, it silently failed, so
CreateRelease ran unconditionally against a tag that already had a release
and got:

    Octokit.ApiValidationException: {"code":"already_exists","field":"tag_name"}

Rather than keep chasing which specific Octokit call breaks next,
CreateOrUpdateRelease is now a self-contained upsert: always POST the
release, and only on a 422 already_exists response resolve the existing
release's id (via a minimal, source-generated-only GET - not Octokit's
Release model) and PATCH it. No separate existence check, so nothing to be
unreliable about.

Verified the full create -> 422 already_exists -> GET id -> PATCH sequence
end-to-end against a local HTTP listener from a real native-AOT linux-x64
build.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant