Skip to content

Instantly share code, notes, and snippets.

@Aldaviva
Last active November 20, 2025 17:51
Show Gist options
  • Select an option

  • Save Aldaviva/8d2256604b0b129dcaf761d583b2df29 to your computer and use it in GitHub Desktop.

Select an option

Save Aldaviva/8d2256604b0b129dcaf761d583b2df29 to your computer and use it in GitHub Desktop.
.NET single-file non-self-contained app forward compatibility across major runtime versions

Problem

Assembly loading priority is greedy (probably for performance) and always prefers to use bundled libraries, even if the runtime already provides a better version.

This makes older .NET apps that should roll forward not actually be forward compatible. Newer runtime-provided libraries will be forced to load the app's old bundled dependencies (like System.Diagnostics.DiagnosticSource), which will crash the runtime that requires a newer version with a FileNotFoundException.

Example

For example, consider a typical non-self-contained, single-file app that targets .NET 8 Runtime or greater. When .NET 10 was released, users should have been able to upgrade from .NET 8 Runtime to .NET 10 Runtime without breaking, retargeting, upgrading dependencies in, or recompiling this app. With normal dependencies, such as Microsoft.Extensions.Hosting 9.0.11, this will crash on launch with a FileNotFoundException because the app transitively depends on and bundles packages such as System.Diagnostics.DiagnosticSource 9.0.11, which is loaded by the runtime assembly resolver at a higher priority than System.Diagnostics.DiagnosticSource 10.0.0 in the runtime installation directory. Some other assembly in the runtime then tries to load System.Diagnostics.DiagnosticSource 10.0.0, but since the old 9.0.11 version was already loaded, it crashes because the actual version is lower than expected.

Solution

πŸ‘ Manually trim runtime-provided dependencies from apps at publishing time, and set their versions to match the target framework major and minor version.

Libraries

  1. Target some framework versions such as net8.0 or netstandard2.0.
  2. Dependency versions which are also provided by the runtime must be specified with the same major and minor version of the targeted runtime, such as System.Text.Json 8.0.6 (can be conditional when multi-targeting). The patch version can be whatever you want.
<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<TargetFramework>net8.0</TargetFramework>
	</PropertyGroup>

	<ItemGroup>
		<!-- Latest 8.0.* version, even though 10.0.0 is the latest, so that it won't crash when run on .NET Runtime 8.0 with manually trimmed provided dependencies -->
		<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
	</ItemGroup>
</Project>

Applications

  1. Target some framework versions such as net8.0 or net10.0.
  2. Project must set the RollForward property to either major or latestMajor. It may also set the RestoreEnablePackagePruning property to true, although it doesn't prune enough to avoid all crashes and isn't strictly necessary.
  3. Dependency (including transitive) versions which are also provided by the runtime (such as Microsoft.Extensions.Hosting or Microsoft.Extensions.Hosting.Systemd) must be specified as the same major and minor version of the targeted runtime, such as Microsoft.Extensions.Hosting [8.0.1,9) (can be conditional when multi-targeting). The patch version can be whatever you want.
  4. For all dependencies (including transitive) that are also provided by the runtime, exclude them from the project by setting their ExcludeAssets to runtime, which removes the dependency packages from the output artifact. If they are transitive, you can redeclare them intransitively (with version *), possibly in an imported .props file for nice organization. This can be done only during publishing instead of for all builds when the _IsPublishing property is true.
  5. Publish normally with dotnet publish -p:PublishSingleFile=true.
<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net8.0</TargetFramework>
		<RollForward>latestMajor</RollForward>
		<RestoreEnablePackagePruning>true</RestoreEnablePackagePruning>
		<SelfContained>false</SelfContained>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Extensions.Hosting" Version="[8.0.1,9)" />
	</ItemGroup>

	<ItemGroup Label="Provided" Condition="'$(_IsPublishing)' == 'true'">
		<PackageReference ExcludeAssets="runtime" Version="*" Include="System.Diagnostics.DiagnosticSource" />
		<PackageReference ExcludeAssets="runtime" Version="*" Include="System.IO.Pipelines" />
		<PackageReference ExcludeAssets="runtime" Version="*" Include="System.Text.Encodings.Web" />
		<PackageReference ExcludeAssets="runtime" Version="*" Include="System.Text.Json" />
	</ItemGroup>
</Project>

The dependencies to mark as provided are the union of what your app depends on and what is found in the runtime's (inherited) installation directories, such as /usr/share/dotnet/shared/Microsoft.NETCoreApp/9.0.11/. ASP.NET Core Runtime inherits its provided packages from .NET Runtime.

With this solution, the app will be smaller because it won't include redundant dependencies that are also provided by the runtime. It will run on the target framework version (like 8.0) because its dependency major and minor versions match, so it won't crash from the actual assemblies being too old. It will also roll forward to run on newer framework versions (like 9.0 and 10.0) because the old assemblies were not bundled in the app, so the newer assemblies provided by the runtime will be found and loaded successfully, since they are also forwards compatible.

Alternatives

  • πŸ‘ Set the IncludeAllContentForSelfExtract property to automatically extract the bundled libraries to a temporary directory on app launch, which wastes time and space but avoids this problem. See note on systemd special case.
  • πŸ‘ Publish the app as not a single file, and ship an entire folder full of libraries instead of just one executable. This is a little ugly, but it's simple and effective, especially if your app ships in an installer instead of being a portable executable.
  • πŸ‘Ž Manually upgrade dependencies, recompile, and reinstall all apps on every .NET major version release, or hope and pray for the maintainers to do so for third-party apps. This is a servicing nightmare and isn't viable.
  • πŸ‘Ž Publish self-contained apps, which also require the same steps above for each .NET major version release. This is a servicing and file size nightmare and isn't viable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment