A CI step that says dotnet tool exec Some.Tool looks isolated, but it is not fully reproducible. Without a version, the command can resolve the latest package from the configured feeds. Machine-level NuGet settings can also change which feeds participate. I use .NET 10 dotnet tool exec with an exact @version and an explicit feed policy when I want one-shot tooling without a global install or a committed tool manifest.
The command is stable from the .NET 10.0.100 SDK onward. Microsoft describes it as a temporary invocation: the package is downloaded to the NuGet cache, executed, and left out of PATH. That is convenient for CI, but temporary installation does not automatically mean deterministic selection.
Why .NET 10 dotnet tool exec can drift
The official command reference documents three useful selection modes:
-
Some.Toolcan resolve the latest version when no local manifest supplies one. -
Some.Tool@2.*stays on a major version, but still floats within that range. -
Some.Tool@2.4.1requests one exact package version.
For CI, I prefer the third form. A new tool release should arrive through a reviewed change, not because the next clean runner happened to restore later.
The feed is a separate input. --add-source adds another source, and NuGet can query feeds in parallel. If the same package and version exists on more than one feed, the fastest response can win. That may be acceptable for interactive experimentation. It is a poor default for a build gate.
.NET 10 is currently an active LTS channel. I still pin the SDK used by CI as well, because a package pin controls the tool package, not the CLI that resolves and launches it.
Pin the version and feed together
For a repository policy, I give dotnet tool exec a checked-in NuGet.Config. This sample uses a generated local feed, so it needs no credentials or external package call:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="globalPackagesFolder"
value="./artifacts/global-packages" />
</config>
<packageSources>
<clear />
<add key="sample-feed" value="./artifacts/packages" />
</packageSources>
<packageSourceMapping>
<packageSource key="sample-feed">
<package pattern="Sukhpinder.DevTo.ToolExec.Demo" />
</packageSource>
</packageSourceMapping>
</configuration>
The --configfile option makes this invocation use only the selected file. I keep <clear /> too so the same config stays isolated when ordinary NuGet discovery merges the configuration hierarchy. NuGet's configuration reference documents both behaviors and recommends a repository config for repeatability. Package source mapping then limits the demo package to the named feed.
The invocation is deliberately boring:
dotnet tool exec `
--configfile ./NuGet.Config `
Sukhpinder.DevTo.ToolExec.Demo@1.0.0 `
-- --label ci
Everything after -- belongs to the tool. The package ID, exact version, configuration file, and tool arguments are all visible in the command. In a private-feed setup, I would keep credentials out of this file and use the feed's credential provider or protected environment settings.
Let the tool exit code gate CI
dotnet tool exec returns the invoked tool's exit code. That means a formatter, schema checker, or policy tool can fail a job without inventing another result protocol. A PowerShell wrapper should still preserve the native exit code explicitly if it performs more work afterward:
dotnet tool exec `
--configfile ./NuGet.Config `
Sukhpinder.DevTo.ToolExec.Demo@1.0.0 `
-- --label gate --exit-code 23
exit $LASTEXITCODE
The runnable sample in draft PR 19 builds versions 1.0.0 and 2.0.0 of the same dependency-free tool. Its verifier proves three behaviors offline: the exact pin runs 1.0.0, an unversioned call selects the newer 2.0.0 from the same feed, and a tool exit code of 23 reaches the caller. It also clears its generated feed and package cache before every run so an old cached package cannot create a false pass.
When I would not use it
One-shot execution is not a replacement for every local tool manifest. If a team wants the complete tool set reviewed in one committed file, dotnet tool restore plus dotnet tool run can communicate that policy better. A manifest also makes shared developer setup straightforward.
An exact version and a constrained feed do not prove that a package is safe. Production pipelines still need trusted sources, appropriate signing and audit controls, protected credentials, and a deliberate update process. The first run also needs access to the package unless the feed or cache is already available; the sample is offline only because it creates its own local packages.
That distinction matters on disposable runners, where the cache starts empty and every implicit selection becomes part of the build.
For short-lived CI checks, though, the combination is useful: no global state, no permanent installation, one visible version, one controlled feed, and the original tool exit code.
How are you pinning one-shot .NET tools in your CI jobs?
Happy coding!
Top comments (0)