The CLI pipeline end to end: restore producing project.assets.json, build producing bin and obj outputs, test running against those outputs, publish producing a deployable folder, and how --no-restore and --no-build keep CI fast and consistent.
Restore works out the full NuGet package graph, downloads packages to the global packages folder and writes obj/project.assets.json. Build compiles against those assets into bin/<Configuration>/<TFM>. Test runs the test projects against the built assemblies. Publish produces a deployable folder with the app, its dependencies and runtime config, in Release by default since .NET 8. Each later command restores and builds implicitly, so in CI I restore once and pass --no-restore and --no-build. That way every step uses exactly the same outputs and nothing is repeated. The configuration has to match across steps.
"Restore doesn't just download files: it resolves the full NuGet package graph, downloads anything missing to the global packages folder, and writes
obj/project.assets.json, the resolved graph that build relies on. Build then compiles against those assets intobin/<Configuration>/<TFM>.Test builds unless told not to, then runs the test projects and produces results; coverage isn't collected by default, so I add a collector like
coverlet.collector. Publish produces a deployable folder with the app's own assemblies, its dependencies and the runtime config. It's framework-dependent by default, with self-contained or a container image as other shapes, and since .NET 8 it uses Release by default.Build, test, publish and pack all restore implicitly, and test and publish also build, which is handy locally but wasteful in CI. So in a pipeline I restore once, build with
--no-restore, then test and publish with--no-build. It's faster, and more importantly the binaries I tested are the binaries I ship. The trap is configuration: if the build used Release and the test step doesn't pass-c Release, it looks for Debug outputs and fails, or quietly runs a stale Debug build. That's the first thing I check when I review a pipeline, because building once and promoting the same artefact removes a whole class of surprises."
dotnet restore ──► obj/project.assets.json + ~/.nuget/packages/...
│
dotnet build -c Release --no-restore ──► bin/Release/net10.0/*.dll, *.pdb, *.deps.json
│
dotnet test -c Release --no-build ──► test results (TRX), coverage if a collector is configured
│
dotnet publish src/Api -c Release --no-build -o out ──► out/ (deployable)
PackageReference (and Central Package Management versions), resolves the dependency graph with NuGet's rules (lowest applicable version, nearest wins), downloads missing packages to the global packages folder, and writes project.assets.json plus generated .props/.targets in obj/. With lock files it also checks or writes packages.lock.json.bin/.dotnet build (unless --no-build) and then executes tests through VSTest or Microsoft.Testing.Platform, producing results through loggers such as --logger trx. Code coverage is not collected by default: you add a collector, for example --collect "XPlat Code Coverage" with the coverlet.collector package, or Microsoft's code coverage collector.bin/<Configuration>/<TFM>/publish/ (or -o): your application's own assemblies, the NuGet dependency assemblies, *.deps.json, *.runtimeconfig.json, and content files that the project marks for publishing (for example appsettings*.json in web projects, or items with CopyToPublishDirectory).By default publish is framework-dependent: the output is small and needs the matching .NET runtime on the server or in the base image. Self-contained (--self-contained -r linux-x64) includes the runtime for one runtime identifier, so nothing needs installing, at the cost of size and owning runtime patching. Single-file, trimming, ReadyToRun and Native AOT build on top of that; the publish options question covers them in depth.
build, run, test, publish and pack all restore first, and test, run, publish and pack also build. That is convenient locally and wasteful in CI. --no-restore skips restore; --no-build skips build (and implies --no-restore).
The catch: every step must use the same -c (and -f/-r if you use them). dotnet test --no-build without -c Release after a Release build looks for Debug outputs and fails, or worse, runs a stale Debug build.
The defaults introduced in .NET 8 still apply in the .NET 10 SDK: dotnet publish and dotnet pack use Release by default, and -r alone no longer implies self-contained.
A pipeline ran dotnet build, then dotnet test, then dotnet publish with defaults. That restored three times and built twice, and the published binaries were not the tested ones. Switching to a single restore and --no-build cut the pipeline from 14 to 6 minutes and made "tested equals shipped" true.
The interviewer is checking whether you understand what each CLI step does and can build an efficient, trustworthy pipeline.
Green flags
project.assets.json and the global packages folder.Red flags
dotnet build before every command "to be safe". dotnet restore Orders.slnx
reads: PackageReference, Directory.Packages.props, nuget.config, lock files
writes: ~/.nuget/packages/<id>/<version>/ (shared by every project)
obj/project.assets.json (the resolved graph)
obj/<Project>.csproj.nuget.g.props/.targets (build logic from packages)
obj/project.nuget.cache ("is restore up to date?")
packages.lock.json (if lock files are on)
|
v
dotnet build Orders.slnx -c Release --no-restore
reads: project.assets.json, source files
writes: obj/Release/net10.0/ (intermediate: compiled DLL, file lists)
bin/Release/net10.0/ (DLLs, PDBs, .deps.json, .runtimeconfig.json)
|
v
dotnet test Orders.slnx -c Release --no-build
reads: test DLLs in bin/Release/net10.0/
writes: TestResults/*.trx, coverage files (only with a collector)
exit code non-zero on failure
|
v
dotnet publish src/Orders.Api -c Release --no-build -o out
reads: bin/Release/net10.0/ outputs
writes: out/ (deployable: app + dependencies + config + content)
your intent version rules
PackageReference Include="Serilog" + Directory.Packages.props
packages.lock.json
\ /
v v
NuGet graph resolution
- direct dependency wins ("nearest wins")
- otherwise lowest version that satisfies every range
- sources from nuget.config (+ package source mapping)
|
v
download missing packages --> ~/.nuget/packages
write obj/project.assets.json and the generated .props/.targets
| Resolution rule | Example |
|---|---|
| Lowest applicable | Version="4.0.0" means 4.0.0 or higher; NuGet picks 4.0.0 if available |
| Nearest wins | Your direct reference to 13.0.3 beats a transitive request for 12.0.1 |
| Cousin dependencies | Two packages need >= 6.0 and >= 8.0; NuGet picks the lowest that satisfies both: 8.0 |
| Floating versions | 4.* resolves to the newest 4.x at restore time, so builds aren't reproducible without a lock file |
Packages can ship MSBuild .props/.targets, analysers and source generators. That is why restore can change how a project builds, and why build must run after it.
Restore is also where supply-chain checks happen: NuGet Audit reports packages with known vulnerabilities (NU1901 to NU1904 warnings), and the .NET 10 SDK prunes package references that the framework already provides, which removes many false-positive audit warnings.
Build evaluates each project, resolves references from project.assets.json, runs the compiler and copies outputs and dependencies to bin/. It is incremental: unchanged projects are skipped. Building the solution builds each project once, in dependency order, which is why CI builds the solution rather than each project separately.
| Detail | Explanation |
|---|---|
| Builds first | Unless --no-build |
| Runner | VSTest by default; Microsoft.Testing.Platform when chosen with test.runner in global.json (.NET 10) or per project |
| Results | --logger trx (VSTest) or the platform's report options, written to --results-directory |
| Coverage | Not collected by default. Add a collector, for example --collect "XPlat Code Coverage" with coverlet.collector, or Microsoft's code coverage |
| Exit code | Non-zero when any test fails, so the pipeline step fails |
Publish builds (unless --no-build), then copies everything needed to run into bin/<Configuration>/<TFM>/publish/ or the -o folder:
| In the publish folder | Why |
|---|---|
| Your assemblies | The app |
| NuGet dependency assemblies | Resolved from the global packages folder |
*.deps.json |
Tells the host where each dependency is |
*.runtimeconfig.json |
Which runtime version to load |
appsettings*.json and other content marked to publish |
Configuration and static files |
| The runtime itself | Only when self-contained |
| Shape | Command | Needs on the target |
|---|---|---|
| Framework-dependent (default) | dotnet publish -c Release |
Matching .NET runtime |
| Self-contained | dotnet publish -c Release -r linux-x64 --self-contained |
Nothing from .NET |
| Container image | dotnet publish -c Release /t:PublishContainer |
A container runtime |
Since .NET 8, dotnet publish and dotnet pack default to Release, and -r alone no longer implies self-contained. Those defaults still apply in the .NET 10 SDK.
| Command | Restores? | Builds? | Skip with |
|---|---|---|---|
build |
Yes | (It is the build) | --no-restore |
run |
Yes | Yes | --no-restore, --no-build |
test |
Yes | Yes | --no-restore, --no-build |
publish |
Yes | Yes | --no-restore, --no-build |
pack |
Yes | Yes | --no-restore, --no-build |
--no-build implies --no-restore.
--no-restore and --no-build Without the flags: With the flags:
restore restore (once)
build -> restore + build build --no-restore (once)
test -> restore + build + test test --no-build
publish -> restore + build + publish publish --no-build
4 restores, 3 builds 1 restore, 1 build
published binaries != tested binaries tested binaries == shipped binaries
restore errors appear in the test step restore errors appear in the restore step
--no-build looks for outputs in bin/<Configuration>/<TFM>/. If the build used -c Release and the test step doesn't pass -c Release, the test step looks in bin/Debug/:
bin/Debug/ exists? |
Result |
|---|---|
| No | Fails: test assemblies not found |
| Yes, from an earlier build | Runs stale Debug binaries, and passes or fails for the wrong reason |
The same applies to -f and -r. Pass the same values to every step, or set them once as pipeline variables.
Publishing a whole solution with -o puts every project's output in one folder, which can overwrite files with different versions, and the SDK warns about it. Publish each deployable project separately with its own output folder, using --no-build after one solution build.
~/.nuget/packages in CI, keyed on Directory.Packages.props and lock files.dotnet restore --locked-mode so a restore never changes versions silently.-bl to find slow targets, and keep the binary log as a pipeline artefact when a build fails.Restore downloads and runs build logic from packages, so package sources must be trusted: nuget.config with <clear />, package source mapping so each package can only come from its expected feed, and NuGet Audit warnings treated seriously (or as errors on main).
The flow is stable. Watch the .NET 11 SDK notes for changes to test runner defaults and package pruning before relying on them.
dotnet restore, then open obj/project.assets.json and find a package and its dependencies.dotnet build -c Release --no-restore.dotnet test -c Release --no-build, then dotnet test --no-build without -c and see the failure.dotnet build (Debug), then dotnet test --no-build again: it now silently tests the Debug build.dotnet publish src/Api -c Release --no-build -o out and list out/.obj/project.assets.json and run dotnet build --no-restore to see why restore must come first.<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>, restore, then change a version and run dotnet restore --locked-mode.The CI sequence, as a bash script:
#!/usr/bin/env bash
set -euo pipefail
dotnet restore Orders.slnx
dotnet build Orders.slnx -c Release --no-restore
dotnet test Orders.slnx -c Release --no-build --logger trx --results-directory ./TestResults
dotnet publish src/Orders.Api/Orders.Api.csproj -c Release --no-build -o ./out
The same in GitHub Actions, with the NuGet cache keyed on central package versions:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/packages.lock.json') }}
- run: dotnet restore Orders.slnx
- run: dotnet build Orders.slnx -c Release --no-restore
- run: dotnet test Orders.slnx -c Release --no-build
- run: dotnet publish src/Orders.Api/Orders.Api.csproj -c Release --no-build -o out
Mistake: letting every step restore and build again.
The pipeline restores several times and builds several times, which is slow, and the published binaries aren't the ones that were tested.
Instead: restore once, build once with --no-restore, then run test and publish with --no-build.
Mistake: mismatched configuration between build and test --no-build.
The test step looks in bin/Debug/ after a Release build, so it fails or, worse, runs a stale Debug build.
Instead: pass the same -c (and -f, -r) to every step, ideally from one pipeline variable.
Mistake: publishing with --no-build after building only some projects.
The project you publish, or one of its references, has no outputs, so publish fails or copies old files.
Instead: build the whole solution first, then publish each deployable project with --no-build.
Mistake: publishing a whole solution into one -o folder.
Projects overwrite each other's files, sometimes with different dependency versions.
Instead: publish each deployable project to its own output folder.
Mistake: assuming publish is Debug by default.
Since .NET 8, dotnet publish and dotnet pack default to Release, so scripts that relied on Debug output paths break.
Instead: pass -c Release explicitly anyway, so the intent is clear in the script.
Mistake: expecting dotnet test to collect coverage on its own.
Without a collector, no coverage file is produced and the coverage step in the pipeline has nothing to report.
Instead: add coverlet.collector and --collect "XPlat Code Coverage", or Microsoft's code coverage.
Mistake: floating versions without a lock file.
4.* resolves to whatever is newest at restore time, so two builds of the same commit can use different packages.
Instead: pin versions in Directory.Packages.props, or use lock files with dotnet restore --locked-mode in CI.
Mistake: not caching the NuGet packages folder in CI.
Every run downloads every package again, adding minutes and depending on the feed being available.
Instead: cache ~/.nuget/packages, keyed on Directory.Packages.props and lock files.
Mistake: restoring from untrusted or unmapped package sources.
Restore runs build logic from packages, so a malicious package with the same name on another feed can run code on your agent.
Instead: use nuget.config with <clear /> and package source mapping, and act on NuGet Audit warnings.
A senior engineer designs the pipeline so that the artefact is built once and promoted: restore, build and test once, publish once, then deploy the same artefact to test, staging and production. That is the CLI-level version of "build once, deploy many", and it removes a whole class of environment-specific surprises. They keep the script runnable locally, cache what is safe to cache, and make each step fail loudly with a binary log attached when something breaks.
dotnet test builds the test projects and runs them through the test platform, using the adapter the project references (xUnit, NUnit or MSTest). I use --filter to pick tests, for example --filter "Category=Unit&FullyQualifiedName~Orders". I use --logger trx so CI can show the results, and --collect "XPlat Code Coverage" with Coverlet for coverage, which I turn into a report. When tests hang or crash, --blame-hang-timeout and --blame-crash capture dumps. The command exits non-zero on failure, so the pipeline fails. In .NET 10 the SDK can also run in Microsoft.Testing.Platform mode, which you opt into in global.json.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
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.
Last updated 1 Nov 2026.