CI Builds and Caching

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

A CI build should be reproducible (the same commit gives the same result), fast (no repeated downloads, no repeated compiles) and diagnosable (when it fails, the logs say why). With the .NET tool chain that comes down to a fixed command sequence, a cache of the NuGet global-packages folder keyed on the lock files, a pinned SDK and a binary log kept as an artifact. This page builds Ledgerline that way with .NET SDK 10.0.402 — locally, as CI would run it — and shows the GitHub Actions and Azure Pipelines definitions that do the same. The workflows themselves are not run here; every line was checked against the action README and the task reference. The short introduction is in Build and Tooling — Continuous Integration; publishing workflows are on CI Release Workflows.

The command sequence

dotnet restore --locked-mode
dotnet build   --no-restore -c Release -bl:artifacts/logs/build.binlog
dotnet test    --no-build   -c Release
dotnet pack    --no-build   -c Release -o artifacts/packages

Each step does one thing and the next one trusts it:

  • restore --locked-mode fails if any packages.lock.json does not match what the projects ask for, instead of quietly resolving a different graph (NU1004; see Lock Files and Locked Mode).

  • build --no-restore reuses the assets files the restore wrote; -bl writes a binary log of everything MSBuild did.

  • test --no-build and pack --no-build reuse the build output, so nothing is compiled twice. They must use the same -c Release, or they look for output that does not exist.

Run in a fresh clone of Ledgerline, with GITHUB_ACTIONS=true (so ContinuousIntegrationBuild is on) and an empty NUGET_PACKAGES folder (as on a new agent):

  Determining projects to restore...
  Restored ~/ledgerline/src/Ledgerline.Cli/Ledgerline.Cli.csproj (in 197 ms).
  Restored ~/ledgerline/src/Ledgerline.Templates/Ledgerline.Templates.csproj (in 176 ms).
  Restored ~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj (in 232 ms).
  Restored ~/ledgerline/src/Ledgerline.Build/Ledgerline.Build.csproj (in 2.08 sec).
  Restored ~/ledgerline/src/Ledgerline/Ledgerline.csproj (in 2.48 sec).
  Restored ~/ledgerline/tests/Ledgerline.Tests/Ledgerline.Tests.csproj (in 2.64 sec).
…
    0 Error(s)

BinaryLogger wrote to: ~/ledgerline/artifacts/logs/build.binlog
…
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0
  duration: 780ms
  Successfully created package '~/ledgerline/artifacts/packages/Ledgerline.Templates.1.0.0.nupkg'.
  Successfully created package '~/ledgerline/artifacts/packages/Ledgerline.Build.1.0.0.nupkg'.
  Successfully created package '~/ledgerline/artifacts/packages/Ledgerline.1.0.0.nupkg'.
  Successfully created package '~/ledgerline/artifacts/packages/Ledgerline.Cli.1.0.0.nupkg'.

The classic mistake is the reverse order of trust: --no-restore after a change to a package version (the old assets file is used) or --no-build with a different configuration than the build.

What to cache

The expensive part of a cold restore is downloading packages into the global-packages folder (~/.nuget/packages, or wherever NUGET_PACKAGES points). After the run above it held 40 package folders, 247 MB — mostly the test and MSBuild packages:

du -sh .nuget/packages
ls .nuget/packages | wc -l
247M	.nuget/packages
40

Cache that folder, keyed on a hash of every packages.lock.json: a new key only when a dependency changes. Without lock files there is no reliable key (a floating version or a new transitive release changes the graph without changing any file), which is why both CI systems' documentation pairs the cache with lock files. Do not cache obj/: the assets files contain absolute paths and are cheap to regenerate from a warm global-packages folder. Clearing the HTTP cache does not help a restore that is stale — dotnet nuget locals all --clear is the reset, see Global Packages and Cache Folders.

GitHub Actions

actions/setup-dotnet installs the SDK and, with cache: true, saves and restores the global-packages folder. Its current major is v6 (v6.0.0; v5 moved the action to Node 24, v6 changed only internals). The GitHub Actions .NET tutorial still shows setup-dotnet@v4, and the older .NET page on MS Learn (dotnet-build-github-action) shows v3; use v6. Not run here:

name: ci

on:
  push:
    branches: [ main ]
  pull_request:

permissions:
  contents: read

env:
  DOTNET_CLI_TELEMETRY_OPTOUT: '1'
  DOTNET_NOLOGO: '1'
  NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      - run: dotnet restore --locked-mode
      - run: dotnet build --no-restore -c Release -tl:off -bl:artifacts/logs/build.binlog
      - run: dotnet test --no-build -c Release
      - run: dotnet pack --no-build -c Release -o artifacts/packages

      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: build-output
          path: |
            artifacts/logs/
            artifacts/packages/

Line by line:

  • global-json-file installs exactly the SDK global.json asks for, so CI cannot drift from what developers use. (If dotnet-version is also given, both are installed.)

  • cache: true looks for packages.lock.json in the repository root and fails if there is none; Ledgerline has one per project, so cache-dependency-path gives a glob.

  • NUGET_PACKAGES puts the global-packages folder in the workspace, which the README recommends to keep the cached size small on runners that ship large preinstalled package folders.

  • -tl:off gives plain console output in the job log; the binary log is uploaded even when a step fails (if: always()).

  • permissions: contents: read — a build job needs nothing more. Publishing jobs, and the id-token: write that Trusted Publishing needs, belong in a separate job (CI Release Workflows).

Azure Pipelines

The same sequence as an Azure Pipelines job (not run here). UseDotNet@2 installs the SDK from global.json, Cache@2 caches the global-packages folder keyed on the lock files, and NuGetAuthenticate@1 is needed only when a source is an Azure Artifacts feed (it sets up the credential provider; see Authenticated Feeds):

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release
  NUGET_PACKAGES: $(Pipeline.Workspace)/.nuget/packages
  DOTNET_CLI_TELEMETRY_OPTOUT: '1'
  DOTNET_NOLOGO: '1'

steps:
- task: UseDotNet@2
  inputs:
    packageType: sdk
    useGlobalJson: true

- task: Cache@2
  inputs:
    key: 'nuget | "$(Agent.OS)" | **/packages.lock.json,!**/bin/**,!**/obj/**'
    restoreKeys: |
      nuget | "$(Agent.OS)"
      nuget
    path: $(NUGET_PACKAGES)
  displayName: Cache NuGet packages

- task: NuGetAuthenticate@1

- script: dotnet restore --locked-mode
  displayName: Restore
- script: dotnet build --no-restore -c $(buildConfiguration) -tl:off -bl:$(Build.ArtifactStagingDirectory)/build.binlog
  displayName: Build
- script: dotnet test --no-build -c $(buildConfiguration)
  displayName: Test
- script: dotnet pack --no-build -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)/packages
  displayName: Pack

- publish: $(Build.ArtifactStagingDirectory)
  artifact: build-output
  condition: always()

Two notes from the Azure documentation: run the restore every time (the cached folder makes it cheap) — skipping it on a cache hit leads to "project.assets.json not found" — and, when using lock files, pin the SDK exactly, because implicit package versions can differ between SDK versions and make a locked restore fail. On Ubuntu 24.04 agents and later, NuGetAuthenticate@1 with the .NET CLI replaces the older NuGetCommand@2 task.

Environment variables for CI

Variable Effect

DOTNET_CLI_TELEMETRY_OPTOUT=1

No usage data is sent (see Environment Variables, Completion and Telemetry).

DOTNET_NOLOGO=1

Suppresses the first-run welcome banner.

NUGET_PACKAGES

Moves the global-packages folder (to a cacheable path).

GITHUB_ACTIONS / TF_BUILD

Set by the CI system; used to turn on ContinuousIntegrationBuild.

MSBUILDTERMINALLOGGER=off

Same as -tl:off for every command. MSBuild reads this variable, but the .NET environment-variable page does not list it; -tl:off is the documented form. (The Terminal Logger’s auto mode already uses the classic logger when standard output is redirected, as it is in most CI systems.)

A binary log records every property and environment variable the build saw, including tokens that CI injects as environment variables. Upload binary logs only as private build artifacts, never attach them to public issues, and keep secrets out of the build’s environment where possible (pass them only to the step that pushes). See Logging and Binary Logs.

Container builds with a restore layer

In a Dockerfile, the same caching idea becomes layer ordering: copy only the files that decide the package graph, restore, and copy the sources afterwards. A change to Program.cs then reuses the restore layer.

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0.402 AS build
WORKDIR /src

# 1. Restore layer: only the files that decide the package graph, so it is cached until they change.
COPY global.json nuget.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
COPY artifacts/feed/ artifacts/feed/
COPY samples/Ledgerline.Report/Ledgerline.Report.csproj samples/Ledgerline.Report/packages.lock.json samples/Ledgerline.Report/
RUN dotnet restore samples/Ledgerline.Report --locked-mode

# 2. Build layer: the sources.
COPY samples/Ledgerline.Report/ samples/Ledgerline.Report/
RUN dotnet publish samples/Ledgerline.Report -c Release --no-restore -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0.13
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["./Ledgerline.Report"]

with a .dockerignore that keeps bin/, obj/ and .git/ out of the build context. The first attempt to build it failed in an instructive way. The container had no route to the package sources, and Ledgerline's TreatWarningsAsErrors turned the audit warning into an error — NuGetAudit contacts every configured source for vulnerability data, even when the project’s packages all come from a local folder:

#15 13.34 /src/samples/Ledgerline.Report/Ledgerline.Report.csproj : error NU1900: Warning As Error: Error occurred while getting package vulnerability data: Unable to load the service index for source https://api.nuget.org/v3/index.json.
#15 13.34 /src/samples/Ledgerline.Report/Ledgerline.Report.csproj : error NU1900: Warning As Error: Error occurred while getting package vulnerability data: Unable to load the service index for source http://localhost:5555/v3/index.json.
#15 ERROR: process "/bin/sh -c dotnet restore samples/Ledgerline.Report --locked-mode" did not complete successfully: exit code: 1

The fixes are network access during the restore step (here --network host, plus the proxy and its CA certificate that this sandbox requires for outbound HTTPS, mounted as a build secret for that one RUN), or an audit source that is reachable (NuGetAuditSources, see Auditing Packages for Vulnerabilities). With access, the image builds and runs, and after a change to Program.cs the restore layer comes from the cache (the commands below were run with the extra sandbox arguments just described; the … in the output hides that mount):

docker run --rm ledgerline-report:dev journal.ledger.csv
echo '// a source change' >> samples/Ledgerline.Report/Program.cs
docker build --progress=plain -f samples/Ledgerline.Report/Dockerfile -t ledgerline-report:dev .
[
  {
    "Account": "1000-Cash",
    "Debit": 500.00,
    "Credit": 120.00,
    "Balance": 380.00
  },
…
#14 [build 6/8] RUN … dotnet restore samples/Ledgerline.Report --locked-mode
#14 CACHED
#15 [build 7/8] COPY samples/Ledgerline.Report/ samples/Ledgerline.Report/
#15 DONE 0.0s
#16 [build 8/8] RUN dotnet publish samples/Ledgerline.Report -c Release --no-restore -o /app

The SDK can also build images without a Dockerfile (dotnet publish -t:PublishContainer), covered on Container Images.

Common mistakes

  • A global.json pinning an SDK the agent does not have — install from global.json (global-json-file, useGlobalJson) rather than hard-coding a version in the workflow.

  • --no-restore after changing a package, --no-build with another configuration — see above.

  • Caching without lock files — a stale cache key; or cache: true failing because there is no root lock file.

  • Global tools installed on the agent (dotnet tool install -g) — use a local tool manifest and dotnet tool restore so the version is in source control (dotnet Tools).

  • Publishing from the build job — keep credentials and id-token: write out of the job that runs untrusted code.

  • Public binary logs — see the warning above.

Release notes

  • setup-dotnet v5 moved the action to the Node 24 runtime (runner 2.327.1 or later); v6 changed only its internals (ESM).

  • .NET 9: Terminal Logger by default for interactive terminals.