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

📚 Interview question

Review this CI pipeline that builds, tests and publishes a .NET 10 service with the dotnet CLI and dotnet format. What would you change and why?

A code review of a CI pipeline: redundant restores, missing --no-restore/--no-build, Release configuration, locked restore, dotnet format --verify-no-changes, test results and coverage, and publish options.

Mid-Level CodeReview Intermediate ci-cd code-review dotnet-cli dotnet-format github-actions

🎯 Key points

  • dotnet format --verify-no-changes is non-negotiable in CI: without it, dotnet format silently rewrites files on the agent and passes
  • dotnet format has whitespace, style and analyzers subcommands driven by .editorconfig
  • Restore once, then use --no-restore on build and --no-build on test and publish
  • Build and test in Release so the shipped binaries are the tested binaries
  • global.json pins the SDK so every developer and agent builds with the same compiler and defaults
  • Lock files plus --locked-mode make dependency resolution identical everywhere and fail on unexpected changes
  • Publish test results and coverage, uploading them even when tests fail; cache NuGet packages
  • Least-privilege workflow permissions and PR triggers

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"When I review a pipeline I check its guarantees: the binary we deploy is the one we tested, built by a known SDK from known packages, and the code meets our agreed standards. This one restores three times, builds and tests Debug, runs dotnet format in a way that fixes files and passes, and publishes from a separate build. So I'd pin the SDK with global.json, restore once with lock files and --locked-mode, build once in Release with --no-restore, then test and publish with --no-build.

For example, at [company] our [orders] service pipeline took [14] minutes, and we shipped a bug that only appeared in Release because tests ran against a Debug build. I offered to fix the pipeline so we tested what we shipped. I found it restored [three] times, ran dotnet format without verification and published a separate build. I rewrote it to restore once, build Release once, and test and publish with --no-build, and made format verification a required check after a one-off cleanup commit. Pipeline time dropped to [N] minutes, and we had no more Release-only surprises.

The mistake I see most is dotnet format without --verify-no-changes: it silently rewrites files on a throwaway agent and exits successfully. Its whitespace, style and analyzers subcommands are all driven by .editorconfig. I'd also write TRX results and coverage and upload them even when tests fail, cache the NuGet folder keyed on lock files, trigger on pull requests, and set permissions: contents: read. If the service owns a database, the migration bundle becomes another artefact from the same build. Each flag buys one guarantee, and saves time as a side effect."


The pipeline under review

name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.x'
      - run: dotnet restore
      - run: dotnet format
      - run: dotnet build
      - run: dotnet test
      - run: dotnet publish src/Orders.Api -o out
      - uses: actions/upload-artifact@v4
        with:
          name: api
          path: out

Findings

# Issue Why it matters Fix
1 dotnet-version: '10.x' Any 10.x SDK; builds change when a new feature band ships global-json-file: global.json
2 dotnet format with no verify flag It rewrites files on the agent and passes; nobody sees the drift dotnet format --verify-no-changes
3 build, test, publish each restore again Wasted minutes, and a chance of resolving different packages --no-restore on build; --no-build on test and publish
4 No -c Release on build/test Tests run against Debug code; publish then builds Release separately, so you ship a binary you never tested -c Release everywhere
5 Plain dotnet restore Floating or transitive versions can change between runs Lock files plus --locked-mode
6 No test results or coverage Failures are hard to read; no trend --logger trx, --collect "XPlat Code Coverage", upload results
7 No NuGet cache Every run downloads every package Cache ~/.nuget/packages keyed on lock files
8 on: [push] only No PR validation Add pull_request
9 Publish without explicit options Framework-dependent, portable, unknown RID Decide: -r linux-x64, --self-contained or not, or container publish

About dotnet format

dotnet format ships in the .NET SDK and has three subcommands:

  • dotnet format whitespace: spacing, indentation, line endings, driven by .editorconfig.
  • dotnet format style: code-style rules (IDExxxx), such as var preferences and file-scoped namespaces.
  • dotnet format analyzers: fixes from analysers (CAxxxx and third-party), filtered with --severity.

Plain dotnet format runs all three. In CI you want --verify-no-changes, which exits non-zero when anything would change. Use --severity warn (or error) to control which diagnostics count.

Best practices, performance and security

  • Build once, test that build, publish that build: the artefact you deploy is the artefact you tested.
  • --locked-mode plus package source mapping in nuget.config limits dependency-confusion risk.
  • Pin third-party actions to a commit SHA in security-sensitive repos, and give the workflow permissions: contents: read by default.
  • Caching NuGet and running format before build gives fast feedback.

🔍 What the interviewer is checking

Tests practical CLI fluency and whether the candidate thinks about reproducibility.

Green flags: spots the triple restore and Debug/Release mismatch; knows --verify-no-changes; mentions global.json, lock files, caching, test result upload.

Red flags: only comments on YAML formatting; thinks dotnet format alone fails on drift; doesn't see that publish rebuilds.

📚 Learn in depth

1. The guarantees a pipeline should give

Review a pipeline against what it promises, not how the YAML looks:

Guarantee What provides it
Same compiler everywhere global.json plus setup-dotnet with global-json-file
Same packages everywhere packages.lock.json plus dotnet restore --locked-mode, package source mapping
Agreed code standards .editorconfig plus dotnet format --verify-no-changes, analysers with TreatWarningsAsErrors where agreed
The deployed binary is the tested binary One Release build; --no-build on test and publish
Failures are readable TRX results and coverage uploaded even on failure
Only reviewed code reaches main pull_request trigger and a required status check
The pipeline can't be abused Least-privilege permissions, pinned actions, secrets only where needed

2. What each step consumes and produces

 checkout
    |
 setup-dotnet (global.json)              -> exact SDK, e.g. 10.0.1xx
    |
 dotnet tool restore                     -> pinned local tools
    |
 dotnet restore --locked-mode            -> obj/project.assets.json
    |                                       fails with NU1004 if a lock file
    |                                       is out of date
 dotnet format --verify-no-changes       -> non-zero exit on any drift
    |  --no-restore                          (reads the restore output)
 dotnet build -c Release --no-restore    -> bin/Release/net10.0/*
    |
 dotnet test -c Release --no-build       -> TestResults/*.trx, coverage
    |
 dotnet publish -c Release --no-build    -> out/  (the same binaries)
    |
 upload artefact (+ test results with if: always())

Each --no-restore and --no-build removes an implicit step. Without them, build restores again, test builds and restores again, and publish builds a third time, which is slower and means the published files are not the ones the tests ran against.

3. The review, line by line

Line in the original Problem Change
on: [push] No validation of pull requests, and every push to every branch runs the full job push: branches: [main] plus pull_request, and add the job as a required check
(no permissions) The GITHUB_TOKEN gets the repo's default, which may be write permissions: contents: read at the top, widened per job only when needed
dotnet-version: '10.x' Any 10.x SDK; a new feature band changes the compiler and analysers global-json-file: global.json
dotnet restore Floating and transitive versions can change between runs Lock files plus --locked-mode
dotnet format Rewrites files on a throwaway agent and passes dotnet format --verify-no-changes
dotnet build Debug, and restores again -c Release --no-restore
dotnet test Builds Debug again; no results kept -c Release --no-build, TRX logger, coverage, upload with if: always()
dotnet publish src/Orders.Api -o out Builds again (Release by default since .NET 8), so the binary was never tested -c Release --no-build, and decide the RID and deployment model
(no cache) Every run downloads all packages Cache the NuGet folder keyed on lock files

4. dotnet format in detail

dotnet format ships in the .NET SDK. It reads .editorconfig and the project's analyser settings, so it needs a restored project.

Subcommand Fixes Rule ids Notes
whitespace Indentation, spacing, new lines, final newline (formatting options) Doesn't need a build; fastest
style Code style such as var preference, file-scoped namespaces, this. qualification IDExxxx Needs restore; only rules with a severity of at least --severity
analyzers Fixes offered by analysers CAxxxx and third-party ids Needs restore; slowest
(none) All three What most CI pipelines run

Useful options:

Option Purpose
--verify-no-changes Make no edits; exit non-zero if anything would change
--severity info\|warn\|error Minimum diagnostic severity to fix or report (default warn)
--diagnostics IDE0005 IDE0161 Only these rule ids
--include / --exclude Limit to files or folders, for example to check only changed files
--no-restore Reuse the restore that already ran
--report ./format-report.json Write a machine-readable list of issues
-v diag Show which files and rules it processed

Why exit codes matter

A CI step fails only when its command returns a non-zero exit code. Plain dotnet format returns success after it has changed files, so the pipeline goes green and the fixes disappear with the agent. With --verify-no-changes it edits nothing and exits non-zero when any file would change, so the step fails and the log names each file and rule.

Rolling it out to an existing repo

  1. Agree the .editorconfig rules and their severities.
  2. Run dotnet format once and commit the result as a single "format only" commit.
  3. Add that commit's hash to .git-blame-ignore-revs so git blame skips it.
  4. Turn on --verify-no-changes in CI and make it a required check.
  5. Offer developers the same command as a pre-commit hook or IDE "format on save".

5. --no-build on publish: when it works

dotnet publish --no-build copies the existing build output, so the build must match:

Build Publish Result
-c Release -c Release --no-build Works
(Debug) -c Release --no-build Fails: no Release output to copy
-c Release (portable) -c Release -r linux-x64 --no-build Fails or uses stale output: no RID-specific build exists
-c Release -r linux-x64 -c Release -r linux-x64 --no-build Works, and tests can run against the same RID build

If you publish for a RID, either build and test with that RID too, or accept that publish does its own build and that tests ran against the portable build. Make that trade-off on purpose.

6. Locked restore

<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>
  • Each project then gets a packages.lock.json recording the exact resolved version and content hash of every direct and transitive package.
  • Developers commit the lock files. When they change a package reference, a normal restore updates the file.
  • CI runs dotnet restore --locked-mode, which fails (NU1004) instead of silently resolving something different.
  • Key the NuGet cache on hashFiles('**/packages.lock.json'), so the cache changes exactly when dependencies change.
  • Since .NET 10, NuGet audit checks transitive packages by default, so restore warns about known vulnerable packages anywhere in the graph. Decide whether those warnings fail the build.

7. Tests and coverage

dotnet test -c Release --no-build \
  --logger trx --results-directory TestResults \
  --collect "XPlat Code Coverage"
  • Upload TestResults with if: always() (GitHub Actions) or condition: succeededOrFailed() (Azure Pipelines), because you need the results most when tests fail.
  • Publish the TRX to the PR (for example with a test reporter action, or PublishTestResults@2 in Azure Pipelines) so developers see failing test names without reading logs.
  • Add --blame-hang-timeout 10m so a hung test produces a dump instead of a silent timeout.
  • If the repo uses Microsoft.Testing.Platform ("test": { "runner": "Microsoft.Testing.Platform" } in global.json on .NET 10), the options differ, for example --report-trx and --coverage. Check which runner the repo uses before copying flags.

8. Performance

Change Typical saving
Restore once, --no-restore / --no-build Removes two restores and two builds
NuGet cache keyed on lock files Restore drops from minutes to seconds on a warm cache
dotnet format whitespace in a fast first job Feedback in under a minute for formatting-only problems
Split format and analysers into a parallel job Developers see style failures while tests are still running
concurrency group with cancel-in-progress: true for PRs Stops old runs when a new commit is pushed
Path filters (paths-ignore: ['docs/**', '**/*.md']) No full build for documentation-only changes

9. Security

  • Set permissions: contents: read at the workflow level. Give packages: write or id-token: write only to the job that needs it.
  • Pin third-party actions to a full commit SHA in sensitive repos, with a comment showing the version, and let Dependabot update them.
  • Don't expose secrets to pull_request runs from forks. Never use pull_request_target to build untrusted code.
  • Use package source mapping in nuget.config so each package can only come from its intended feed.
  • Generate an SBOM and sign or attest the artefact if your organisation requires supply-chain evidence.

10. .NET 11 awareness

The .NET 11 SDK is due in November 2026 as a standard-term (STS) release and is at the preview and release-candidate stage now. It keeps the same restore, build, test, publish and format commands. Expect more projects to move to Microsoft.Testing.Platform, which changes dotnet test options. Keep production pipelines on the .NET 10 SDK through global.json, and trial .NET 11 in a separate branch or job.

11. Try it yourself

  1. Create a solution with a class library and a test project. Add an .editorconfig with csharp_style_namespace_declarations = file_scoped:warning.
  2. Write a class with a block-scoped namespace.
  3. Run dotnet format style --verify-no-changes, then echo $?. Note the non-zero exit code.
  4. Run dotnet format style and look at the diff.
  5. Enable RestorePackagesWithLockFile, restore, and commit the lock file. Change a package version in the project file without restoring, then run dotnet restore --locked-mode and read the NU1004 error.
  6. Time dotnet build && dotnet test && dotnet publish against dotnet build -c Release && dotnet test -c Release --no-build && dotnet publish -c Release --no-build.
  7. Run dotnet publish -c Release -r linux-x64 --no-build after a portable build, and see why the RID must match.

💻 Code

name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      DOTNET_NOLOGO: true
      NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json

      - uses: actions/cache@v4
        with:
          path: ${{ github.workspace }}/.nuget/packages
          key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}

      - run: dotnet tool restore
      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes --no-restore --severity warn
      - run: dotnet build -c Release --no-restore
      - run: >
          dotnet test -c Release --no-build
          --logger trx --results-directory TestResults
          --collect "XPlat Code Coverage"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: TestResults
      - run: dotnet publish src/Orders.Api -c Release --no-build -o out
      - uses: actions/upload-artifact@v4
        with:
          name: api
          path: out
<!-- Directory.Build.props: enable lock files repo-wide -->
<Project>
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>

⚠ Common mistakes

  • Mistake: running dotnet format in CI without --verify-no-changes. It fixes the files on a throwaway agent and exits successfully, so the pipeline goes green and nobody ever sees the drift. Instead: run dotnet format --verify-no-changes --no-restore so any drift fails the step, and make that step a required check.

  • Mistake: testing a Debug build and shipping a Release build. dotnet build and dotnet test default to Debug, while publish defaults to Release since .NET 8. The deployed binary was never tested, and code inside #if DEBUG behaves differently. Instead: build once with -c Release, then run dotnet test -c Release --no-build and dotnet publish -c Release --no-build.

  • Mistake: letting every command restore and build implicitly. Each step repeats the work, which is slow and can resolve different package versions in each step. Instead: restore once, then use --no-restore on build and --no-build on test and publish.

  • Mistake: having no lock files. Floating and transitive versions can resolve differently on a laptop, on yesterday's agent and on today's agent, so a build that passed can fail or behave differently with no code change. Instead: set RestorePackagesWithLockFile in Directory.Build.props, commit the lock files and use dotnet restore --locked-mode in CI.

  • Mistake: using --locked-mode without enabling lock files. With no packages.lock.json there is nothing to lock against, so the flag gives a false sense of safety. Instead: enable lock files first, check they are committed for every project, then add --locked-mode.

  • Mistake: using --no-build on publish with a different configuration or RID from the build. Publish copies output that doesn't exist or is stale, giving "file not found" errors or an artefact built for the wrong target. Instead: build with exactly the configuration and RID you publish, or let publish build and accept that tests ran against a different build.

  • Mistake: floating the SDK version (10.x). A new feature band brings a newer compiler and analysers, so the build can change or break without any code change. Instead: pin the SDK in global.json and point setup-dotnet at it with global-json-file.

  • Mistake: uploading test results only when tests pass. The default step condition skips the upload after a failure, which is exactly when you need the results. Instead: use if: always() in GitHub Actions, or condition: succeededOrFailed() in Azure Pipelines.

  • Mistake: triggers that miss work or waste it. Running only on push means pull requests are never validated, and running the full pipeline for documentation changes wastes minutes. Instead: trigger on pull_request and pushes to main, add path filters, and make the CI job a required status check on the protected branch.

  • Mistake: computing the version in several places, or editing it by hand. The assembly version, package version and artefact name drift apart, so you can't tell which build is deployed. Instead: compute the version once (from a Git tag or a tool such as Nerdbank.GitVersioning) and pass it to build and publish.

  • Mistake: leaving the workflow token with write access and actions on moving tags. A compromised action or a malicious pull request can then push code or publish packages. Instead: set permissions: contents: read by default, pin third-party actions to a commit SHA in sensitive repos, and keep secrets away from fork pull requests.

  • Mistake: using legacy Azure Pipelines tasks such as NuGetCommand or VSBuild instead of dotnet commands. The pipeline then restores and builds differently from developers' machines, and problems can't be reproduced locally. Instead: use the same dotnet commands in the pipeline that developers run locally, through DotNetCoreCLI@2 or plain script steps.

🏛 Senior insight: trade-offs

A good pipeline review is about guarantees, not style: the artefact we deploy is the one we tested, built by a known SDK from known packages, and the code meets our agreed standards. Each flag (--no-restore, --no-build, --locked-mode, --verify-no-changes) buys one of those guarantees and saves time as a side effect. I'd also push shared settings into Directory.Build.props and .editorconfig so the pipeline stays thin and local builds behave the same as CI. For larger repos, consider splitting format and analyser checks into a fast parallel job so developers get feedback within a couple of minutes.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate adds trx logging and coverage collection. Why: Goes deeper on the test step: filters, coverage, blame for 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 adds --locked-mode or a private feed to the restore step. Why: Checks what happens when the locked restore can't reach a private feed.
    How to answer

    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.

  3. When: Candidate asks how schema changes are deployed alongside the app. Why: Extends the pipeline to database changes.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Formatting drift and an untested Release binary reach production
Situation
A .NET 10 orders service built with GitHub Actions. The pipeline ran dotnet format without verification and tested a Debug build, then published Release separately.
Signals
Large unrelated whitespace diffs in every PR; a bug that reproduced only in the deployed build, caused by code inside #if DEBUG; pipeline duration around 14 minutes.
Investigation
Compared the binaries in the test step's bin/Debug with the published output and found them built separately. Ran dotnet format --verify-no-changes locally and found hundreds of violations the pipeline had been 'fixing' on the agent. Looked at the step timings and saw three restores.
Resolution
Restructured the pipeline: global.json SDK, single locked restore, format verification, one Release build, test and publish with --no-build, test results uploaded. Ran a one-off dotnet format commit to clear the backlog.
Prevention
Required status check on the format step; lock files enabled in Directory.Build.props; pipeline template shared across repos; a rule in the PR template about #if DEBUG code.

How to tell it (STAR)

**Situation:** At [company], our [orders] service pipeline took [14] minutes, and we shipped a bug that only appeared in Release because tests ran against a Debug build. **Task:** I offered to review and fix the pipeline so we tested what we shipped. **Action:** I found it restored [three] times, ran dotnet format without --verify-no-changes, and published a separate build. I rewrote it to restore once with --locked-mode, build Release once, and test and publish with --no-build, and made format verification a required check after a one-off cleanup commit. **Result:** Pipeline time dropped to [N] minutes, whitespace noise disappeared from PRs, and we had no more Release-only surprises in [period]. The template was adopted by [N] other teams.

↩ Usually asked after

More in .NET CLI

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me