Covers dotnet tool: global vs local vs tool-path installs, the .config/dotnet-tools.json manifest, restore in CI, one-shot execution in .NET 10, and supply-chain safety.
A .NET tool is a console app shipped as a special NuGet package. Global tools (dotnet tool install -g) are installed per user and land on your PATH. Local tools are pinned per repository in a manifest, .config/dotnet-tools.json, created with dotnet new tool-manifest. Anyone who clones the repo runs dotnet tool restore and gets exactly the same versions. For team and CI work I prefer local tools, because the version lives in source control next to the code that depends on it.
"A .NET tool is a console app packaged as a NuGet package with
PackAsTool, likedotnet-efordotnet-counters. There are three ways to install one. Global tools, with-g, are per user and on your PATH;--tool-pathinstalls into a folder you choose; and local tools are pinned per repository.Local tools are recorded in
.config/dotnet-tools.json, which I create withdotnet new tool-manifestand commit. Anyone who clones the repo, or any CI agent, runsdotnet tool restoreand gets exactly the same versions. The SDK finds the manifest by walking up from the current folder, andisRootstops that search so a stray manifest higher up can't add tools. In a pipeline, tool restore is one of the first steps, and the pinneddotnet-efis then what applies or bundles migrations. In .NET 10,dotnet tool exec, or thednxshortcut, runs a tool one-off without installing it, which is great ad hoc, but in CI I pin a version.The mistake I see is everyone installing
dotnet-efglobally, so versions drift from the EF Core packages and migrations differ between machines. Tools also run arbitrary code with my permissions, so I pin versions, check the package id for typosquatting, and restore through a trusted internal feed with source mapping. I treat the tool manifest like a lock file: reviewed, updated through automation, and restored from a controlled feed."
A .NET tool is a console application packed as a NuGet package with <PackAsTool>true</PackAsTool>. You install it with dotnet tool install, and the SDK puts a launcher in front of it. Common examples are dotnet-ef, dotnet-counters, dotnet-trace, dotnet-dump, dotnet-outdated-tool and csharpier.
| Mode | Command | Where it lives | How you run it | Version pinned in repo? |
|---|---|---|---|---|
| Global | dotnet tool install -g dotnet-ef |
~/.dotnet/tools (on PATH) |
dotnet-ef or dotnet ef |
No |
| Tool path | dotnet tool install dotnet-ef --tool-path ./tools |
The folder you choose | ./tools/dotnet-ef |
No (unless you script it) |
| Local | dotnet tool install dotnet-ef (with a manifest) |
NuGet cache, recorded in .config/dotnet-tools.json |
dotnet ef or dotnet tool run dotnet-ef |
Yes |
dotnet new tool-manifest # creates .config/dotnet-tools.json
dotnet tool install dotnet-ef --version 10.0.0
dotnet tool install csharpier
git add .config/dotnet-tools.json
A teammate or a CI agent then only needs:
dotnet tool restore
dotnet ef migrations list
The SDK searches for the manifest from the current directory upwards, so one manifest at the repo root covers every project.
dotnet tool list and dotnet tool list -g show what is installed.dotnet tool update <id> updates a tool and, for local tools, rewrites the manifest.dotnet tool uninstall <id> removes it.dotnet tool exec <id>, with the dnx shorthand. It downloads and runs a tool without installing it permanently, which is handy for a one-off but is not a replacement for a pinned manifest in CI.On a team where every developer had installed dotnet-ef globally, migrations generated on one laptop differed from another because the tool versions had drifted from the EF Core runtime package. We moved dotnet-ef into a local manifest pinned to the same major version as Microsoft.EntityFrameworkCore, added dotnet tool restore as the first pipeline step, and the drift stopped.
nuget.config with package source mapping.dotnet tool restore benefits from the NuGet package cache, so cache ~/.nuget/packages between pipeline runs to cut restore time.Checks whether the candidate knows how teams keep tooling reproducible.
Green flags: explains the manifest and dotnet tool restore; prefers local tools for repo work; mentions version pinning and supply-chain risk.
Red flags: "everyone just installs it globally"; doesn't know where the manifest lives; treats tools as harmless.
A .NET tool is an ordinary console app packed with <PackAsTool>true</PackAsTool>. The package has a different type from a library package:
Contoso.ReleaseNotes.1.2.0.nupkg (package type: DotnetTool)
+-- Contoso.ReleaseNotes.nuspec
+-- tools/
+-- net10.0/
+-- any/
+-- DotnetToolSettings.xml command name -> entry point DLL
+-- Contoso.ReleaseNotes.dll
+-- (all its dependencies, bundled)
| Library package | Tool package |
|---|---|
Assemblies in lib/<tfm>/ |
Assemblies in tools/<tfm>/any/ |
Added with dotnet add package |
Added with dotnet tool install |
| Its dependencies become the consumer's dependencies | Its dependencies are bundled inside and isolated |
| Compiled into your app | Runs as a separate process |
That is why dotnet add package dotnet-ef doesn't give you the dotnet ef command, and why a tool's dependencies never conflict with your project's.
dotnet <command> is resolved dotnet ef migrations add Init
|
v
built-in SDK command (build, test, new ...)? --yes--> run it
| no
v
walk up from the current directory looking for .config/dotnet-tools.json
(and dotnet-tools.json in the folder itself); stop at a manifest with isRoot: true
|
+-- a manifest lists a tool with command "dotnet-ef"
| restored in ~/.nuget/packages?
| no --> error: "Run 'dotnet tool restore'"
| yes --> run the PINNED version
|
| no manifest entry
v
an executable called dotnet-ef on PATH (a global tool)? --yes--> run it
| no
v
"Could not execute because the specified command or file was not found"
Two consequences: a local tool wins over a global tool with the same command, and the same command can run different versions in different folders.
Global (-g) |
Tool path (--tool-path) |
Local (manifest) | |
|---|---|---|---|
| Installed to | ~/.dotnet/tools |
A folder you choose | NuGet global packages folder |
| On PATH | Yes (after adding ~/.dotnet/tools) |
Only if you add it | No; run through dotnet <command> |
| Scope | Your user, every folder | Whoever uses that folder | One repository |
| Version recorded in source control | No | Only if your script pins it | Yes, in .config/dotnet-tools.json |
| Restore on a new machine | Reinstall by hand | Your script | dotnet tool restore |
| Best for | Personal utilities you use everywhere | Build agents, container images | Anything the repo's build, tests or scripts need |
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.0",
"commands": ["dotnet-ef"],
"rollForward": false
},
"csharpier": {
"version": "1.0.2",
"commands": ["csharpier"],
"rollForward": false
}
}
}
| Field | Meaning |
|---|---|
isRoot |
Stop searching parent folders, so a stray manifest higher up the disk can't add tools |
version (per tool) |
The exact version dotnet tool restore installs |
commands |
The command names the tool provides; two tools can't claim the same command |
rollForward |
Allow the tool to run on a newer .NET runtime than it targets |
Put one manifest at the repo root. dotnet tool install, update and uninstall (without -g) edit it for you.
A tool targets a runtime, just like any app:
tool targets net8.0, machine has only the .NET 10 runtime
rollForward false --> fails: "You must install or update .NET to run this application"
rollForward true --> runs on .NET 10 (usually fine, test it)
Install with --allow-roll-forward (or set rollForward in the manifest) when you know the tool works on newer runtimes. With the .NET 10 SDK, tool authors can also ship RID-specific and Native AOT tool packages, which don't need a matching runtime at all.
dnx dotnet-counters --version 9.0.0 ps # shorthand
dotnet tool exec dotnet-counters --version 9.0.0 ps
| Use it for | Avoid it for |
|---|---|
| Trying a tool once | Build and test steps in CI without a pinned version |
| Running a scaffolder or support script | Anything the team runs every day (put it in the manifest) |
| Diagnosing a machine where you don't want installs |
It asks before downloading unless you pass --yes, and it downloads through the same NuGet sources and package source mapping as any other package.
1. setup-dotnet from global.json
2. restore the NuGet cache (key includes .config/dotnet-tools.json)
3. dotnet tool restore <-- first, and let it fail loudly
4. dotnet restore / build / test
5. dotnet ef migrations bundle <-- uses the pinned dotnet-ef
6. dotnet csharpier check . <-- uses the pinned formatter
Because local tools restore into the NuGet global packages folder, the same cache, nuget.config, authentication and source mapping apply as for package references.
| Property | Purpose |
|---|---|
PackAsTool |
Pack as a tool package |
ToolCommandName |
The command users type (defaults to the assembly name) |
PackageId, Version, Description |
Normal package metadata |
RollForward=Major |
Let the tool run on newer runtimes |
ToolPackageRuntimeIdentifiers (.NET 10) |
Ship RID-specific packages, for example Native AOT builds |
Pack with dotnet pack, push to a feed, and teams install it like any other tool.
A tool runs arbitrary code with your permissions and, in CI, with the pipeline's credentials.
dotnet-tools.json).dotnet nuget verify checks signatures, and trustedSigners can require them.One-shot execution and RID-specific tool packages are new in .NET 10. Watch the .NET 11 SDK notes for further changes to tool execution before relying on them in scripts.
dotnet new tool-manifest and open .config/dotnet-tools.json.dotnet tool install dotnet-counters and see the manifest change.dotnet tool install -g dotnet-counters --version <other>, then run dotnet counters --version inside and outside the folder.~/.nuget/packages, run dotnet counters --version, and read the error. Run dotnet tool restore and try again.dotnet tool list and dotnet tool list -g and compare.dnx dotnet-dump --help and note the confirmation prompt.dotnet tool install Contoso.ReleaseNotes --add-source ./artifacts, and run dotnet release-notes.# GitHub Actions: restore pinned tools before using them
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json', '.config/dotnet-tools.json') }}
- name: Restore local tools
run: dotnet tool restore
- name: Check formatting with a pinned formatter
run: dotnet csharpier check .
<!-- Making your own tool -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<ToolCommandName>release-notes</ToolCommandName>
<PackageId>Contoso.ReleaseNotes</PackageId>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Mistake: installing dotnet-ef globally on build agents.
The agent's version drifts from the EF Core packages in the project, so migrations and bundles differ between machines or fail.
Instead: pin dotnet-ef in the repo's tool manifest to the same major version as Microsoft.EntityFrameworkCore, and run dotnet tool restore in CI.
Mistake: forgetting dotnet tool restore in CI.
The job fails with "Run 'dotnet tool restore' to make the tool available" or "command not found", often several steps later.
Instead: make dotnet tool restore an early step and let it fail the job loudly, without || true around tool calls.
Mistake: a manifest without isRoot: true in a monorepo.
A manifest in a parent folder adds tools nobody expected, or supplies a different version.
Instead: set isRoot: true in the repo's manifest.
Mistake: installing tools from unknown publishers or mistyped ids. A tool runs arbitrary code with your credentials, including pipeline secrets. Instead: check the owner and id, pin versions, and restore through a trusted feed with package source mapping.
Mistake: using dnx or dotnet tool exec without a version in a pipeline.
The build depends on whatever version is latest that day.
Instead: pass an explicit --version, or put the tool in the manifest.
Mistake: treating --tool-path as reproducible on its own.
It installs into a folder you control, but the version is only pinned if your script pins it.
Instead: pass --version in the install script, or use a manifest.
Mistake: trying to add a tool with dotnet add package.
A tool package isn't a library, so the reference fails or does nothing useful.
Instead: use dotnet tool install.
Mistake: assuming a global tool is on PATH.
On Linux and macOS, ~/.dotnet/tools isn't always added to PATH, and some tools also need DOTNET_ROOT.
Instead: add ~/.dotnet/tools to PATH in your shell profile, or use local tools through dotnet <command>.
Mistake: ignoring runtime compatibility.
A tool built for net8.0 won't start on a machine with only the .NET 10 runtime.
Instead: install the needed runtime, or allow roll forward with --allow-roll-forward (or rollForward in the manifest) after checking the tool works.
Local tools turn developer tooling into a versioned dependency, which is what you want in a team. A senior engineer treats the tool manifest like the lock file: reviewed, updated through automation (Dependabot or Renovate pull requests), and restored from a controlled feed.
For security and reliability they go further:
dotnet nuget verify checks a package's signature, and trustedSigners in nuget.config can require signatures from specific publishers.In regulated Australian environments (for example, aligning with the ACSC Essential Eight on application control), that evidence matters. The trade-off is a little friction: people must run dotnet tool restore after cloning, so put it in the README, in a bootstrap script and as the first pipeline step.
I generate migrations locally with dotnet ef migrations add, then make deployment a separate, reviewed step. The pipeline produces an artefact: either an idempotent SQL script (dotnet ef migrations script --idempotent) for a DBA to review, or a migration bundle (dotnet ef migrations bundle), a self-contained executable that applies pending migrations. That artefact runs once, before the new app version starts, with a dedicated deployment identity. I avoid Database.Migrate() at startup because several instances can race, the app needs DDL permissions it shouldn't have, and a failed migration turns into a crash loop.
The pipeline restores three times, builds in Debug, runs dotnet format in a way that silently fixes files instead of failing, and publishes from a different build than the one it tested. I'd use global.json for the SDK, restore once with --locked-mode, build once in Release with --no-restore, then test and publish with --no-build. I'd run dotnet format --verify-no-changes so formatting drift fails the build, collect test results and coverage, and cache the NuGet folder.
First I read the exact error. NU1301 means a source couldn't be reached, 401 or 403 means authentication, and NU1101 means the package isn't on any configured source. It works on my machine because I have cached packages and stored credentials, while CI starts clean. Then I check which nuget.config CI actually uses, since config files are merged from the repo upwards. I make the repo's config explicit with <clear />, and I authenticate CI the supported way, for example the Azure Artifacts credential provider or a token from the pipeline's secret store, never a committed password. I also add package source mapping and lock files so restores are reproducible and protected against dependency confusion.
Last updated 1 Nov 2026.