Mert Özen Further With Every Line
Backend .NET SDK, CLI and MSBuild — #09

What Changed in the .NET SDK from .NET 6 to 11: Release Publish by Default, Terminal Logger, .slnx, dnx, File-Based Apps and the .NET 11 CLI

Mert Özen Nov 15, 2026 10 min 6 views
What Changed in the .NET SDK from .NET 6 to 11: Release Publish by Default, Terminal Logger, .slnx, dnx, File-Based Apps and the .NET 11 CLI

What breaks when you upgrade the SDK is usually not the code but the places that repeat the version: the errorMessage in global.json, container image tags and CI steps. We tabulate SDK changes from .NET 6 to 11, give a checklist for reading an old project, move Bookstore to SDK 11 while keeping it on net10.0, and measure the MSBuild server's effect in CI.

Version note: The code in this post was built and run with .NET SDK 11.0.100 and 10.0.404; the apps target net10.0 and run on the .NET 10.0.15 runtime. The PublishAot output was produced in the mcr.microsoft.com/dotnet/sdk:11.0.100-resolute-aot image.

Guess First

We changed a single line in the global.json of Bookstore, the online bookstore used as the shared example across this site's series: "version": "10.0.404" became "11.0.100". The apps still target net10.0; only the SDK that runs the build changed. On a CI agent that had not been updated yet and only had SDK 10.0.404, the build ended like this:

The command could not be loaded, possibly because:
  * You intended to execute a .NET application:
      The application 'build' does not exist or is not a managed .dll or .exe.
  * You intended to execute a .NET SDK command:
      Bookstore needs .NET SDK 10.0.4xx (10.0.404 or later). Install it from https://dotnet.microsoft.com/download/dotnet/10.0

The agent has 10.0.404 installed, and the message asks for exactly that. So where is the problem? We wrote that message into the errorMessage field of global.json in post 1; we changed the version and forgot the message. A misleading error message costs more time than no message at all. In this last post of the series we look at what changed in the SDK from .NET 6 to 11, list what to watch for when reading an old project, and show what actually broke when we moved Bookstore to SDK 11.

From .NET 6 to 9: Where Today's Defaults Came From

Most of this series was built on defaults that entered the SDK over the last five years. We checked every item on the day of writing against the "what's new" and breaking change pages on Microsoft Learn:

VersionChange in the SDKIn this series
.NET 6ImplicitUsings and Nullable in templates; dotnet workloadPosts 1 and 2
.NET 7Central Package Management; container images with dotnet publish; dotnet new install (formerly --install)Post 3
.NET 8Release by default for publish and pack; artifacts output; -getProperty/-getItem/-getTargetResult; Source Link and the commit in InformationalVersion; NuGetAudit; terminal logger (opt-in)Posts 2, 3, 6 and 8
.NET 9Terminal logger by default; workload sets; .slnx and dotnet sln migrate in 9.0.200; dotnet run -ePosts 1 and 3
.NET 10dotnet new sln now creates .slnx; file-based apps; dnx; Microsoft Testing Platform in dotnet test; noun-first commands; NuGetAuditMode=all for net10.0 projectsPosts 1, 4 and 7
.NET 11CLI host compiled with Native AOT; MSBuild server on by default; solution filter commands; minimum publish age; NETSDK1235; UseOptimizedPublishPosts 1, 3, 4, 6 and 7

Two rows of the table cause silent behavior changes when you upgrade an old project. With .NET 8, dotnet publish switched to Release; a pipeline whose script looked for a bin/Debug/.../publish path broke that day. In the same version, passing a RuntimeIdentifier stopped meaning self-contained; a command that used to package the runtime with -r win-x64 produces framework-dependent output in a project targeting .NET 8. That is why we wrote --self-contained explicitly in every command in post 6.

In .NET 9 the terminal logger became the default. This output, which shows progress live in the terminal, is also attempted in CI logs; if you use a tool that processes the log line by line, MSBUILDTERMINALLOGGER=off or --tl:off brings back the old plain output. That is why we took some of the build output in this series with -tl:off.

Our plan had NuGetAudit scanning transitive packages under .NET 9; NuGet's documentation says it came with the .NET 10 SDK (NuGet 7.0): for projects targeting net10.0 and later, NuGetAuditMode defaults to all, and to direct for older ones. In a project like Bookstore.Contracts that targets net8.0;net10.0, one TFM selects all, so both are audited with all.

.NET 10: .slnx, File-Based Apps and dnx

The most visible change in .NET 10 is that new solutions come as .slnx. Microsoft lists it as a breaking change:

dotnet new sln -n Legacy
The template "Solution File" was created successfully.

Legacy.slnx appeared in the folder. If you need the old format you pass --format sln. To convert an existing .sln there has been dotnet sln migrate since 9.0.200. While trying it we noticed one thing: with Legacy.slnx already in the same folder, the command overwrote it without a warning.

dotnet new sln -n Legacy --format sln
dotnet sln Legacy.sln add Bookstore.Legacy
dotnet sln Legacy.sln migrate
Project `Bookstore.Legacy/Bookstore.Legacy.csproj` added to the solution.
.slnx file C:\src\Legacy\Legacy.slnx generated.

Migrate does not delete the old file. With two solution files in the folder, commands such as dotnet sln add give the "Found more than one solution file" error we saw in post 7. After converting, delete the .sln, update the file names in CI scripts, and make sure everyone's IDE on the team can open .slnx.

File-based apps, dnx and platform-specific tool packages were the topic of post 7; the global.json setting that connects Microsoft Testing Platform to dotnet test has been in Bookstore since post 1. .NET 10 also moved commands to a noun-first form: dotnet package add instead of dotnet add package. The old form still works, but using the new one in new scripts means speaking the same language as the documentation.

.NET 11: What Changed in the SDK?

We measured the two big CLI changes in .NET 11, the dotnet command compiled with Native AOT and the MSBuild server being on by default, in post 1. The others are spread across the series: solution filter commands in post 3, minPublishAgeHours in post 4, UseOptimizedPublish in post 6, NETSDK1235 and #:include in post 7. One note on UseOptimizedPublish: the breaking change page says Native AOT publish skips the full build, while the "what's new" page and SDK 11.0.100's own target file say the setting is opt-in. When pages disagree, the target file of the installed SDK has the final word.

There are three new things on the container side, and we ran none of them in this post: identical image digests from identical inputs with SOURCE_DATE_EPOCH (reproducible containers), multi-arch images with Podman, and container runtime selection preferring wslc on Windows and container on macOS. Images and registries are covered in the Docker series.

A small change that is easy to miss: the "Using launch settings from..." message of dotnet run now goes to stderr instead of stdout. Scripts that redirect the output of dotnet run to a file or another command no longer have to filter out that line.

Reading an Old Project: A Checklist

Many companies still have project files from the .NET Framework era. When you open one, look for these:

  • If there is a packages.config, the packages are not in the project file but in a separate XML file and a packages/ folder, with transitive dependencies listed by hand. Visual Studio's "Migrate packages.config to PackageReference" command does the switch; we didn't try it in this post.
  • A csproj that starts with <Project ToolsVersion=...> and has no Sdk attribute lists every file one by one with Compile Include. When moving to SDK-style, delete that list; the globs from post 2 do the same job.
  • Properties/AssemblyInfo.cs clashes with the attributes the SDK generates in an SDK-style project.
  • If there is a .sln at the root, convert it to .slnx with dotnet sln migrate and delete the old file.
  • If there is no global.json at the root, add one; without it every machine builds with its own newest SDK.

We saw the third item with a test project. When the old AssemblyInfo.cs stayed in a library converted to SDK-style:

C:\src\Legacy\Bookstore.Legacy\obj\Debug\net10.0\Bookstore.Legacy.AssemblyInfo.cs(15,12): error CS0579: Duplicate 'System.Reflection.AssemblyFileVersionAttribute' attribute [C:\src\Legacy\Bookstore.Legacy\Bookstore.Legacy.csproj]
C:\src\Legacy\Bookstore.Legacy\obj\Debug\net10.0\Bookstore.Legacy.AssemblyInfo.cs(18,12): error CS0579: Duplicate 'System.Reflection.AssemblyTitleAttribute' attribute [C:\src\Legacy\Bookstore.Legacy\Bookstore.Legacy.csproj]
C:\src\Legacy\Bookstore.Legacy\obj\Debug\net10.0\Bookstore.Legacy.AssemblyInfo.cs(19,12): error CS0579: Duplicate 'System.Reflection.AssemblyVersionAttribute' attribute [C:\src\Legacy\Bookstore.Legacy\Bookstore.Legacy.csproj]

The file the error points to is under obj, the copy the SDK generates; but the problem is in your file. Moving the version and title information into properties such as Version and AssemblyTitle and deleting the file is better than turning off GenerateAssemblyInfo; if you turn it off, the AssemblyMetadata items from post 8 stop working too. According to the note on Learn, Microsoft's .NET Upgrade Assistant is no longer being developed and GitHub Copilot's modernization agent is recommended instead; we used neither and did the steps by hand.

Moving Bookstore to SDK 11

Why move? .NET 11 is a standard term support release with 24 months of support, ending in November 2028, which is the same end date as .NET 10, the long term support release. The apps stay on net10.0, the LTS runtime; SDK 11 brings us the new CLI, NuGet 8 and MSBuild 18.12. We did the move in a copy of the repo and ran every step again with SDK 11.0.100: the solution built with zero warnings, the 16 tests across both frameworks passed, and the win-x64 publish profile, the tool and template packages and the check-isbns.cs script worked unchanged. What broke was not in the code but in the places that repeat the SDK version.

The first was the error message from the opening. The new global.json:

{
  "sdk": {
    "version": "11.0.100",
    "rollForward": "latestPatch",
    "errorMessage": "Bookstore needs .NET SDK 11.0.100 or a later 11.0.1xx patch. The apps still target net10.0. Install the SDK from https://dotnet.microsoft.com/download/dotnet/11.0"
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

The second was the Native AOT container from post 6. The sdk:10.0.404-noble-aot image has no SDK 11; global.json rejected it and the build stopped with the text from errorMessage. While looking for the right image there was another surprise: on the day of writing, the Microsoft Container Registry had no sdk:11.0.100-noble-aot tag; .NET 11 SDK images are based on Ubuntu 26.04 and come with a -resolute suffix:

docker run --rm -v ${PWD}:/src -w /src mcr.microsoft.com/dotnet/sdk:11.0.100-resolute-aot dotnet publish tools/Bookstore.Admin -r linux-x64 -p:PublishAot=true -o artifacts/aot
  Generating native code
  Bookstore.Admin -> /src/artifacts/aot/

In a repo the SDK version is usually repeated in four places: global.json, the SDK setup step in CI, the FROM lines in Dockerfiles, and the documentation. In Bookstore we also updated the requirements line in the README. Do a version upgrade as a single pull request that changes these four places together.

The third was a notice: dotnet tool restore says 11.0.0 is out for dotnet-ef. Upgrading the SDK doesn't require upgrading tools; Bookstore uses EF Core 10, and the tool stays at 10.0.15.

The MSBuild Server and CI

In post 1 we left the question of why the MSBuild server might be turned off in CI to this post. With SDK 11, after a build an MSBuild process with /nodemode:8 on its command line stays in the background; later builds evaluate and build the project in this warm process. We measured the benefit in Bookstore. Locally, repeated dotnet build tools/Bookstore.Admin runs with no changes took a median of 0.28 seconds with the server on and 0.62 seconds with DOTNET_CLI_USE_MSBUILD_SERVER=false. But when we shut down the build servers and started cold each time, the difference disappeared: 0.78 seconds against 0.85 seconds.

A CI job is exactly this second case: each job starts on a clean machine, usually runs a single build and ends. The server gains next to nothing; in return, a process is left behind when the job ends. On ephemeral agents that process is deleted along with the machine. On persistent (self-hosted) agents, though, it lives between jobs, holds memory and keeps the task assemblies it loaded; a new version of a task that is built in the repo and used in the build may not take effect until the old process exits. Our recommendation for Bookstore's CI is to turn the server off with an environment variable:

DOTNET_CLI_USE_MSBUILD_SERVER=false

After a build with this variable, no /nodemode:8 process was created. On developer machines, leave the server on; the time it saves on repeated builds is real. If you use persistent agents, adding dotnet build-server shutdown as the last step of the job is another option. Your choice depends on whether your CI is ephemeral or persistent.

In Production

Keep SDK upgrades separate from runtime upgrades. Bookstore is built with SDK 11 but runs on the .NET 10 runtime; the risk in production did not change, only the build tools did. Moving the runtime to net11.0 is a separate decision and a separate pull request; since .NET 11 is STS, that decision has to be made together with the support window.

Run the same commands before and after the upgrade and compare the outputs: build warnings, test counts, the contents of the publish folder, the contents of the packages. In our move they were all the same. Had they differed, seeing the difference in the upgrade pull request would have been much cheaper than seeing it in production.

Read the breaking change lists, but verify on your own SDK. In this series we saw that dotnet run -e, #:include and the solution filter commands work on earlier SDKs than the .NET 11 release notes suggest, and that UseOptimizedPublish is described differently on two pages.

Common Pitfalls

Changing the version in global.json and forgetting errorMessage. The message looks as if it asks for the version installed on the agent, and finding the problem takes longer.

Changing the SDK version only in global.json. Container images, CI setup steps and documentation stay on the old version; that is exactly how our AOT container broke.

Leaving the MSBuild server on for persistent CI agents. Leftover processes hold memory and may keep old task assemblies loaded; on ephemeral agents there is no problem, on persistent ones turn it off or shut it down at the end of the job.

Leaving the .sln after dotnet sln migrate. In a folder with two solution files the CLI can't tell which one to use; and if an .slnx with the same name exists, migrate silently overwrites it.

Keeping the old AssemblyInfo.cs in an SDK-style project. You get CS0579 errors; move the information into properties and delete the file.

A Small Experiment

★☆☆ Change the version in global.json to a version not installed on your machine (for example 11.0.200) and run dotnet build. Look at how your own errorMessage appears, then rewrite the message so that someone reading it knows what to do.

★★☆ Publish Bookstore separately with SDK 10.0.404 and 11.0.100 (dotnet publish src/Bookstore.Api) and compare the two publish folders file by file. Do any files differ, and if so, why?

★★★ Read the oldest .NET project at your company with the checklist in this post: packages.config, a non-SDK-style csproj, AssemblyInfo.cs, .sln, global.json. Convert it to SDK-style on a branch and compare the build and test output before and after.

This post ends the series. We started from the dotnet CLI and went all the way down through global.json, the project file, shared build settings, NuGet restore, multi-targeting, publish options, tools and templates, and MSBuild itself; every step was run on Bookstore and shown with its real output. From here the road continues in other series: publishing packages and generating version numbers in the Versioning and Release Management series, putting the build into a pipeline in the GitHub Actions series, and turning publish output into an image in the Docker series.