After two years of preparation and migrating several application projects to gain confidence, I recently began migrating the team's largest project from .NET Framework 4.5 to .NET 6. This project started development in 2016, involved up to 50+ developers, has over 10,000 MRs, and no one on the team can fully explain all its features. It references a large number of internal base libraries, many of which have been inactive for years. The application currently has nearly 10 million users, and the migration process also requires preparing many fallback methods. Such a complex project naturally requires many black technologies to complete the migration to .NET 6. This article will share the pitfalls I encountered, the knowledge I gained, and why I made certain decisions.
Preface
To be precise, I was essentially the last mile in this process. I estimate the workload of migrating this project from .NET Framework 4.5 to .NET 6 to be about 1.5 person-years. Although I now say I completed it in five weeks, the previous preparation work is not included. The preparation work included: modifying major base libraries to support .NET Core, filling the gaps between .NET Core and .NET Framework (e.g., the absence of IPC like .NET Remoting and WCF), updating the packaging and build platforms to support .NET Core, updating the OTA (over-the-air update) functionality for complex grayscale releases and testing .NET 6 environment support, and progressively migrating application projects from edge to core, learning from mistakes and accumulating experience.
With sufficient preparation, enough courage, good timing, and the support of the entire team, I began the last mile of migration.
In fact, before making the final switch from .NET Framework 4.5 to .NET 6, neither I nor the team expected so many pitfalls. No one knows how many strange black technologies this massive project uses. While writing this article, I told my colleagues that perhaps no other team in the world would encounter our problems.
Background
This project started development in 2016, involved up to 50+ developers, and none of them were simple: developers who had to build everything themselves, developers who would never build anything if they could use something ready-made, developers whose code appeared on CCTV, developers who participated in setting national standards, developers who insisted on using every strange design pattern in a single class, developers who had to include a big Buddha in code comments, developers who used every black technology they learned, developers who only cared whether the code or the developer could run, developers who blatantly lied with code and comments being completely different, developers who wrote code comments in classical Chinese, developers who wrote all comments in English, developers whose comments and documentation far exceeded the code volume, developers who hadn't even mastered Chinese well, developers who liked digging holes and had to step in them themselves, developers who logged everything, developers who wrote code in suits looking very handsome, developers who wrote code in dresses, developers who acted cute in code, developers who said "only I can call this function", developers who implemented the same logic in different ways, and developers who changed engines while the tank was running.
During this migration, there were many pitfalls to fill. One was that .NET Core lacks a best practice for client applications with multiple EXE entry points. This involves conflicts among multiple EXEs when independently managing the runtime environment and the trade-off between folder size after installation. This is also a focus of this article.
There were also some requirements, including: minimizing dependence on the system while ensuring the system environment meets conditions, ensuring the application is not affected by .NET runtime installed on the user's system, and considering future support for multiple applications within the product line to share the runtime, but this runtime must not be shared with other teams or companies to avoid tampering. Additionally, the WPF version used required customization, meaning we needed to modify part of the logic based on the official release to meet specific product requirements.
This meant redistributing .NET as a library fully controlled by the team. After this change, after updating to .NET 6, we could fully control the .NET framework, including the WPF framework. This allowed us to do much more and made impossible things possible.
To achieve more customization of WPF, I changed the status of the WPF framework from the application runtime layer to the base library layer, at the same level as internal components (CBB), existing only as a low-level library, architecturally at the same level as the lowest-level base library.
The problems encountered fall into two categories: one due to the complexity of the project itself, and the other due to .NET. This article only documents the problems caused by .NET, most of which are due to special customization requirements.
Development Architecture
In the original application development architecture, the dependent .NET Framework existed as a system component. System components are affected by the system environment, and in the chaotic environment in China, system components are often tampered with or damaged. Applications using .NET Framework have high customer support costs, requiring help solving environmental issues. As the user base grows, this cost increases. This is one reason why we could invest so many resources to update the project.
The original development architecture is shown in the following figure:

After updating to .NET, the runtime is above the system layer. This design reduces the impact of the system environment and solves many application environment problems:

From the figure, WPF exists as part of the runtime, which is not conducive to subsequent WPF customization. The team I'm in wants to fully control WPF and make deep customizations. Naturally, the team has this ability, as I am also an official developer of the WPF framework. This deep customization will be partially open-sourced depending on the customization.
The current development architecture after the change is shown below:

WPF is placed as part of the base library instead of the runtime. The plan is that multiple product projects within the product line share the .NET runtime, while individual products carry their own WPF load as a base library.
Problems Encountered
During the last mile update, we encountered some problems that have no best practices in .NET Core.
Dependency Issues for Multi-AppHost Entry Applications
The dependency issue for multi-EXE applications is a mechanical problem. The project being migrated is a multi-process model application with many EXEs. However, .NET Core currently lacks a best practice to allow multiple EXEs to perfectly share the runtime without being affected by the globally installed .NET runtime, while also considering folder size after installation.
The problems I listed are:
- How to share the runtime among multiple EXE files? If they don't share a folder and publish independently, the output folder volume will be very large.
- If multiple EXE files are published in the same folder, they will overwrite assemblies with the same name. According to .NET's reference dependency strategy, if version incompatibility occurs, a
FileLoadExceptionerror will occur. - Cannot use the global assembly store in Program Files because its contents may be modified by other companies' applications, breaking the .NET Core environment independence.
- Cannot use the global assembly store in Program Files because the team will customize the .NET runtime, e.g., by customizing WPF assemblies, changing WPF's status from runtime to base library. This customization cannot pollute other applications.
- The runtime version released to users can only be a stable version, while developers use newer SDK versions. Assemblies built by developers will reference the newer SDK version, which may cause errors if the application only loads the runtime version released to users because the runtime version is lower than the build version.
- The runtime version released to users includes a customized version of the runtime, e.g., customized WPF assemblies. Developers should reference the customized WPF assemblies during development, but cannot reference the user-side runtime version that is lower than the build version.
Additionally, because .NET Core and .NET Framework have mechanical changes for EXEs, .NET Core EXEs are just app hosts that do not contain IL data by default, while in .NET Framework, EXEs contain the application entry and IL data assemblies. This caused many unsupported parts in the original NuGet distribution, but these pitfalls have been smoothed over.
However, customizing the AppHost inevitably conflicts with NuGet distribution. Since NuGet provides unified distribution logic, if an NuGet package contains an EXE file, the configuration in that EXE file will not meet specific project requirements.
Dependency Version Issues
In .NET 6, the dependency resolution logic differs from .NET Framework. In .NET Framework, as long as a DLL with the same name exists, the version is ignored. However, in .NET 6, the actual DLL version must be greater than or equal to the dependency's referenced DLL version. The core issue is the difference between the runtime framework version delivered to users and the SDK version used by developers.
Why does this difference occur? Because developers almost always use the latest SDK, but the runtime version delivered to users is not brave enough to use the latest.
To understand this difference, you need to clarify the concepts:
- The SDK version used by developers: the official .NET SDK version, usually the latest, e.g., 6.0.3.
- The runtime version on the user side: the runtime version distributed to users, usually a stable version, e.g., 6.0.1.
- The private version: a version customized for internal use, e.g., adding business code to the WPF framework, distributed by ourselves. This version also serves as the user-side runtime version, but it is modified based on a stable .NET official release.
After updating to .NET 6, we have full control over .NET and can use our own private .NET version, including the WPF version. This means we can customize the WPF framework sufficiently and use our customized WPF framework in the project.
However, using a customized WPF framework comes at a cost: encountering the difference between the runtime framework version delivered to users and the SDK version used by developers. This difference means that if the distributed version is a private version, it will lag behind the developers' SDK version. Lagging leads to two issues:
- If the developers' SDK version is used as the loaded assembly during software runtime, the private version's assemblies will not be loaded, making it difficult to debug the private version and handle behavioral changes in the private version during development.
- If the private version is used as the loaded assembly, its version number is lower than the developers' SDK version, causing the built assemblies to not find the corresponding version and fail to run.
Current Solutions
The current solution is to add references to the customized assemblies in the application's entry assembly during development and output them. This allows using the private version during development.
During server builds, the entry assembly is configured to not reference the customized assemblies, so all built assemblies do not contain references to the customized assemblies. The customized assemblies are placed in the runtime folder and referenced by the AppHost.
File Organization
Code File Organization
First, store the customized assemblies in the Build\dotnet runtime\ folder in the code repository. For example, the customized WPF framework is stored in Build\dotnet runtime\WpfLibraries\.
Then, place the selected .NET runtime version into the Build\dotnet runtime\runtime\ folder. The runtime folder structure is roughly as follows:
├─host
│ └─fxr
│ └─6.0.1
├─shared
│ ├─Microsoft.NETCore.App
│ │ └─6.0.9901
│ └─Microsoft.WindowsDesktop.App
│ └─6.0.9904
└─swidtag
Then, overwrite the runtime folder with the customized assemblies.
Output File Organization
Output files include two concepts: the installation output folder on the user's device and the output folder during development. These are different.
The installation output folder, for example, is C:\Program Files\Company\AppName\AppName_5.2.2.2268\.
The output folder structure looks roughly like this:
├─runtime
│ ├─host
│ │ └─fxr
│ │ └─6.0.1
│ ├─shared
│ │ ├─Microsoft.NETCore.App
│ │ │ └─6.0.9901
│ │ └─Microsoft.WindowsDesktop.App
│ │ └─6.0.9904
│ └─swidtag
├─runtimes
│ ├─win
│ │ └─lib
│ │ ├─netcoreapp2.0
│ │ ├─netcoreapp2.1
│ │ └─netstandard2.0
│ └─win-x86
│ └─native
├─Resource
│
│ AppHost.exe
│ AppHost.dll
│ AppHost.runtimeconfig.json
│ AppHost.deps.json
│
│ App1.exe
│ App1.dll
│ App1.runtimeconfig.json
│ App1.deps.json
│
└─Lib1.dll
Why put the Runtime folder (containing the runtime) inside the application? For the following reasons:
- Because there are multiple EXEs, self-contained publishing is not realistic.
- Considering that multiple applications within the team may share a runtime in the future, rather than each application carrying its own, putting the runtime in a common folder is reasonable. However, since it's not stable yet, it's tested within the application first.
- This Runtime folder contains customized content that differs from the official .NET runtime, so it cannot be installed globally.
Since self-contained publishing is inappropriate and putting it globally in Program Files is also unsuitable, it must be placed in the application's own folder. To make the Runtime folder inside the application's own folder recognizable, the AppHost file needs to be customized. See the following blogs for details:
- Sharing a self-deployed .NET runtime among multiple executables (EXEs) - walterlv
- How to make .NET programs run independently of the system-installed .NET runtime? Besides Self-Contained, there is a better method! Talk about the working principle of dotnetCampus.AppHost - walterlv
- How to compile, modify, and debug apphost, nethost, comhost, ijwhost in the dotnet runtime repository - walterlv
The output folder during development is for developers to debug. The output folder is $(SolutionDir)bin\$(Configuration)\$(TargetFramework), e.g., for Debug .NET 6, it outputs to bin\Debug\net6.0-windows. The output folder structure looks roughly like this:
├─runtimes
│ ├─win
│ │ └─lib
│ │ ├─netcoreapp2.0
│ │ ├─netcoreapp2.1
│ │ └─netstandard2.0
│ └─win-x86
│ └─native
├─Resource
│
│ AppHost.exe
│ AppHost.dll
│ AppHost.runtimeconfig.json
│ AppHost.deps.json
│
│ App1.exe
│ App1.dll
│ App1.runtimeconfig.json
│ App1.deps.json
│
│ PresentationCore.dll
│ PresentationCore.pdb
│ PresentationFramework.dll
│ PresentationFramework.pdb
│ ...
│ PresentationUI.dll
│ PresentationUI.pdb
│ System.Xaml.dll
│ System.Xaml.pdb
│ WindowsBase.dll
│ WindowsBase.pdb
│
└─Lib1.dll
As you can see, the development output folder does not contain the Runtime folder, but it contains the customized assemblies, e.g., the customized WPF assemblies above. This allows debugging using the SDK's assemblies except for the customized ones. The reason will be explained later.
Modifying Project Files
In the entry assembly, add references to the customized assemblies, e.g., the custom WPF assemblies in the Build\dotnet runtime\WpfLibraries\ folder, and copy them to the output:
<ItemGroup>
<Reference Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
<ReferenceCopyLocalPaths Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
</ItemGroup>
This allows referencing the customized assemblies during development and outputting them, enabling debugging with the customized version.
This is a feature of the .NET SDK: if an assembly with the same name as one in the runtime framework is referenced, it will be used instead of the framework's assembly. This is the basic support for replacing the .NET SDK's default WPF assemblies with customized ones.
For actual releases, during server builds, to reduce the folder volume on the user's device, we want to avoid outputting the customized assemblies from the entry assembly reference; instead, we only use the version in the runtime folder to reduce duplicate files. Therefore, the entry assembly's reference code needs to be optimized, setting it to not output during server builds.
The implementation is: during server builds, pass msbuild parameters to set a property, and in the project file, check the property to know if it's a server build. If so, the assembly reference is not added:
<ItemGroup Condition=" '$(TargetFrameworkIdentifier)' != '.NETFramework' And $(DisableCopyCustomWpfLibraries) != 'true'">
<Reference Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
<ReferenceCopyLocalPaths Include="$(SolutionDir)Build\dotnet runtime\WpfLibraries\*.dll"/>
</ItemGroup>
For details on modifying builds via msbuild parameters, see below.
The above method has a design flaw: the logic used by developers differs from what runs on the user's machine. However, I have not found another way to solve so many problems.
Modifying the Build
During server builds, pass the parameter /p:DisableCopyCustomWpfLibraries=true to msbuild to avoid referencing the customized WPF framework.
Then, during the build, copy the customized runtime from the Build\dotnet runtime\runtime\ folder to the output folder.
/// <summary>
/// Use self-distributed runtime, need to copy from Build\dotnet runtime\runtime
/// </summary>
private void CopyDotNetRuntimeFolder()
{
var runtimeTargetFolder = Path.Combine(BuildConfiguration.OutputDirectory, "runtime");
var runtimeSourceFolder =
Path.Combine(BuildConfiguration.BuildConfigurationDirectory, @"dotnet runtime\runtime");
PackageDirectory.Copy(runtimeSourceFolder, runtimeTargetFolder);
}
That is, do not let the entry assembly reference the customized WPF framework; instead, let the application runtime reference the runtime folder's contents, reducing duplicate files.
Reasons for Decisions
The above solutions involved complex decisions. Below, I explain the reasons for each decision.
Solving Runtime Sharing Among Multiple EXEs
There are multiple EXEs, and some EXEs are in other folders (e.g., Main folder). If these EXEs are all self-contained published, the installation output folder volume is large, with many duplicate files, and builds take a long time.
The solution is to customize the AppHost so that all EXEs load the contents of the runtime folder in the application's output folder. This allows multiple EXEs to share the runtime.
To make the Runtime folder inside the application's folder recognizable, the AppHost needs to be customized. See the following blogs for details:
- Sharing a self-deployed .NET runtime among multiple executables (EXEs) - walterlv
- How to make .NET programs run independently of the system-installed .NET runtime? Besides Self-Contained, there is a better method! Talk about the working principle of dotnetCampus.AppHost - walterlv
- How to compile, modify, and debug apphost, nethost, comhost, ijwhost in the dotnet runtime repository - walterlv
Another solution is to modify the file organization: put only the main entry EXE and its dependencies and runtime in the outermost Main entry application folder, and put other EXEs in inner folders. The inner EXEs should not be executable directly from the outside; they can only be invoked indirectly by the outer entry EXE. When the outer entry EXE starts an inner EXE, it sets environment variables to tell the .NET mechanism of the inner EXE to use the runtime content in the outermost folder.
However, the second solution was not chosen in this migration. The fundamental reason is that there are many old and edge logic cases with strange calling methods. Moving EXEs into inner folders changes their relative paths, potentially breaking many business modules. Some EXEs are also started by other application software, which is also unchangeable. Due to these requirements, we chose to put the Runtime folder in the outermost folder and modify the AppHost to allow these executables to share a single privately deployed .NET runtime.
Solving Custom Version Pollution of the Global .NET
Customizing the .NET runtime, e.g., customizing WPF assemblies and changing WPF's status from runtime to base library, has two ways to distribute to users:
- Bundled with the application, e.g., self-contained publishing.
- Installed globally in Program Files.
To avoid polluting other companies' applications, it cannot be installed globally in Program Files. It must be bundled with the application.
As discussed above, self-contained publishing for each EXE is not suitable, so it must be placed in the runtime folder within the output folder.
Invoking Plugin Processes
Some plugin processes are placed in the AppData folder, not in the application's installation output folder. How can they use the runtime without carrying their own copy?
The solution is to use environment variables. In .NET, the process looks for the runtime based on the DOTNET_ROOT environment variable.
In the main application entry Program, set the environment variable for the application itself. According to .NET's Process start policy, processes started by the current process via Process will inherit the current process's environment variables. Thus, plugin processes started by the main application can obtain the DOTNET_ROOT environment variable and use the main application's runtime.
/// <summary>
/// Set environment variable so that child processes can also find the runtime
/// </summary>
static void AddEnvironmentVariable()
{
string key;
if (Environment.Is64BitOperatingSystem)
{
// https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables
key = "DOTNET_ROOT(x86)";
}
else
{
key = "DOTNET_ROOT";
}
// For example, start a standalone process in AppData (e.g., CEF process) that can find the runtime
var runtimeFolder =
Path.Combine(AppDomain.CurrentDomain.SetupInformation.ApplicationBase!, "runtime");
Environment.SetEnvironmentVariable(key, runtimeFolder);
}
According to official documentation, for x86 applications, the DOTNET_ROOT(x86) environment variable should be used.
For details, see dotnet 6: Using DOTNET_ROOT to let child processes get the shared runtime folder
However, this method has a clear disadvantage: these plugins cannot run independently; if they run alone, they will fail because they cannot find the runtime. They must be started by the main entry process or another process that has the runtime, which sets the environment variable.
There is also a solution to this problem: without polluting the global .NET, install .NET in the product's own folder. The default Program Files folder layout for applications is C:\Program File\<Company>\<Product>. So, you can install .NET as a product, resulting in a layout like C:\Program File\<Company>\dotnet. This allows multiple applications to share the runtime via absolute paths.
In this migration, we did not adopt the C:\Program File\<Company>\dotnet layout because of the current .NET management method, the ongoing version migration, and the fact that the private WPF is not yet mature. Moreover, this layout would require considering OTA updates and rollback issues, requiring more resources. However, this approach can be the final form.
Handling the Version Mismatch Between Developer SDK and User Runtime
Problem: The developer's SDK version is higher than the runtime version intended for users. Assemblies built with the newer SDK reference higher-version .NET assemblies. When developers run the application, it will report that the corresponding version assembly cannot be found.
Since writing App.config is ineffective, we cannot use the previous method to unify multiple versions into one. We are still looking for a solution but haven't found one yet.
Two solutions were tried: First, have developers install the same SDK version as the user runtime and use global.json to specify the version. This works, but requires developers to install an additional SDK (by extracting files).
The first method requires each developer to install the old SDK version, and each SDK update requires repeating this for all developers. This is unfriendly for new developers because it requires environment setup. Moreover, .NET SDK does not allow installing an older version when a newer version is available (except preview versions), making deployment complex. This is why the first method is not used.
The second method: In the entry assembly, reference the customized WPF assemblies. During development, they are output and loaded. During release, use the runtime folder's content and delete the output folder's content.
The reason for deleting the output folder's content during release is to reduce file volume on the user's device, because the runtime folder's content and the customized assemblies in the entry assembly's folder are identical. For example, the customized WPF assemblies are about 30MB after release; duplicate files would take up an extra 30MB, though this does not affect the installer size.
The second method has a disadvantage: each time a private WPF version is released or the .NET version is updated, the files must be manually copied. Future versions may consider using NuGet distribution packages.
The second method cannot simply delete the output folder's content; during server packaging, the entry project must not reference the customized assemblies, otherwise the deps.json file will reference the deleted assemblies, causing the software to fail.
Here is an example of deps.json referencing an assembly:
"PresentationFramework/6.0.2.0": {
"runtime": {
"PresentationFramework.dll": {
"assemblyVersion": "6.0.2.0",
"fileVersion": "42.42.42.42424"
}
},
"resources": {
"cs/PresentationFramework.resources.dll": {
"locale": "cs"
},
"de/PresentationFramework.resources.dll": {
"locale": "de"
},
"es/PresentationFramework.resources.dll": {
"locale": "es"
},
"fr/PresentationFramework.resources.dll": {
"locale": "fr"
},
"it/PresentationFramework.resources.dll": {
"locale": "it"
},
"ja/PresentationFramework.resources.dll": {
"locale": "ja"
},
"ko/PresentationFramework.resources.dll": {
"locale": "ko"
},
"pl/PresentationFramework.resources.dll": {
"locale": "pl"
},
"pt-BR/PresentationFramework.resources.dll": {
"locale": "pt-BR"
},
"ru/PresentationFramework.resources.dll": {
"locale": "ru"
},
"tr/PresentationFramework.resources.dll": {
"locale": "tr"
},
"zh-Hans/PresentationFramework.resources.dll": {
"locale": "zh-Hans"
},
"zh-Hant/PresentationFramework.resources.dll": {
"locale": "zh-Hant"
}
}
},
The solution to the above problem is the method described earlier: use different reference relationships for developer builds and server builds.
Handling the Problem of Loading Global Assemblies on the User Side
Background
In .NET, version evaluation is performed based on the Roll forward policy. With the default Minor policy, the system first looks for the Runtime folder recorded in the AppHost, then looks for the .NET folder in Program Files. It selects an appropriate version. For example, if the application is packaged with 6.0.1 and the user has 6.0.3 installed in Program Files, the system will choose the 6.0.3 version from Program Files.
This means if the user's Program Files 6.0.3 version is corrupted, the application will use the corrupted files.
Thus, the goal of using .NET to handle environment issues would not be achieved.
The expectation is that the application should not automatically load the global assemblies from Program Files on the user's machine; instead, it should use the runtime folder bundled with the application.
Solution
Make the version number of the .NET folder in the application's Runtime folder high enough to solve this problem.
Change the .NET folder in the application's Runtime to version 6.0.990x, where x corresponds to the original .NET official Minor version. For example, 6.0.1 becomes 6.0.9901.
According to the Roll forward logic, it will consider 6.0.990x as the highest version and will not load the global assemblies from Program Files.
For details, see https://docs.microsoft.com/en-us/dotnet/core/versions/selection
Debugging Method
Modifying the Runtime folder loading path requires debugging. Since most developers have the SDK environment installed, they cannot easily debug on their own machines. If the Runtime folder configuration is incorrect, the AppHost will default to loading the SDK environment, so the application may run as expected on the developer's machine.
However, on the user's machine, without the environment or with a corrupted environment, the application will fail to start.
One debugging method on the developer's machine is to set environment variables and use .NET's built-in AppHost debugging mode to output the loading process to a file.
Suppose the application to test is App.exe. Open cmd and first run the following commands to set environment variables for the current cmd (this does not pollute the development environment):
set COREHOST_TRACE=1
set COREHOST_TRACEFILE=host.txt
After setting, run App.exe via the command line. The App.exe will output debugging information to the host.txt file:
App.exe
An example of the debugging information:
--- The specified framework 'Microsoft.WindowsDesktop.App', version '6.0.0', apply_patches=1, version_compatibility_range=minor is compatible with the previously referenced version '6.0.0'.
--- Resolving FX directory, name 'Microsoft.WindowsDesktop.App' version '6.0.0'
Multilevel lookup is true
Searching FX directory in [C:\lindexi\App\App\runtime]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [6.0.1]
Changing Selected FX version from [] to [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1]
Searching FX directory in [C:\Program Files (x86)\dotnet]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [3.1.1]
Inspecting version... [3.1.10]
Inspecting version... [3.1.20]
Inspecting version... [3.1.8]
Inspecting version... [5.0.0]
Inspecting version... [5.0.11]
Inspecting version... [6.0.1]
Inspecting version... [6.0.4]
Attempting FX roll forward starting from version='[6.0.0]', apply_patches=1, version_compatibility_range=minor, roll_to_highest_version=0, prefer_release=1
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
Applying patch roll forward from [6.0.1] on release only
Inspecting version... [6.0.4]
Inspecting version... [6.0.1]
Changing Selected FX version from [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1] to [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]
Chose FX version [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]
Starting from ---, it loads various frameworks, such as Desktop. The first search folder is configured in the AppHost, set by the method in Sharing a self-deployed .NET runtime among multiple executables (EXEs) - walterlv. The application first looks for the runtime folder's content, as per the file layout above.
Then, .NET reads the Roll forward strategy as minor, finds version 6.0.1 in the runtime folder:
'Roll forward' enabled with version_compatibility_range [minor]. Looking for the lowest release greater than or equal version to [6.0.0]
Found version [6.0.1]
As the first found, it becomes the default runtime folder:
Changing Selected FX version from [] to [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1]
Then it continues searching the C:\Program Files (x86)\dotnet folder:
Searching FX directory in [C:\Program Files (x86)\dotnet]
Many versions are found in the global folder, and they are compared with the default runtime folder to find the most suitable one.
In the example above, it finds 6.0.4, which is more suitable than the default 6.0.1, so it changes the selected runtime folder to 6.0.4:
Changing Selected FX version from [C:\lindexi\App\App\runtime\shared\Microsoft.WindowsDesktop.App\6.0.1] to [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]
Since no other folders can be searched, 6.0.4 is chosen as the runtime folder:
Chose FX version [C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.4]
With this, you can see if the application's runtime folder meets expectations.
These are the pitfalls encountered during the migration of this application and the decisions made. I hope this helps with your migration.