Skip to content

Ship assembly-differ as a native-AOT dotnet tool - #41

Merged
Mpdreamz merged 5 commits into
masterfrom
aot-tool-packaging
Sep 2, 2026
Merged

Mpdreamz merged 5 commits into
masterfrom
aot-tool-packaging

Conversation

@Mpdreamz

@Mpdreamz Mpdreamz commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

assembly-differ ships as a self-contained native-AOT executable on five platforms, with its CLI now parsed by Nullean.Argh instead of Mono.Options.

Prompt summary: Modernize this repo's build tooling the way curb and mermaider already do — package the tool as native-AOT dotnet tools per-RID with a framework-dependent fallback, and move CLI parsing onto Nullean.Argh everywhere instead of the mix of parsers the sibling tool repos had accumulated.

Why

assembly-differ only shipped as a framework-dependent tool: every invocation paid a JIT warmup cost, and installing it required the matching .NET runtime already present. dotnet pack also had a latent bug where the unsigned obj/ build of this project's own assembly, not the correctly signed bin/ one, ended up inside the portable any package — nupkg-validator's signature check silently checked the wrong bytes. CLI parsing used Mono.Options, a hand-rolled reflection-free parser with no source generation and an unused Argu reference left over from an earlier attempt.

What

Native-AOT packaging

assembly-differ.csproj gains linux-x64, linux-arm64, win-x64, win-arm64, and osx-arm64 as RuntimeIdentifiers, with PublishAot, PublishSelfContained, and StripSymbols scoped to builds that target a specific RID so the managed any fallback keeps its signed assembly. NuGet.Protocol and Newtonsoft.Json are rooted for the trimmer — the nuget/previous-nuget providers go through NuGet.Protocol's repository-signature check, which needs Newtonsoft.Json's reflection-based constructor resolution that trimming would otherwise strip.

CLI parsing

DiffCommand.cs replaces Program.cs's Mono.Options.OptionSet wiring with a Nullean.Argh command registered via MapAndRootAlias, so diff can still be omitted when a flag precedes the two provider arguments. --prevent-change stays string-typed and is parsed by hand with Enum.Parse — Argh 0.18.0 fails to compile an enum parameter whose default member has a non-zero underlying value (SuggestedVersionChange.None = 1), filed as nullean/argh#75. The net8.0 leg also needed <LangVersion>latest</LangVersion>: the generated parser uses a C# 13 feature (ref/unsafe in async iterators) that net8.0's default language version doesn't support.

Package staging and signing

generatePackages now runs a full dotnet pack into a staging directory and keeps only the root and any packages — the real per-RID packages come from CI, each compiled on a matching runner, since AOT can't cross-compile. A new fixAnyPackageSigning helper re-signs the any package's assembly, replacing the unsigned obj/ copy dotnet pack picks up with the signed one from bin/. validatePackages now points at the any package specifically and checks against currentVersionInformational, which includes the git SHA suffix modern SDKs append.

CI

.github/workflows/ci.yml adds an aot-pack matrix job that builds each RID package on a matching runner, ahead of the build job, which downloads and merges the per-RID artifacts before packages get published. actions/checkout and actions/setup-dotnet move to v5.

Dependencies

NuGet.Frameworks/Versioning/Protocol/Packaging/Resolver/Common move from 6.13.1 to 7.9.0 — 7.9.0's NuGet.Protocol drops its direct Newtonsoft.Json dependency for net8.0+ in favor of System.Text.Json, though the trimmer-root workaround above is still needed since Newtonsoft.Json still arrives transitively through NuGet.Packaging. The unused Argu package reference is removed — this project's CLI was never actually parsed with Argu.

Breaking

Mono.Options had no subcommand concept, so assembly-differ was invoked as a bare <old> <new> [options]. Argh needs a diff subcommand unless an option precedes the two provider arguments — a provider spec as the very first argument (assembly-differ "nuget|..." "nuget|...") now fails and needs assembly-differ diff "nuget|..." "nuget|...". --target also moves from a single comma/pipe-separated value (--target a,b) to a repeatable flag (--target a --target b).

Out of scope

nupkg-validator, release-notes, and assembly-rewriter each pin an older, pre-Argh assembly-differ release in their dotnet-tools.json and call it positional-first. Bumping those pins and adding diff to their invocations happens in a follow-up once this ships, so their CI doesn't break against a tool version they don't yet depend on.

Verify

./build.sh build generatepackages validatepackages generateapichanges -s true
dotnet tool install --add-source build/output assembly-differ
assembly-differ diff "directory|src/assembly-differ/bin/Release/net10.0" "directory|src/assembly-differ/bin/Release/net10.0" --target assembly-differ

Mpdreamz and others added 2 commits September 2, 2026 17:08
Ships assembly-differ as a self-contained native-AOT executable on
linux-x64/arm64, win-x64/arm64 and osx-arm64, alongside a
framework-dependent 'any' fallback for everywhere else, following the
same root+RID-package pattern used by curb/mermaider.

- assembly-differ.csproj: RuntimeIdentifiers list, AOT properties
  scoped to RID-specific builds only (so the managed 'any'/root build
  keeps its signed assembly), and TrimmerRootAssembly entries for
  NuGet.Protocol/Newtonsoft.Json, whose reflection-based constructor
  resolution the trimmer otherwise breaks for the nuget/previous-nuget
  providers.
- Targets.fs/Paths.fs: generatePackages now stages a full pack and
  keeps only the root and 'any' packages (real per-RID packages come
  from CI, compiled on a matching runner); validatePackages skips
  per-RID packages, which carry no managed assembly to check.
- ci.yml: aot-pack matrix job (linux-x64 only on PRs, all five RIDs on
  push) uploads per-RID packages that the build job downloads and
  merges into build/output before publishing.

Verified end-to-end: AOT build's nuget-provider diff output matches
the non-AOT build exactly, and `dotnet tool install` from a local feed
correctly resolves and runs the native binary.

Co-authored-by: Cursor <cursoragent@cursor.com>
dotnet pack copies the unsigned obj/ build of this project's own assembly
into the portable 'any' tool package once RuntimeIdentifiers is declared,
even though bin/ has the correctly signed one. Patch the signed dll back
in after packing, and make validatepackages target the any package
explicitly instead of "most recently created of {root, any}", which was
non-deterministic since the root package (a bare DotnetToolSettings.xml
pointer with no dll) doesn't reliably sort after the any package.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mpdreamz and others added 3 commits September 2, 2026 18:36
7.9.0's NuGet.Protocol drops the direct Newtonsoft.Json dependency for
net8.0+ in favor of System.Text.Json, moving closer to full AOT/trim
compatibility (NuGet/Home#14845). Newtonsoft.Json is still pulled in
transitively via NuGet.Packaging and used by a few reflection-based
protocol paths (plugin messages, repository signature metadata), so the
existing TrimmerRootAssembly workaround stays for now.

Co-authored-by: Cursor <cursoragent@cursor.com>
Argu isn't referenced anywhere in the source — CLI parsing here has
always gone through Mono.Options's OptionSet, not Argu or Argh. Dead
weight left over from history.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the hand-rolled Mono.Options.OptionSet parsing with
Nullean.Argh, matching nupkg-validator, release-notes, and
assembly-rewriter. Registers the diff command via MapAndRootAlias so
the explicit `diff` subcommand can still be omitted when an option
precedes the two provider arguments; a provider spec as the very first
argument still needs it, same caveat as nupkg-validator.

- --target moves from a single comma/pipe-separated value to a
  repeatable flag.
- --prevent-change stays string-typed and is parsed manually: Argh
  0.18.0 fails to compile an enum parameter whose default member has a
  non-zero underlying value (SuggestedVersionChange.None = 1), see
  nullean/argh#75.
- Needs <LangVersion>latest</LangVersion> for net8.0: the generated
  code uses a C# 13 feature (ref/unsafe in async iterators) that
  net8.0's default LangVersion (12) doesn't support.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Mpdreamz Mpdreamz added the enhancement New feature or request label Sep 2, 2026
@Mpdreamz Mpdreamz changed the title Add native-AOT tool packaging Ship assembly-differ as a native-AOT dotnet tool Sep 2, 2026
@Mpdreamz
Mpdreamz merged commit 519f549 into master Sep 2, 2026
2 checks passed
@Mpdreamz
Mpdreamz deleted the aot-tool-packaging branch September 2, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant