Continuous Integration
A workflow that restores, builds with warnings as errors, tests and publishes on every push, with the NuGet folder cached.
dotnet build --no-restore --configuration Release -warnaserrorContinuous integration means a machine builds and tests your code every time you push it, not only on your own laptop. The machine is a runner. The file that tells it what to do is a workflow. A workflow holds jobs, and a job is an ordered list of steps. If a step exits with a non-zero status, the job fails and the commit is marked broken.
The four steps
Restore downloads the NuGet packages the projects reference. Build compiles them. Test runs the test projects. Publish writes the output a later job will package into an image.
Most .NET commands restore first on their own. So dotnet build is shorthand for dotnet restore followed by dotnet build --no-restore. The two forms do the same work. Write the long form in CI, because a broken package feed then shows up as a failed restore step instead of a confusing build error.
Warnings as errors
A warning nobody fails on is a warning nobody fixes. The -warnaserror switch turns every warning into an error for the whole build. The project-file property TreatWarningsAsErrors does nearly the same thing, but it only covers the C# compiler, while the switch covers every MSBuild task. -warnaserror and /warnaserror are the same switch written two ways.
Caching
Restore downloads the same packages on every run. The actions/setup-dotnet step with cache: true keeps the NuGet global packages folder between runs. It builds the cache key from the hash of your packages.lock.json files, so the cache is reused until a dependency actually changes.
Example
// Code that survives a build run with -warnaserror. Nullable reference
// types are enabled, so the compiler tracks which values can be null.
string[] names = ["ada", "grace", "alan"];
// Array.Find returns string? because the search can fail. Assigning it to
// a plain string would raise CS8600, which -warnaserror makes fatal.
string? found = Array.Find(names, name => name.StartsWith('g'));
if (found is null)
{
Console.WriteLine("no match");
}
else
{
Console.WriteLine($"found: {found}");
}
// Writing Console.WriteLine(found.Length) outside that check would raise
// CS8602, "dereference of a possibly null reference". On a laptop it is a
// yellow squiggle. In CI with -warnaserror it stops the job.
// Output:
// found: graceWhen to use it
- Fail a pull request before a human reads it when a test breaks or a new compiler warning appears.
- Cut the restore time on every run by caching the NuGet global packages folder.
- Produce the published output that a later job packages into a container image.
- Stop a dependency version changing without the lock file change being reviewed.
More examples
The whole workflow
The order matters: restore, build, test, publish, each with the flag that stops it redoing the previous step. Every step shares one checkout and one SDK install. The uploaded folder is the published application, which the image job in the next lesson turns into a container. Reach for this layout as the first workflow in any repository.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
# Read-only by default. A job that needs more asks for it itself.
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
# --locked-mode fails if the lock file and the project disagree, so a
# dependency cannot change without the lock file being committed too.
- run: dotnet restore --locked-mode
- run: dotnet build --no-restore --configuration Release -warnaserror
- run: dotnet test --no-build --configuration Release
- run: dotnet publish src/Orders.Api --no-build --configuration Release --output artifacts/api
- uses: actions/upload-artifact@v7
with:
name: api
path: artifacts/apiSettings that belong in the repository, not the workflow
Putting the gates here instead of in the workflow means a developer sees the same errors before pushing. Use this whenever a rule must hold for every project in a solution; it saves repeating properties in each .csproj and keeps the workflow file down to commands.
<!-- Directory.Build.props at the repository root. Every project
underneath it picks these up, so CI and a laptop build agree. -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<!-- Same intent as the -warnaserror switch, narrower reach: this one
only covers the C# compiler. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Writes packages.lock.json next to each project. Commit the files:
setup-dotnet's cache key and restore --locked-mode both read them. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>A formatting gate, and not wasting runner time
Formatting arguments disappear once a machine decides them. Running dotnet format with --verify-no-changes turns indentation and brace style into a pass or fail rather than a review comment. The concurrency block belongs in any repository where people push several commits in a row to the same pull request.
# Cancel a run that a newer push to the same branch has made pointless.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
style:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
# Reports files that are not formatted and exits non-zero.
# It changes nothing, unlike a bare dotnet format.
- run: dotnet format --verify-no-changes
Discussion