CI Builds and Caching
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its 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-modefails if anypackages.lock.jsondoes not match what the projects ask for, instead of quietly resolving a different graph (NU1004; see Lock Files and Locked Mode). -
build --no-restorereuses the assets files the restore wrote;-blwrites a binary log of everything MSBuild did. -
test --no-buildandpack --no-buildreuse 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-fileinstalls exactly the SDKglobal.jsonasks for, so CI cannot drift from what developers use. (Ifdotnet-versionis also given, both are installed.) -
cache: truelooks forpackages.lock.jsonin the repository root and fails if there is none; Ledgerline has one per project, socache-dependency-pathgives a glob. -
NUGET_PACKAGESputs 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:offgives 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 theid-token: writethat 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 |
|---|---|
|
No usage data is sent (see Environment Variables, Completion and Telemetry). |
|
Suppresses the first-run welcome banner. |
|
Moves the global-packages folder (to a cacheable path). |
|
Set by the CI system; used to turn on |
|
Same as |
|
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.jsonpinning an SDK the agent does not have — install fromglobal.json(global-json-file,useGlobalJson) rather than hard-coding a version in the workflow. -
--no-restoreafter changing a package,--no-buildwith another configuration — see above. -
Caching without lock files — a stale cache key; or
cache: truefailing because there is no root lock file. -
Global tools installed on the agent (
dotnet tool install -g) — use a local tool manifest anddotnet tool restoreso the version is in source control (dotnet Tools). -
Publishing from the build job — keep credentials and
id-token: writeout 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.