From 8c08456d8dd8deccca98e6e8a8c8162c4b6a11c5 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 2 Sep 2026 18:31:52 +0200 Subject: [PATCH] Modernize to net8.0/net10.0, port to Nullean.Argh, add native-AOT packaging CommandLineParser's reflection-based parsing is trimmed away under NativeAOT, so a published AOT build of the old CLI silently failed to recognize its own arguments. Porting to Nullean.Argh (source-generated, no reflection at parse time) unblocks AOT packaging the same way curb, assembly-differ, and nupkg-validator already ship it: one native-AOT package per RID plus a framework-dependent 'any' fallback, selected transparently by the root package's DotnetToolSettings.xml. Also bumps the pinned nupkg-validator and assembly-differ build tools to versions that can actually validate/diff net8.0+ output (the old pins predate those TFMs and either fail to load the assembly or mis-resolve its framework folder), and modernizes the CI workflow with an AOT pack matrix job. Co-authored-by: Cursor --- .github/workflows/ci.yml | 56 +++++++-- build/scripts/Paths.fs | 8 ++ build/scripts/Targets.fs | 65 +++++++++- build/scripts/scripts.fsproj | 2 +- dotnet-tools.json | 4 +- global.json | 4 +- .../AssemblyRewriterCommand.cs | 86 ++++++++++++++ src/assembly-rewriter/Options.cs | 9 +- src/assembly-rewriter/Program.cs | 111 +----------------- .../assembly-rewriter.csproj | 29 ++++- 10 files changed, 236 insertions(+), 138 deletions(-) create mode 100644 src/assembly-rewriter/AssemblyRewriterCommand.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bde1ea9..66bb1de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,25 +15,54 @@ on: - "*.*.*" jobs: + # On pull requests, only linux-x64 runs, to prove AOT still links without burning the full matrix + # on packages nobody sees. All five run on push, where the packages are actually uploaded. + aot-pack: + runs-on: ${{ matrix.runner }} + name: AOT pack (${{ matrix.rid }}) + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(github.event_name == 'pull_request' + && '[{"rid":"linux-x64","runner":"ubuntu-latest"}]' + || '[{"rid":"linux-x64","runner":"ubuntu-latest"},{"rid":"linux-arm64","runner":"ubuntu-24.04-arm"},{"rid":"win-x64","runner":"windows-latest"},{"rid":"win-arm64","runner":"windows-11-arm"},{"rid":"osx-arm64","runner":"macos-latest"}]') }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + + - run: dotnet pack src/assembly-rewriter/assembly-rewriter.csproj -c Release -r ${{ matrix.rid }} -o build/output + name: Pack native-AOT tool for ${{ matrix.rid }} + shell: bash + + - name: Upload per-RID package + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: nupkg-${{ matrix.rid }} + path: build/output/*.nupkg + if-no-files-found: error + build: runs-on: ubuntu-latest + needs: aot-pack steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 with: fetch-depth: 1 - run: | git fetch --prune --unshallow --tags echo exit code $? git tag --list - - uses: actions/setup-dotnet@v1 + - uses: actions/setup-dotnet@v5 with: - dotnet-version: | - 5.0.x - 6.0.x - - uses: actions/setup-dotnet@v1 - with: - dotnet-version: '6.0.302' - source-url: https://nuget.pkg.github.com/nullean/index.json + dotnet-version: | + 10.0.x + source-url: https://nuget.pkg.github.com/nullean/index.json env: NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} @@ -41,6 +70,15 @@ jobs: name: Build - run: ./build.sh generatepackages -s true name: Generate local nuget packages + + - name: Download per-RID AOT packages + if: github.event_name == 'push' + uses: actions/download-artifact@v4 + with: + pattern: nupkg-* + path: build/output + merge-multiple: true + - run: ./build.sh validatepackages -s true name: "validate *.npkg files that were created" - run: ./build.sh generateapichanges -s true diff --git a/build/scripts/Paths.fs b/build/scripts/Paths.fs index cb1d68a..2056868 100644 --- a/build/scripts/Paths.fs +++ b/build/scripts/Paths.fs @@ -6,6 +6,14 @@ open System.IO let ToolName = "assembly-rewriter" let Repository = sprintf "nullean/%s" ToolName +/// The RIDs we ship native-AOT tool packages for. AOT compilation requires a matching +/// OS/arch, so CI packs one RID per runner; this list only documents the set. +let AotRuntimeIdentifiers = ["linux-x64"; "linux-arm64"; "win-x64"; "win-arm64"; "osx-arm64"] + +/// Must mirror assembly-rewriter.csproj's TargetFrameworks. Used to patch the signed managed dll back +/// into the packed 'any' fallback for every TFM it ships — see fixAnyPackageSigning in Targets.fs. +let ManagedTargetFrameworks = ["net8.0"; "net10.0"] + let Root = let mutable dir = DirectoryInfo(".") while dir.GetFiles("*.sln").Length = 0 do dir <- dir.Parent diff --git a/build/scripts/Targets.fs b/build/scripts/Targets.fs index 46ca1c4..bd6d330 100644 --- a/build/scripts/Targets.fs +++ b/build/scripts/Targets.fs @@ -3,6 +3,7 @@ module Targets open Argu open System open System.IO +open System.IO.Compression open Bullseye open CommandLine open Fake.Tools.Git @@ -22,6 +23,9 @@ let private currentVersion = o.Line ) +let private currentVersionInformational = + lazy (sprintf "%s+%s" currentVersion.Value (Information.getCurrentSHA1 ".")) + let private clean (arguments:ParseResults) = if (Paths.Output.Exists) then Paths.Output.Delete (true) exec "dotnet" ["clean"] |> ignore @@ -33,15 +37,64 @@ let private pristineCheck (arguments:ParseResults) = | true -> printfn "The checkout folder does not have pending changes, proceeding" | _ -> failwithf "The checkout folder has pending changes, aborting" +let private isPerRidPackage (name: string) = + Paths.AotRuntimeIdentifiers |> List.exists (fun rid -> name.Contains(sprintf ".%s." rid)) + +/// `dotnet pack`'s RID-aware tool-packaging path (used once RuntimeIdentifiers is declared) copies +/// the *unsigned* obj/ build of this project's own assembly into the portable 'any' package, even +/// though the normal bin/ output is correctly strong-name signed — a long-standing obj-vs-bin mixup +/// in `dotnet pack` (see https://github.com/dotnet/sdk/issues/20197) that resurfaces here. Patched in +/// place after packing by swapping in the signed bin/ copies for every TFM the 'any' package ships. +let private fixAnyPackageSigning (anyPackagePath: string) = + use archive = ZipFile.Open(anyPackagePath, ZipArchiveMode.Update) + for tfm in Paths.ManagedTargetFrameworks do + let entryName = sprintf "tools/%s/any/%s.dll" tfm Paths.ToolName + let signedDll = Path.Combine(Paths.ToolProject.FullName, "bin", "Release", tfm, sprintf "%s.dll" Paths.ToolName) + match archive.GetEntry(entryName), File.Exists signedDll with + | null, _ | _, false -> () + | entry, true -> + entry.Delete() + let newEntry = archive.CreateEntry(entryName) + use entryStream = newEntry.Open() + use fileStream = File.OpenRead(signedDll) + fileStream.CopyTo(entryStream) + let private generatePackages (arguments:ParseResults) = let output = Paths.RootRelative Paths.Output.FullName - exec "dotnet" ["pack"; "-c"; "Release"; "-o"; output] |> ignore - + if not Paths.Output.Exists then Paths.Output.Create() + + // A plain `dotnet pack` emits the root package (whose DotnetToolSettings.xml v2 maps each RID to + // its own package) AND a package per RID — but native AOT can only compile for the machine it + // runs on, so those per-RID outputs from a single machine are self-contained MANAGED builds, + // silently missing the AOT compilation. We therefore keep only the root and the portable 'any' + // fallback here, and take the real per-RID packages from the CI matrix, where each is compiled + // on a matching runner (see aot-pack in .github/workflows/ci.yml). + let staging = Paths.RootRelative <| Path.Combine(Paths.Output.FullName, "..", "rewriter-staging") + if Directory.Exists staging then Directory.Delete(staging, true) + exec "dotnet" ["pack"; sprintf "src/%s/%s.csproj" Paths.ToolName Paths.ToolName; "-c"; "Release"; "-o"; staging] |> ignore + + DirectoryInfo(staging).GetFiles("*.nupkg") + |> Seq.filter (fun f -> not (isPerRidPackage f.Name)) + |> Seq.iter (fun f -> + let destination = Path.Combine(Paths.Output.FullName, f.Name) + printfn "keeping %s" f.Name + f.CopyTo(destination, true) |> ignore + if f.Name.Contains(sprintf "%s.any." Paths.ToolName) then + fixAnyPackageSigning destination) + + Directory.Delete(staging, true) + let private validatePackages (arguments:ParseResults) = let nugetPackage = - let p = Paths.Output.GetFiles("*.nupkg") |> Seq.sortByDescending(fun f -> f.CreationTimeUtc) |> Seq.head + // Only the 'any' package carries a signed managed assembly to check: the root package is + // just a DotnetToolSettings.xml pointer with no dll of its own, and the per-RID AOT packages + // hold a native binary with no managed identity either. + let p = + Paths.Output.GetFiles("*.nupkg") + |> Seq.filter (fun f -> f.Name.Contains(sprintf "%s.any." Paths.ToolName)) + |> Seq.sortByDescending(fun f -> f.CreationTimeUtc) |> Seq.head Paths.RootRelative p.FullName - exec "dotnet" ["nupkg-validator"; nugetPackage; "-v"; currentVersion.Value; "-a"; Paths.ToolName; "-k"; "96c599bbe3e70f5d"] |> ignore + exec "dotnet" ["nupkg-validator"; nugetPackage; "-v"; currentVersionInformational.Value; "-a"; Paths.ToolName; "-k"; "96c599bbe3e70f5d"; "--allow-roll-forward"] |> ignore let private generateApiChanges (arguments:ParseResults) = let output = Paths.RootRelative <| Paths.Output.FullName @@ -49,8 +102,8 @@ let private generateApiChanges (arguments:ParseResults) = let args = [ "assembly-differ" - (sprintf "previous-nuget|%s|%s|netcoreapp3.1" Paths.ToolName currentVersion); - (sprintf "directory|src/%s/bin/Release/netcoreapp3.1" Paths.ToolName); + (sprintf "previous-nuget|%s|%s|net10.0" Paths.ToolName currentVersion); + (sprintf "directory|src/%s/bin/Release/net10.0" Paths.ToolName); "--target"; Paths.ToolName; "-f"; "github-comment"; "--output"; output ] diff --git a/build/scripts/scripts.fsproj b/build/scripts/scripts.fsproj index 5220347..4c2351f 100644 --- a/build/scripts/scripts.fsproj +++ b/build/scripts/scripts.fsproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 false diff --git a/dotnet-tools.json b/dotnet-tools.json index 316f47b..35760b1 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -15,13 +15,13 @@ ] }, "nupkg-validator": { - "version": "0.5.0", + "version": "0.10.1", "commands": [ "nupkg-validator" ] }, "assembly-differ": { - "version": "0.14.0", + "version": "0.16.0", "commands": [ "assembly-differ" ] diff --git a/global.json b/global.json index c317b00..d46d21e 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "6.0.302", + "version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": false } -} \ No newline at end of file +} diff --git a/src/assembly-rewriter/AssemblyRewriterCommand.cs b/src/assembly-rewriter/AssemblyRewriterCommand.cs new file mode 100644 index 0000000..56a42b7 --- /dev/null +++ b/src/assembly-rewriter/AssemblyRewriterCommand.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; +using ILRepacking; +using Nullean.Argh; + +namespace AssemblyRewriter; + +internal sealed class AssemblyRewriterCommands +{ + /// Rewrites assemblies and namespaces. + /// -i, --in, Input path for assembly to rewrite. Use multiple flags for multiple input paths. + /// -o, --out, Output path for rewritten assembly. Use multiple flags for multiple output paths. + /// -r, --resolvedir, Additional assembly resolve directories. Use multiple flags for multiple resolve directories. + /// -k, --keyfile, Sign rewritten assembly with this key file. When merge option is specified, the merged assembly will be signed. + /// -m, --merge, Merge all rewritten assemblies into a single assembly using the first output path as target. + /// -v, --verbose, Verbose output. + [DefaultCommand] + public int Rewrite( + [MinLength(1)] List input, + [MinLength(1)] List output, + List? resolveDir = null, + string? keyFile = null, + bool merge = false, + bool verbose = false) + { + if (input.Count != output.Count) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Number of input paths must equal number of output paths"); + Console.ResetColor(); + return 1; + } + + var options = new Options + { + InputPaths = input, + OutputPaths = output, + ResolveDirectories = resolveDir ?? [], + KeyFile = keyFile, + Merge = merge, + Verbose = verbose + }; + + try + { + var rewriter = new AssemblyRewriter(options); + rewriter.Rewrite(options.InputPaths, options.OutputPaths, options.ResolveDirectories); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(e); + Console.ResetColor(); + return 1; + } + + if (!merge) return 0; + + try + { + var repackOptions = new RepackOptions + { + Internalize = true, + Closed = true, + KeepOtherVersionReferences = false, + TargetKind = ILRepack.Kind.SameAsPrimaryAssembly, + InputAssemblies = output.ToArray(), + LineIndexation = true, + OutputFile = output.First(), + KeyFile = keyFile, + SearchDirectories = output.Select(p => new DirectoryInfo(p).FullName).Distinct(), + }; + + var pack = new ILRepack(repackOptions, new RepackConsoleLogger()); + pack.Repack(); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(e); + Console.ResetColor(); + return 2; + } + + return 0; + } +} diff --git a/src/assembly-rewriter/Options.cs b/src/assembly-rewriter/Options.cs index 9fed8a8..e110fa3 100644 --- a/src/assembly-rewriter/Options.cs +++ b/src/assembly-rewriter/Options.cs @@ -1,26 +1,19 @@ using System.Collections.Generic; -using CommandLine; namespace AssemblyRewriter { public class Options { - [Option('i', "in", Min = 1, Required = true, HelpText = "input path for assembly to rewrite. Use multiple flags for multiple input paths")] public IEnumerable InputPaths { get; set; } - [Option('o', "out", Min = 1, Required = true, HelpText = "output path for rewritten assembly. Use multiple flags for multiple output paths")] public IEnumerable OutputPaths { get; set; } - [Option('r', "resolvedir", HelpText = "Additional assembly resolve directories. Use multiple flags for multiple resolve directories")] - public IEnumerable ResolveDirectories { get; set; } + public IEnumerable ResolveDirectories { get; set; } = []; - [Option('k', "keyfile", HelpText = "Sign rewritten assembly with this key file. When merge option is specified, the merged assembly will be signed.")] public string KeyFile { get; set; } - [Option('m', "merge", Default = false, HelpText = "Merge all rewritten assemblies into a single assembly using the first output path as target")] public bool Merge { get; set; } - [Option('v', "verbose", Default = false, HelpText = "verbose output")] public bool Verbose { get; set; } } } diff --git a/src/assembly-rewriter/Program.cs b/src/assembly-rewriter/Program.cs index de5ccec..08ce7ff 100644 --- a/src/assembly-rewriter/Program.cs +++ b/src/assembly-rewriter/Program.cs @@ -1,108 +1,7 @@ -using System; -using System.IO; -using System.Linq; -using CommandLine; -using CommandLine.Text; -using ILRepacking; +using AssemblyRewriter; +using Nullean.Argh; -namespace AssemblyRewriter -{ - internal static class Program - { - private static int Main(string[] args) - { - using var parser = new Parser(settings => - { - settings.HelpWriter = null; - settings.IgnoreUnknownArguments = false; - settings.AllowMultiInstance = true; - }); +var app = new ArghApp(); +app.MapAndRootAlias(); - var result = parser.ParseArguments(args); - - return result switch - { - Parsed parsed => Run(parsed.Value), - NotParsed notParsed => HandleError(notParsed), - _ => 1 - }; - } - - private static int Run(Options options) - { - if (options.InputPaths.Count() != options.OutputPaths.Count()) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("Number of input paths must equal number of output paths"); - Console.ResetColor(); - return 1; - } - - try - { - var rewriter = new AssemblyRewriter(options); - rewriter.Rewrite(options.InputPaths, options.OutputPaths, options.ResolveDirectories); - } - catch (Exception e) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(e); - return 1; - } - if (!options.Merge) return 0; - try - { - var repackOptions = new RepackOptions - { - Internalize = true, - Closed = true, - KeepOtherVersionReferences = false, - TargetKind = ILRepack.Kind.SameAsPrimaryAssembly, - InputAssemblies = options.OutputPaths.ToArray(), - LineIndexation = true, - OutputFile = options.OutputPaths.First(), - KeyFile = options.KeyFile, - SearchDirectories = options.OutputPaths.Select(p=> new DirectoryInfo(p).FullName).Distinct(), - }; - - var pack = new ILRepack(repackOptions, new RepackConsoleLogger()); - pack.Repack(); - } - catch (Exception e) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(e); - return 2; - } - return 0; - } - - private static int HandleError(NotParsed notParsed) - { - var helpText = HelpText.AutoBuild(notParsed, h => - { - h.AdditionalNewLineAfterOption = false; - h.Heading = "AssemblyRewriter" + - Environment.NewLine + - "----------------" + - Environment.NewLine + - "Rewrites assemblies and namespaces"; - h.AddPostOptionsLine("Each input path must have a corresponding output path"); - return HelpText.DefaultParsingErrorsHandler(notParsed, h); - }, e => e); - - if (notParsed.Errors.IsHelp() || notParsed.Errors.IsVersion()) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine(helpText); - Console.ResetColor(); - return 0; - } - - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(helpText); - Console.ResetColor(); - return 1; - } - } -} +return await app.RunAsync(args); diff --git a/src/assembly-rewriter/assembly-rewriter.csproj b/src/assembly-rewriter/assembly-rewriter.csproj index 495d402..bb997e3 100644 --- a/src/assembly-rewriter/assembly-rewriter.csproj +++ b/src/assembly-rewriter/assembly-rewriter.csproj @@ -1,14 +1,22 @@  Exe - netcoreapp3.0;netcoreapp3.1;net5.0;net6.0 + net8.0;net10.0 assembly-rewriter AssemblyRewriter + enable + enable + + $(NoWarn);CS8600;CS8601;CS8603;CS8604;CS8618;CS8625 true assembly-rewriter + Major + latest true - ..\..\build\keys\keypair.snk + $(MSBuildThisFileDirectory)..\..\build\keys\keypair.snk nuget-icon.png MIT @@ -18,8 +26,21 @@ assembly-rewriter: a dotnet tool to rewrite assembly namespaces Diff assemblies and nuget packages - latest + + linux-x64;linux-arm64;win-x64;win-arm64;osx-arm64;any + + + + + true + true + true + false @@ -31,7 +52,7 @@ - +