blog post image
Andrew Lock avatar

Andrew Lock

~16 min read

Packaging self-contained and native AOT .NET tools for NuGet

Exploring the .NET 10 preview - Part 7

Share on:

In this post we'll look at the support for platform-specific .NET tools feature added in .NET 10,. This new feature allows you to pack tools in a variety of different ways—including self-contained, trimmed, and using native AOT—mirroring the many different ways you can publish .NET apps today.

In this post we'll use a sample app to look at each of those package types in turn to see the impact the package type has on the package size and the contents of the packages. I'll highlight some of the bugs I found during testing, and also when the various package types might be most useful.

.NET tools

I discussed .NET tools at length in my previous post, so for this post I'm going to assume you're already familiar with the general .NET tools feature. There's not been much change to them since .NET Core 3.0, so that's a pretty safe bet!

That said, the previous post covers one important aspect that's particularly relevant to this post: ensuring compatibility for consumers of your package by multi-targeting your tool against multiple frameworks. I strongly recommend reading that section of the post at least, if you haven't already.

The many ways to deploy .NET applications.

So I'm assuming you already know a bit about .NET tools, but before we get to the new .NET tool features in .NET 10, we first need to look at some of the ways you can publish your .NET applications today:

  • Framework-dependent executable. The .NET runtime must be installed on the target machine. The published app is small because it does not include any of the runtime binaries.
  • Self-contained executable. The published app includes all the .NET runtime binaries it needs, so is very large, but the benefit is that you don't need a .NET runtime installed on the target machine.
  • Trimmed self-contained executable. As with the previous example, but the binaries are trimmed to remove unused code, significantly reducing the size of the published app.
  • Native AOT executable. Native Ahead of Time (AOT) compiled applications have fast startup and smaller memory footprints, and don't need a .NET runtime installed on the host machine. They are platform-specific and some features (like some reflection APIs) are not available.

Another dimension to consider is whether deployments are "platform agnostic" or "platform specific". In simple terms:

  • Platform agnostic deployments can run on any platform, e.g. Linux ARM64 or Windows x64, typically expressed as a runtime ID e.g. linux-arm64, win-x64.
  • Platform specific deployments can only run on a specific platform.

This becomes particularly important when you have native dependencies, so you need a different version of the library for each platform. In these cases, platform agnostic deployments include the native libraries for all supported platforms, whereas platform specific deployments need only include the libraries for a single platform.

The rough summary is that the .NET 10 SDK now supports creating and consuming .NET tools using all these new deployment models.

.NET tools support more deployment models in .NET 10

Prior to .NET 10, it was only possible to publish .NET tools using the "framework dependent" deployment model. .NET 10 preview 6 announced support for publishing .NET tools using more deployment models:

  • Framework-dependent, platform-agnostic (the way it works today)
  • Framework-dependent, platform-specific
  • Self-contained, platform-specific
  • Trimmed, platform-specific
  • Native AOT-compiled, platform-specific

The "Framework-dependent, platform-specific" option is useful if you have native dependencies in your app, as it effectively splits these across multiple NuGet packages, making each individual package smaller.

The self-contained, trimmed, and NativeAOT options are particularly useful, as they mean you're no longer dependent on the consumer having the correct .NET runtime already installed on the target machine. Instead of having to support multiple runtimes or configure RollForward for your application, you can just pack the whole runtime (ideally, trimmed or AOT compiled) into your package, and don't worry about the target machine.

There's some caveats to this, which I discuss at the end of this post.

That's the crux of the feature, so for the remainder of the post we'll look at how to generate each of the different packages, what the NuGet packages look like, and what they contain.

The sample app

For the test app, I created a sample based on Chet Husk's multi-rid-tool which looked a bit like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0</TargetFrameworks>
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
    <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
    <PackAsTool>true</PackAsTool>
    <ToolCommandName>sayhello</ToolCommandName>
    <PackageId>sayhello</PackageId>
    <PackageVersion>1.0.0</PackageVersion>
    <Authors>Andrew Lock</Authors>
    <Description>A tool that says hello</Description>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
  </PropertyGroup>
  
  <ItemGroup>
    <PackageReference Include="Spectre.Console" Version="0.50.0" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.8" />
  </ItemGroup>
</Project>

There's nothing particularly interesting there, it's a simple exe called sayhello that has a bunch of target frameworks, is packed as a .NET tool, and has a couple of dependencies. The Microsoft.Data.Sqlite dependency is there to ensure we have a native dependency (SQLite) while Spectre.Console is there because it's cool.

In Program.cs we have some simple code that ensures we use the dependencies (to make sure they're not completely trimmed out for example):

using Microsoft.Data.Sqlite;
using Spectre.Console;

using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();

var command = connection.CreateCommand();
command.CommandText = "SELECT 'world'";

using var reader = command.ExecuteReader();
while (reader.Read())
{
    var name = reader.GetString(0);

    var figlet = new FigletText($"Hello {name}!")
        .Centered()
        .Color(Color.Green);

    AnsiConsole.Write(figlet);
}

This program just prints Hello world! when you run it:

    _   _          _   _                                       _       _   _ 
   | | | |   ___  | | | |   ___     __      __   ___    _ __  | |   __| | | |
   | |_| |  / _ \ | | | |  / _ \    \ \ /\ / /  / _ \  | '__| | |  / _` | | |
   |  _  | |  __/ | | | | | (_) |    \ V  V /  | (_) | | |    | | | (_| | |_|
   |_| |_|  \___| |_| |_|  \___/      \_/\_/    \___/  |_|    |_|  \__,_| (_)

Next we're going to publish it using each of the five different approaches. In each case we could control the publish output by passing values to the dotnet pack commands. However, in practice you'll likely want to embed the options inside the project file, so that's the approach I take in this post.

Framework-dependent, platform agnostic

For the application above, by default, when you run dotnet pack you'll get a framework-dependent, platform agnostic package. The tool can be installed on any of the supported platforms, but you need one of the supported .NET runtimes installed on the target machine:

dotnet pack -o ./artifacts/packages/agnostic

This produces the package in the ./artifacts/packages/agnostic folder, and results in a single, chonky, package:

The sayhello package is large, at 91MB

If you open the package using NuGet Package Explorer, you can see why the package is so large: it contains duplicate files for every target framework, and each target framework contains the native files for all the supported platforms

The package contains a folder for each target framework, each of which contains all the runtimes

Finally, if we look at the DotnetToolSettings.xml file, we see something like this:

<?xml version="1.0" encoding="utf-8"?>
<DotNetCliTool Version="1">
  <Commands>
    <Command Name="sayhello" EntryPoint="MultiRid.dll" Runner="dotnet" />
  </Commands>
</DotNetCliTool>

This package is effectively the same as you get today if you're packing your .NET tools with the .NET 9 SDK or earlier.

There are two main reasons the package is large:

  • We target multiple frameworks to support multiple .NET runtime environments
  • We support all platforms in a single package.

For the next scenario, we address the second of those points.

Framework-dependent, platform specific

In the next scenario, instead of supporting all platforms (linux-x64/win-x64 etc) in a single package, we split each of those into a separate package. This is handled automatically by the .NET 10 SDK when you specify runtime IDs in your project. To produce platform-specific NuGet packages, we simply add the following property group to our project:

<!-- specific -->
<PropertyGroup>
  <RuntimeIdentifiers>linux-x64;linux-arm64;win-x64;win-arm64;any</RuntimeIdentifiers>
  <PublishSelfContained>false</PublishSelfContained>
</PropertyGroup>

There's a couple of interesting points here:

  • We explicitly set PublishSelfContained=false. This is because on earlier versions of .NET Core, setting a runtime ID would automatically set PublishSelfContained=true, but that's a different scenario we're going to get to later!
  • We have an additional any runtime ID. The any option was added in .NET 10 preview 7 and is meant to ensure you still produce a "platform agnostic" package in addition to the platform-specific packages, so that you have a "fallback".

If we run .NET pack again:

dotnet pack -o ./artifacts/packages/specific

This time we end up with 6 different packages:

The dotnet pack command produces 6 different packages

We now have

  • A "top level" NuGet package with the "correct" name for our tool.
  • A package for each of the 4 specific runtime IDs we specified in our project file.
  • An "any" package, which should be used on platforms that don't have a dedicated package.

We'll look at each of these packages in turn.

The top-level package is tiny, and contains just a single file for each target framework:

The DotnetToolSettings.xml file for each framework

The DotnetToolSettings.xml file in this case looks like this:

<?xml version="1.0" encoding="utf-8"?>
<DotNetCliTool Version="2">
  <Commands>
    <Command Name="sayhello" />
  </Commands>
  <RuntimeIdentifierPackages>
    <RuntimeIdentifierPackage RuntimeIdentifier="linux-x64" Id="sayhello.linux-x64" />
    <RuntimeIdentifierPackage RuntimeIdentifier="linux-arm64" Id="sayhello.linux-arm64" />
    <RuntimeIdentifierPackage RuntimeIdentifier="win-x64" Id="sayhello.win-x64" />
    <RuntimeIdentifierPackage RuntimeIdentifier="win-arm64" Id="sayhello.win-arm64" />
    <RuntimeIdentifierPackage RuntimeIdentifier="any" Id="sayhello.any" />
  </RuntimeIdentifierPackages>
</DotNetCliTool>

There's some interesting aspects to this file compared to the platform agnostic version:

  • The <DotNetCliTool> element has Version=2 for the platform-specific version, and Version=1 for the platform-agnostic version.
  • There's a collection of <RuntimeIdentifierPackage> which match a runtime ID to a different package.

If we look at one of those platform-specific packages, e.g. sayhello.linux-x64.1.0.0, and compare it to the platform agnostic version, we can see why it's so much smaller:

The sayhello.linux-x64 package contents

There's still a folder for every target framework, but now instead of a runtimes folder with all the different platform-specific binaries, there's only the single platform, linux-x64 in this case.

Comparing the platform agnostic package to the platform specific package

It's also worth looking at the DotnetToolSettings.xml file in these packages:

<?xml version="1.0" encoding="utf-8"?>
<DotNetCliTool Version="2">
  <Commands>
    <Command Name="sayhello" EntryPoint="MultiRid" Runner="executable" />
  </Commands>
</DotNetCliTool>

As you can see, this is another Version=2 manifest and interestingly this is where we see a new "runner" type, executable, with a defined EntryPoint which is the executable that will run.

So the platform-specific packages can be much smaller as they only need a single runtime, but the any package does still need all those runtimes, so that it will work on any platform. Unfortunately, there's currently a bug which means that none of the runtimes are currently included, so actually the any package won't work on any platform if your app has native dependencies 😅 That should be fixed in .NET 10 RC2.

Self-contained, platform specific

For the next permutation, we build a self-contained application, so we bundle the .NET runtime as part of the package. The other change in this case is that there's no point in targeting multiple frameworks for increased compatibility; you only need to target one, because it's going to be bundled in the package anyway:

<PropertyGroup>
  <TargetFramework>net9.0</TargetFramework>
  <RuntimeIdentifiers>linux-x64;linux-arm64;win-x64;win-arm64</RuntimeIdentifiers>
  <PublishSelfContained>true</PublishSelfContained>
</PropertyGroup>

The other thing to note is that there's no any runtime ID here. That's because there's currently a bug that will cause errors if you try to add it:

 C:\Program Files\dotnet\sdk\10.0.100-preview.7.25380.108\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(528,5):
error NETSDK1082: There was no runtime pack for Microsoft.NETCore.App available for the specified RuntimeIdentifier 'any'.

This should also be fixed for .NET 10 RC 2, and will mean you can have a true fallback package, just as we saw for the framework-dependent, platform-specific case. For now though, we'll have to do without the any package.

Running dotnet pack once again:

dotnet pack -o ./artifacts/packages/specific

If we look at the packages this scenario produces, they're very similar to the previous framework-dependent platform-specific case, but much larger, because they ship .NET in the package too:

The self-contained, platform-specific packages

The root sayhello package is essentially the same as the root package for the platform-specific version, the only difference being that the self-contained package only contains a single target framework.

The sayhello root package only contains a sing any

However, If we look inside the platform-specific packages like sayhello.linux-arm64 you'll see that there's a lot of files in the package, everything you need to run the .NET app, even when there's no .NET runtime installed on the target application.

Inside the sayhello.linux-arm64 package there are a lot files, that are part of the .NET runtime

If we look at the DotnetToolSettings.xml file in these self-contained package you can see that it's still the same as the framework-dependent platform-specific package, pointing towards the executable shim:

<?xml version="1.0" encoding="utf-8"?>
<DotNetCliTool Version="2">
  <Commands>
    <Command Name="sayhello" EntryPoint="MultiRid.exe" Runner="executable" />
  </Commands>
</DotNetCliTool>

Even though these packages are large, if you think back to the first framework-dependent, platform agnostic package we're much smaller than the 90MB we started with.

Self-contained, trimmed

If you're going to go to the effort of building self-contained, platform-specific packages, then it likely makes sense to go to the next step and enable trimming too. Enabling an application for trimming can be somewhat risky depending on the libraries and methods your application uses. However, if your application is amenable to trimming, it can significantly reduce the resulting package sizes.

To create trimmed NuGet packages, you just need the <PublishTrimmed> property on top of our previous configuration:

<PropertyGroup>
  <TargetFramework>net9.0</TargetFramework>
  <RuntimeIdentifiers>linux-x64;linux-arm64;win-x64;win-arm64</RuntimeIdentifiers>
  <PublishSelfContained>true</PublishSelfContained>
  <!-- 👇 Add this -->
  <PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

Again we publish with

dotnet pack -o ./artifacts/packages/trimmed

And this time the packages are a third of the size, around 10MB each, much better!

The packages when building self-contained trimmed packages

We're almost done, the last case we have to look at is native AOT.

Native AOT

As per the documentation:

Publishing your app as Native AOT produces an app that's self-contained and that has been ahead-of-time (AOT) compiled to native code. Native AOT apps have faster startup time and smaller memory footprints. These apps can run on machines that don't have the .NET runtime installed.

If you're already packaging your application as both self-contained and trimmed, it's very possible that your application could be Native AOT compatible too. And given that you're building tools, it's very possible that you could get real benefits from compiling your application as native AOT.

We'll update the tool to build as Native AOT, and to reduce the size of the package, we'll also strip the symbols

<PropertyGroup>
  <RuntimeIdentifiers>linux-x64;linux-arm64;win-x64;win-arm64;</RuntimeIdentifiers>
  <PublishSelfContained>true</PublishSelfContained>
  <PublishTrimmed>true</PublishTrimmed>
  <!-- 👇 Add these -->
  <PublishAot>true</PublishAot>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>

Publishing for native AOT is a little trickier than for the other packages, because you can only native AOT compile on the same platform as you're running on. In total, you need to run dotnet pack once for each runtime, and once for the "root" package.

Running the simple dotnet pack produces the "root" package:

dotnet pack -o ./artifacts/packages/nativeoat

and then you need to run dotnet pack for each runtime ID, for example:

dotnet pack -o ./artifacts/packages/nativeaot --runtime-id win-x64

This produces the sayhello.win-x64.1.0.0 package only. You then need to run it again for each of the supported runtimes.

Chet Husk has an example of how you can do this in GitHub actions and later aggregate the packages together. Note that he uses --current-runtime instead of listing each runtime ID specifically. Be aware that this doesn't work if you are using <TargetFrameworks> in your project; you must use <TargetFramework> instead.

If we look at those packages, you can see that the native AOT packages are even smaller than the trimmed packages!

The native AOT packages are only 2.5MB

And if we look inside the package, you can see that there's only 2 application files left: the native AOT'd .NET tool, and the native library:

The contents of the native AOT package

In many ways this represents the "pinnacle" of .NET tool usability; on supported platforms, consumers of your tool will benefit from the fastest start up times and the smallest download sizes (which further improves the dnx one-off-tool experience). In the cases where you're on an unsupported platform, you should be able to fallback to the same framework dependent, platform-agnostic packages that are available today, so there's nothing much lost there.

Note that as mentioned earlier, the any fallback for self-contained, trimmed, and Native AOT packages does not work as of .NET 10 preview 7, but it should be working for RC2.

So the last remaining question is: as a tool author, should you actually use these new platform-specific packages?

Limitations and recommendations

The main limitation with all of the new platform-specific package types is that they only work when you're using the .NET 10 SDK. If you try to install any of the packages that use version 2 of the <DotNetCliTool> element then you'll get an error similar to the following:

> dotnet tool install sayhello --tool-path .\specific --source D:\repos\blog-examples\MultiRid\artifacts\packages\specific

Tool 'sayhello' failed to update due to the following:
The settings file in the tool's NuGet package is invalid: Command 'sayhello' uses unsupported runner ''."
Tool 'sayhello' failed to install. Contact the tool author for assistance.

Given this requirement, depending on the specifics of the .NET tool you're creating, the benefits of creating a platform-specific tool may be limited.

First of all, if your tool don't have any native dependencies, then there's fundamentally no difference between the framework-dependent platform-specific packages and the framework-dependent platform agnostic packages, other than the fact that you can only run the platform-specific tool if you're using the .NET 10 SDK.

Where things get interesting is if you're currently targeting multiple frameworks with your tool. In these cases you end up with multiple instances of the app, compiled for each target framework, all packaged in the same .nupkg. If you want to support the widest range of installed frameworks, then theoretically you need a new copy for every framework. That can both increase complexity in the tool and increase the size of the package that needs to be downloaded.

You can avoid that somewhat by configuring the rollForward rules for your tool, but there's always a certain amount of risk in relying solely on that approach.

The neat thing about the self-contained/trimmed/Native AOT packages is that you don't need to multi-target your application for multiple frameworks, because you include the framework in the package! That reduces the complexity in your tool (no need to multi-target) and the size of your package (no need for multiple copies of the compiled app).

At least, that's the theory. Today, it's not really true, because these packages only support .NET 10, because they require the .NET 10 SDK to install the tool. So ease of support and compatibility is actually a reason not to use the platform-specific packages today. This will become less of a problem as time goes on and more people are using the latest .NET SDKs, but today it's hard to recommend as your sole approach.

That said, there are some "escape route" avenues I explored to try to get the best of both worlds. These may work for you if you want to support both the enhanced experience of the .NET 10 SDK while still supporting users stuck on older SDKs.

Summary

In this post I described the new platform-specific .NET tool packages that are supported in .NET 10 as of preview 6 and 7. The .NET 10 SDK allows you to easily create "root" meta-packages that delegate to platform-specific versions of your .NET tool. This is particularly useful if your app has native dependencies, as it can significantly reduce the size of each package.

In addition, you can create self-contained, trimmed, or native AOT compiled versions of your app. These packages mean you're no longer beholden to the consumer having the correct version of the runtime installed for your tool (though currently it does mean they must have the .NET 10 SDK installed).

In this post we looked at the results of using each of the new package types, the impact it has on the package size and the contents of the packages. I highlighted some of the bugs I found during testing (but which will hopefully be resolved by the time .NET 10 goes GA in November). Finally, I discussed some of the limitations of the new packages, the most important being that you must have the .NET 10 SDK installed to install tools that use the new package types.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?