Continuous Integration

A workflow that restores, builds with warnings as errors, tests and publishes on every push, with the NuGet folder cached.

Syntaxdotnet build --no-restore --configuration Release -warnaserror

Continuous 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

Example · csharp
// 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: grace

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

Example · yaml
# .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/api

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

Example · xml
<!-- 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.

Example · yaml
# 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

  • Be the first to comment on this lesson.