📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Walk through the dotnet restore, build, test and publish flow. What does each step produce, and why do CI pipelines use --no-restore and --no-build?

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.

Junior Theory Intermediate ci-cd dotnet-cli dotnet-publish dotnet-restore msbuild

🎯 Key points

  • Restore resolves the package graph, downloads to the global packages folder and writes obj/project.assets.json
  • Build compiles to bin/<Configuration>/<TFM> using the restored assets
  • Test builds (unless --no-build) and runs tests; coverage needs a collector such as coverlet.collector
  • Publish produces a deployable folder with the app's own assemblies, dependencies and runtime config; framework-dependent by default, Release by default since .NET 8
  • build, test, publish and pack restore implicitly unless --no-restore is passed
  • In CI: restore once, then --no-restore and --no-build so every step uses the same outputs and runs faster
  • Configuration must match across steps: --no-build with a different -c fails or tests the wrong build

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 into bin/<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."


The flow

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)

What each step does

  1. Restore reads every 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.
  2. Build evaluates the project, resolves references from the assets file, runs the compiler and copies outputs and dependencies to bin/.
  3. Test runs 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.
  4. Publish builds, then copies everything needed to run into 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).

Framework-dependent or self-contained

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.

Implicit restore and build

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).

Why CI uses them

  • Speed: restore and build happen once.
  • Consistency: the binaries you tested are the binaries you publish.
  • Clear failures: a restore failure is reported in the restore step, not buried in a test step.

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.

Production example

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.

🔍 What the interviewer is checking

The interviewer is checking whether you understand what each CLI step does and can build an efficient, trustworthy pipeline.

Green flags

  • Explains project.assets.json and the global packages folder.
  • Knows implicit restore/build and why CI disables them.
  • Mentions matching configuration and "tested equals shipped".

Red flags

  • Thinks restore only downloads files.
  • Runs dotnet build before every command "to be safe".
  • Unaware of Release defaults for publish.

📚 Learn in depth

1. The whole flow and its artefacts

 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)

2. Inside restore

 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.

3. Build, briefly

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.

4. Test

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

5. Publish

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.

6. Implicit restore and build

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.

7. Why CI uses --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

8. The configuration trap

--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.

9. Publishing from a solution

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.

10. Performance

  • Cache ~/.nuget/packages in CI, keyed on Directory.Packages.props and lock files.
  • Use lock files with dotnet restore --locked-mode so a restore never changes versions silently.
  • Use -bl to find slow targets, and keep the binary log as a pipeline artefact when a build fails.

11. Security

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).

12. .NET 11 awareness

The flow is stable. Watch the .NET 11 SDK notes for changes to test runner defaults and package pruning before relying on them.

13. Try it yourself

  1. In a solution with a test project, run dotnet restore, then open obj/project.assets.json and find a package and its dependencies.
  2. Run dotnet build -c Release --no-restore.
  3. Run dotnet test -c Release --no-build, then dotnet test --no-build without -c and see the failure.
  4. Run dotnet build (Debug), then dotnet test --no-build again: it now silently tests the Debug build.
  5. Run dotnet publish src/Api -c Release --no-build -o out and list out/.
  6. Delete obj/project.assets.json and run dotnet build --no-restore to see why restore must come first.
  7. Turn on lock files with <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>, restore, then change a version and run dotnet restore --locked-mode.

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says the pipeline runs dotnet test after the build. Why: Digs into the test stage: filtering, coverage and diagnosing hangs.
    How to answer

    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.

  2. When: Candidate reaches the publish step of the flow. Why: Checks whether the candidate can choose a deployment shape for the publish stage.
    How to answer

    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.

  3. When: Candidate mentions --no-restore or --no-build. Why: Applies the flow to a real pipeline and looks for practical flags like --no-build.
    How to answer

    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.

↩ Usually asked after

More in .NET CLI

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me