Skip to main content

Test .NET MAUI Preview SDKs Locally with global.json sdk.paths

I like trying new .NET MAUI previews early. That is where the interesting fixes and new platform work usually show up first.

What I do not like is breaking my main machine while doing that.

Installing preview SDKs and workloads globally is fine until it is not. Suddenly another project picks up the wrong SDK, a workload update changes more than you expected, or you spend time figuring out how to get back to the clean state you had before.

That is why I really like the new global.json sdk.paths feature in .NET 10. It lets you install a .NET SDK into a local .dotnet/ folder for one repository and tell the .NET host to look there first.

I had shared this kind of workflow mostly as short social posts before, but it deserves a proper write-up.

TL;DR #

  • Install the preview or specific .NET SDK into a project-local .dotnet/ folder.
  • Add sdk.paths to global.json so that this repo uses that local SDK first.
  • Keep "$host$" as fallback if you still want your machine-wide SDK to be used when possible.
  • Add .dotnet/ to .gitignore.
  • For .NET MAUI workloads, run workload commands through the local binary: ./.dotnet/dotnet workload install maui.
  • If you want to go back, delete .dotnet/ and update or remove global.json.
  • There is now a setup-local-sdk skill that helps GitHub Copilot follow this workflow.

The official docs are here: Test prerelease .NET SDKs locally with global.json paths.

The Problem with Preview SDKs #

When a new .NET MAUI major version or preview SDK appears, I want to try it quickly.

Maybe I want to check if my app still builds. Maybe I want to test a new iOS or Android workload. Maybe I want to see whether a bug is actually fixed before I update a bigger project.

The classic way is to install the SDK globally. That works, but it also means the SDK is now part of my machine setup. If I install workloads globally too, there is even more state involved.

That is not always what I want for a quick test.

For this kind of thing I prefer a setup that is boring to undo: install the SDK into a repo-local .dotnet/ folder that is ignored by Git, point this repository to that SDK, delete the folder when I am done.

No magic. No machine-wide cleanup adventure.

Minimal Local SDK Setup #

From the root of your repository, install a preview SDK into .dotnet/. This example uses the current next major preview style. Replace 11.0 and preview with the channel and quality you need.

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 11.0 --quality preview --install-dir .dotnet
rm dotnet-install.sh

On Windows with PowerShell:

Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile 'dotnet-install.ps1'
.\dotnet-install.ps1 -Channel 11.0 -Quality preview -InstallDir .dotnet
Remove-Item dotnet-install.ps1

Then create or update global.json:

{
  "sdk": {
    "paths": [".dotnet", "$host$"]
  }
}

And make sure the local SDK folder is ignored by Git:

.dotnet/

That is the smallest version of the setup.

When you run dotnet build, dotnet test, or dotnet run inside this repo, the .NET 10+ host reads global.json, sees sdk.paths, and looks in .dotnet/ first. If it finds a suitable SDK there, it uses that one.

The "$host$" entry means “also look at my normal machine-wide .NET install”. I like keeping that in there for flexibility, especially when I am not pinning an exact SDK version yet.

If you want to verify what is being used:

dotnet --version
dotnet --info

In dotnet --info, check the SDK base path. It should point somewhere under your repository’s .dotnet/ folder.

A More Team-Friendly global.json #

The minimal version is nice for a quick test. For a team, I would usually make it more explicit.

{
  "sdk": {
    "version": "11.0.100-preview.2.26159.112",
    "allowPrerelease": true,
    "rollForward": "latestFeature",
    "paths": [".dotnet", "$host$"],
    "errorMessage": "Required .NET SDK not found. Run ./install-dotnet.sh or .\\install-dotnet.ps1 to install it locally."
  }
}

This gives your repository a clear expectation:

  • version says what SDK line you are testing.
  • allowPrerelease makes preview SDK resolution intentional.
  • rollForward gives the host some room within the same feature band.
  • paths keeps the SDK lookup local first.
  • errorMessage tells the next person what to do instead of leaving them with a vague SDK resolution error.

I also like adding a small install script to the repository. The script installs the SDK into .dotnet/, updates global.json, and makes sure .dotnet/ is ignored.

For example, the important bits of a macOS/Linux script look like this:

#!/usr/bin/env bash
set -euo pipefail

CHANNEL="11.0"
QUALITY="preview"
INSTALL_DIR="$(pwd)/.dotnet"

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel "$CHANNEL" --quality "$QUALITY" --install-dir "$INSTALL_DIR"
rm dotnet-install.sh

SDK_VERSION="$("$INSTALL_DIR/dotnet" --version)"
echo "Installed local .NET SDK $SDK_VERSION"

You can then write the detected SDK_VERSION into global.json so everyone on the team gets the same SDK version. The official docs show a fuller version of these scripts for both bash and PowerShell.

Why This Is Great for .NET MAUI Previews #

.NET MAUI is exactly the kind of workload where I want this.

When a new .NET MAUI preview lands, I often want to check one app or one sample. I do not want every project on my machine to suddenly have a different SDK available. I also do not want to guess which workload packs were installed where.

With a local SDK setup, the experiment is scoped to the repository.

The local SDK lives in:

.dotnet/

The repository points to it through:

global.json

If the experiment goes wrong, I remove the local state:

rm -rf .dotnet/

On Windows:

Remove-Item -Recurse -Force .dotnet

With the minimal paths setup and "$host$" fallback, the repository falls back to the machine-wide SDK when the local SDK is gone. If you pinned a preview version in global.json, also update or remove that pin when you are done.

That is a much nicer failure mode than trying to undo a global SDK and workload install.

The New setup-local-sdk Skill #

I also added this workflow as a new GitHub Copilot skill in dotnet/skills PR #508.

The skill is called setup-local-sdk. It gives Copilot a repeatable checklist for this exact setup:

  • check that a .NET 10+ host is available;
  • install a specific or prerelease SDK with the official dotnet-install scripts;
  • configure global.json with sdk.paths;
  • add .dotnet/ to .gitignore;
  • install optional workloads through the local SDK;
  • verify the resolved SDK;
  • explain how to clean it all up again.

That last part matters. I do not want an agent to just install things and leave me with mystery state. The whole point is that the setup is project-local and reversible.

Caveat 1: You Need a .NET 10+ Host #

This is the most important detail: sdk.paths requires a .NET 10 or newer host installed globally.

The host is the dotnet executable on your PATH. It starts first, reads global.json, and then decides which SDK to load.

If your global host is .NET 8 or .NET 9, it does not understand sdk.paths. It will ignore the property and use the normal SDK resolution rules.

Also, sdk.paths is SDK resolution. It is not a general runtime-only installation feature.

It helps SDK commands like these resolve the SDK from .dotnet/:

dotnet build
dotnet run
dotnet test

It does not mean every framework-dependent executable or runtime-only launch on your machine will automatically probe this local folder. Keep that distinction in mind when you are testing more advanced scenarios.

Caveat 2: Use the Local dotnet for Workloads #

For .NET MAUI workloads, use the local dotnet binary directly:

./.dotnet/dotnet workload install maui
./.dotnet/dotnet workload list

On Windows:

.\.dotnet\dotnet.exe workload install maui
.\.dotnet\dotnet.exe workload list

This is important because workload metadata is relative to the dotnet root of the host process. global.json sdk.paths routes SDK resolution correctly, but workload commands have this extra bit of state.

If you run workload commands through the machine-wide dotnet, you can end up installing or updating workloads in the machine-wide dotnet root instead of the local one.

There is an open SDK issue related to this area here: dotnet/sdk#49825.

Also be realistic about MAUI workloads per platform. Install the workloads that make sense for the operating system you are on. Android, iOS, Mac Catalyst, and Windows all have their usual platform requirements. A local SDK does not make Windows able to build iOS apps, and it does not remove the need for platform-specific tooling.

For CI, I would keep this explicit per runner. Install the local SDK on each OS, then run the workload install with that runner’s local .dotnet/dotnet.

Caveat 3: Do Not Commit .dotnet/ #

The .dotnet/ folder is a local cache. It can be several hundred megabytes and it belongs to your machine or CI runner.

Commit the setup:

global.json
install-dotnet.sh
install-dotnet.ps1
.gitignore

Do not commit the installed SDK:

.dotnet/

Each developer runs the install script locally. Each CI runner can do the same, optionally with caching.

Wrapping Up #

This is a small feature, but for me it makes preview testing feel much safer.

I can try a new .NET MAUI SDK in one repository, install the workloads into that same local SDK, and throw it all away when I am done.

If you are testing a new .NET MAUI major version, the next .NET SDK preview, or a very specific SDK version for a bug report, give global.json sdk.paths a look.

Start with the official docs, and if you are using GitHub Copilot for this kind of repository setup, check out the new setup-local-sdk skill.