Abstract: We practically show examples of 15 different .NET8/9 build modes, including Framework-Dependent and Framework-Independent, how to bundle an app into a single file, how to trim app bundles from unused libraries, and how to build an Ahead-of-time (AOT) precompiled app.

1. .NET 8/9 toolset supports different build/publish/deployment modes

I was experimenting with different project properties/flags, and I developed several proof-of-concept build projects showcasing different build/publish/deployment modes available in .NET8/9, using Microsoft tools.

1.2. Articles in this series

For technical reasons, I will organize this text into several articles.

1.3. Experimenting with Bundling of .NET8/C# application

For the purpose of my project, I started looking into how to deploy some auxiliary tools that I developed as a WinForms/Net8/C# desktop application as a Single-File application, meaning all .dll-s and supporting files are bundled in one assembly.

Over time, I was experimenting more and more with different project properties/flags, and I developed several proof-of-concept build projects showcasing different build/publish/deployment modes available in .NET8, using Microsoft tools.

1.4. Practically oriented projects

I was not really interested in fully understanding and learning all the options available and how they work. I was practically oriented, aiming at some “intermediate level of practical skills” of building recipes and how to properly apply settings and reliably build some applications, resulting in the wanted build/deployment mode.

I think I got it right in most cases. But, for a deeper understanding of the implications of certain project build/publish options, you will need to look elsewhere, like [1], [7].

2. Publish Modes – Terminology reminder

Here is some terminology reminder (very short, see [1] for full formulations)

  1. Publish Modes – build type
    1. Debug
    2. Release
  2. Publish Modes – regarding installed .NET runtime
    1. framework-dependent – The app depends on .NET runtime installed
    2. self-contained (also called framework independent )- The app carries .NET runtime with itself. It does not need to be installed a priori on the deployment machine
  3. Publish Modes – regarding platform specificity
    1. Cross-platform – The app can be run on multiple platforms
    2. Platform-specific – The app targets specific platforms, like X64
  4. Publish Modes – regarding bundling the app and dependencies
    1. Un-bundled – The app consists of multiple files
    2. Single-file (also called bundled )– All app files are bundled into a single file
  5. Publish Modes – regarding trimming of bundled “Single File” mode
    1. Untrimmed – During bundling into “Single File" all dependency files are embedded in full content
    2. Trimmed – During bundling into “Single File" dependency files are examined for dependencies, and only classes/libraries being really needed/used are bundled. Sometimes called tree-shaking or pruning.
  6. Publish Modes – regarding ReadyToRun (R2R) format. R2R is a form of ahead-of-time (AOT) compilation.
    1. NotR2R –relay fully on JIT
    2. ReadyToRun – R2R means that the code is ready to run right away without JIT-ing.
  7. Publish Modes – regarding if PDB files are separate files
    1. PdbFilesSeparate –Pdb files are built as separate files
    2. PdbEmbeded – The PDB file for an assembly can be embedded into the assembly itself (the .dll)
  8. Publish Modes – regarding if binaries (non-managed files) are included in the single build
    1. binariesNotInSingleFile –binaries are not included in SingleFile
    2. binariesInSingleFile – binaries are included in SingleFile
  9. Publish Modes – regarding if assemblies, when included in SingleFile, are compressed
    1. assembliesNotCompressed –assemblies in SingleFile are not compressed
    2. assembliesCompressed – The SingleFile that's produced will have all of the embedded assemblies compressed
  10. Publish Modes – regarding ahead-of-time (AOT) compilation to native code
    1. NotAot – not ahead-of-time (AOT) compiled
    2. PublishAot– ahead-of-time (AOT) compiled to native code

2.1. Comments

3. .NET 8 not supporting “Framework-dependent Single-file Trimmed” build

When I started this prototyping, what I really had in mind as my goal was Framework-dependent_Single-file_Trimmed build. That would be a build in which all assemblies are bundled into a Single File, unnecessary code/classes would be trimmed, and the resulting single assembly would be .NET framework-dependent. The resulting assembly would be small, compact, tamper-proof, and easy to deploy.

To the best of my knowledge .NET8 tools do not support that build/publish configuration. I was VERY SURPRISED about that situation. The process they call “trimming” is only enabled for “Self-contained”, that is .NET framework installation independent configuration.

I understand all complexities related to statistical lexical analysis and problems that usage of Reflection can cause to the trimming process, sometimes called “pruning” or “tree shaking”.

The thing is that I saw that the build process was working, maybe 10 years ago, for .NET Framework 4.0. It worked well, and I used it regularly. At that time, I was using a RedGate product called SmartAssembly, which is an obfuscator. It had the option to bundle all dependencies into one file and prune/trim unused code. It would then obfuscate the code. I do not know what the status of that tool is now for .NET Core. But, it worked well then, and I was using decompilers, I think it was Reflector then, to look into assembly what was obfuscated, and which classes have not been pruned/ trimmed. It actually does trimming/pruning at the class level, not the assembly level. I think that .NET8 tools do trimming/pruning on the assembly level. There were some limitations then. I remember the file directory tree structure was a problem (for language-dependent .dll-s), but it worked well for my project at that time.

That is why I find it VERY STRANGE that .NET8 build tools, with all the options they have, do not support that particular, very attractive build option. Simply put, I saw such technology 10 years ago in some 3rd-party tools, and is not present today in .NET8.

I was getting this error when I tried that particular build configuration (Framework-dependent_Single-file_Trimmed): Error (active) NETSDK1102 Optimizing assemblies for size is not supported for the selected publish configuration. Please ensure that you are publishing a self-contained app.

4. Project Flags influencing Build/Publish/Deployment modes

The first thing we need are different Project Flags influencing Build/Publish/Deployment modes.

Here is a pretty long list of Project Flags I encountered during my experimenting. I will not pretend that I really understand all of them. Like probably everyone else I was doing a bit of trial-and-error flipping certain flags on and off. But I think it is good to have such a list available as a short overview of syntax if needed.

<!-- OVERVIEW OF BUILD/PUBLISH PROPERTIES IN .csproj FILE -->

<!-- Some special properties in csproj file have syntax like this: -->
<PropertyGroup>
   <PropertyXYZ>true</PropertyXYZ>
</PropertyGroup>

<!-- Below listed properties are NOT all of them, and some seem to have changed from .NET6 to .NET8. -->
<!-- When I say changed, I mean meaning/effect and default value if not set. -->
<!-- Still, this is an OVERVIEW of what is available. -->
<!-- For detailed info on usage and effects, check Microsoft latest documentation. -->

<!-- ============================================================= -->

<RuntimeIdentifiers>win-x64;osx-x64</RuntimeIdentifiers>
<!-- defines the platforms your app targets, and specify the runtime identifier (RID) of each platform that you target -->
<!-- Specifies the OS and CPU type you're targeting. -->

<!-- ============================================================= -->

<PublishSingleFile>true</PublishSingleFile>
<!-- Enables single file publishing. -->

<!-- ============================================================= -->

<SelfContained>true</SelfContained>
<!-- Determines whether the app is self-contained or framework-dependent. -->

<!-- ============================================================= -->

<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<!-- Certain files can be explicitly excluded from being embedded in the single file. -->

<!-- ============================================================= -->

<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- The single file that's produced will have all of the embedded assemblies compressed, which can significantly reduce the size of the executable. -->

<!-- ============================================================= -->

<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- Only managed DLLs are bundled with the app into a single executable by default, but the native binaries of the core runtime itself are separate files. -->
<!-- To embed native binaries for extraction and get one output file, set the property. -->

<!-- ============================================================= -->

<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<!-- Only managed DLLs are bundled with the app into a single executable by default, but the native binaries are separate files. -->
<!-- To embed all files, including native binaries for extraction and get one output file, set the property. -->

<!-- ============================================================= -->

<DebugType>embedded</DebugType>
<!-- The PDB file for an assembly can be embedded into the assembly itself (the .dll). -->

<!-- ============================================================= -->

<PublishReadyToRun>true</PublishReadyToRun>
<!-- Regarding ReadyToRun (R2R) format. R2R is a form of ahead-of-time (AOT) compilation. -->

<!-- ============================================================= -->

<PublishReadyToRunExclude Include="Contoso.Example.dll" />
<!-- Exclude specific assemblies from ReadyToRun processing. -->

<!-- ============================================================= -->

<PublishReadyToRunEmitSymbols>true</PublishReadyToRunEmitSymbols>
<!-- Symbol generation for use with profilers. -->

<!-- ============================================================= -->

<PublishReadyToRunComposite>true</PublishReadyToRunComposite>
<!-- Composite ReadyToRun compiles a set of assemblies that must be distributed together. -->
<!-- This has the advantage that the compiler is able to perform better optimizations and reduces the set of methods that cannot be compiled via the ReadyToRun process. -->

<!-- ============================================================= -->

<PublishTrimmed>true</PublishTrimmed>
<!-- Will produce a trimmed app on self-contained publish. -->

<!-- ============================================================= -->

<TrimMode>full</TrimMode>
<!-- Set the trimming granularity to either partial or full. -->

<!-- ============================================================= -->

<TrimmableAssembly Include="MyAssembly" />
<!-- Opt-in individual assemblies to trimming. -->

<!-- ============================================================= -->

<TrimmerRootDescriptor Include="MyRoots.xml" />
<!-- Specify roots for analysis using an XML file that uses the trimmer descriptor format. -->
<!-- This lets you root specific members instead of a whole assembly. -->

<!-- ============================================================= -->

<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<!-- Trimming removes IL that's not statically reachable. Apps that use reflection or other patterns that create dynamic dependencies might be broken by trimming. -->
<!-- To warn about such patterns, set <SuppressTrimAnalysisWarnings> to false. -->

<!-- ============================================================= -->

<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<!-- Enable a Roslyn analyzer for a subset of trim analysis warnings. -->

<!-- ============================================================= -->

<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
<!-- Don't treat ILLink warnings as errors. -->

<!-- ============================================================= -->

<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- Show all detailed warnings, instead of collapsing them to a single warning per assembly. -->

<!-- ============================================================= -->

<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<!-- Remove symbols from the trimmed application, including embedded PDBs and separate PDB files. -->

<!-- ============================================================= -->

<IsTrimmable>true</IsTrimmable>
<!-- Setting the MSBuild property IsTrimmable to true marks the assembly as "trimmable" and enables trim warnings. -->

<!-- ============================================================= -->

<PublishAot>true</PublishAot>
<!-- This property enables Native AOT compilation during publish. It also enables dynamic code-usage analysis during build and editing. -->

<!-- ============================================================= -->

<IsAotCompatible>true</IsAotCompatible>
<!-- Indicate whether a library is compatible with Native AOT. -->

<!-- ============================================================= -->

<StripSymbols>false</StripSymbols>
<!-- On Unix-like platforms, set the StripSymbols property to false to include the debug information in the native binary. -->

<!-- ============================================================= -->

<_SuppressWinFormsTrimError>true</_SuppressWinFormsTrimError>
<!-- Strange, this one is not documented and I see people on the internet using it. -->

5. Dotnet Publish command

The next thing we need is the “dotnet publish” command. Here is the help file.

======================================================
>dotnet publish /?
Description:
  Publisher for the .NET Platform

Usage:
  dotnet publish [<PROJECT | SOLUTION>...] [options]

Arguments:
  <PROJECT | SOLUTION>  The project or solution file to operate on. 
                        If a file is not specified, the command will search 
                        the current directory for one.

Options:
  --ucr, --use-current-runtime         Use current runtime as the target runtime.
  -o, --output <OUTPUT_DIR>            The output directory to place the published artifacts in.
  --artifacts-path <ARTIFACTS_DIR>     The artifacts path. All output from the project, including build, publish, 
                                       and pack output, will go in subfolders under the specified path.
  --manifest <MANIFEST>                The path to a target manifest file that contains the list of packages 
                                       to be excluded from the publish step.
  --no-build                           Do not build the project before publishing. Implies --no-restore.
  --sc, --self-contained               Publish the .NET runtime with your application so the runtime doesn't need 
                                       to be installed on the target machine.
                                       The default is 'false.' However, when targeting .NET 7 or lower, the default is 
                                       'true' if a runtime identifier is specified.
  --no-self-contained                  Publish your application as a framework-dependent application. A compatible .NET 
                                       runtime must be installed on the target machine to run your application.
  --nologo                             Do not display the startup banner or the copyright message.
  -f, --framework <FRAMEWORK>          The target framework to publish for. The target framework has to be specified 
                                       in the project file.
  -r, --runtime <RUNTIME_IDENTIFIER>   The target runtime to publish for. This is used when creating a self-contained
                                       deployment.
                                       The default is to publish a framework-dependent application.
  -c, --configuration <CONFIGURATION>  The configuration to publish for. The default is 'Release' for NET 8.0 projects 
                                       and above, but 'Debug' for older projects.
  --version-suffix <VERSION_SUFFIX>    Set the value of the $(VersionSuffix) property to use when building the project.
  --interactive                        Allows the command to stop and wait for user input or action (for example to 
                                       complete authentication).
  --no-restore                         Do not restore the project before building.
  -v, --verbosity <LEVEL>              Set the MSBuild verbosity level. Allowed values are q[uiet], m[inimal], 
                                       n[ormal], d[etailed], and diag[nostic].
  -a, --arch <ARCH>                    The target architecture.
  --os <OS>                            The target operating system.
  --disable-build-servers              Force the command to ignore any persistent build servers.
  -?, -h, --help                       Show command line help.
============================================================

6. To be continued

This will be continued in the next article of the series.

7. References

[1] .NET application publishing overview https://learn.microsoft.com/en-us/dotnet/core/deploying/

[2] Self-contained deployment runtime roll forward https://learn.microsoft.com/en-us/dotnet/core/deploying/runtime-patch-selection

[3] https://www.red-gate.com/products/smartassembly/

[7] https://devblogs.microsoft.com/dotnet/conversation-about-ready-to-run/