Skip to content

CI & automation

Targets: net10.0 · Last reviewed: 2026-09-14 · Sources: meziantou, ms-learn, house

Supply-chain hygiene is not optional in the agentic era.

Opinions

  • Pin GitHub Actions to commit SHAs, not mutable tags: tags move silently; SHAs don't. Automate the sweep across repositories. (Meziantou: SHA pinning)
  • Never interpolate user-provided input into workflow scripts: pass it via environment variables and parse deliberately (script-injection is the top Actions vulnerability). (Meziantou: Safely passing extra arguments)
  • CI builds are pinned and reproducible: global.json decides the SDK, lock files or CPM decide packages, and a CI run must not float versions the repo didn't choose. (Microsoft Learn: global.json overview, Meziantou: Faster and Safer NuGet restore using Source Mapping and Lock files)
  • Restrict what a package may run, not only which version restores. A PackageReference can carry MSBuild props and targets, analyzers and source generators, all of which execute during restore, design-time build and compile. Pinning the version says which code you get; it does not say that none of it runs. Name the asset types a package actually needs and leave the rest out: IncludeAssets="compile;runtime" for a library you only call, and PrivateAssets="all" on build-only tooling so it never reaches your consumers. Some packages are their build logic, so expect to loosen this per package and to re-test when you tighten it. (Meziantou: Limit what NuGet packages can do in your project)
  • House: Warnings are treated as errors everywhere, not only in CI. A warning discovered on the build machine and not the workstation is one that shipped a day late. Suppressing a warning with #pragma warning disable, [SuppressMessage] or a <NoWarn> entry always carries an inline comment stating why; an unexplained suppression is reverted on sight, since the reviewer should never have to reconstruct the reason.

Pipeline shape

One pipeline, four explicit stages (restore, build, test, publish), each forbidding the previous stage's work. --no-restore on build and --no-build on test/publish guarantee every stage runs against exactly the outputs of the one before it, instead of silently rebuilding with different flags. Pass the same --configuration to every stage: dotnet publish defaults to Release for current target frameworks while dotnet build and dotnet test default to Debug, so a mismatched --no-build stage looks for outputs that were never built. The shape is platform-agnostic, and GitHub Actions is only the worked example. (Microsoft Learn: dotnet publish, Microsoft Learn: dotnet test)

  • Restore with --locked-mode so a lock-file drift fails the build rather than floating a version, but only where the repo commits packages.lock.json (RestorePackagesWithLockFile=true). templates/Directory.Build.props pins the dependency graph via CPM without lock files, so the example below restores plain; adding the flag without a lock file fails every restore with NU1004.
  • Build once, in Release, with -warnaserror. Keep the switch even though the template sets TreatWarningsAsErrors: the property covers compiler and analyzer diagnostics, while the switch also promotes MSBuild engine warnings (e.g. MSB3277 assembly-version conflicts) that the property leaves as warnings. House: TreatWarningsAsErrors is set unconditionally in templates/Directory.Build.props, so those warnings fail the build on every workstation, not only in CI. This file previously recommended CI-only enforcement to keep local iteration fluid, and the house rule supersedes it, because a build that is only red in CI is a warning that already reached a PR. Enforce style the same way: analyzers in the build, dotnet format --verify-no-changes as a step. (Meziantou: Enforce .NET code style in CI, Meziantou: The Roslyn analyzers I use)
  • Test via dotnet test on Microsoft.Testing.Platform, which requires the test.runner opt-in in global.json (see testing); without it the SDK routes through VSTest, and on .NET 10 that fails before the build, so --no-build cannot rescue it. Publish TRX and coverage as build artifacts so failures are diagnosable without a rerun. Under xunit.v3 the TRX switch is --report-xunit-trx with --results-directory, as in the example below, and upload the results even when the step fails, since that is the run they diagnose. VSTest's --logger trx is rejected under MTP (see testing.md). (Microsoft Learn: What's new in .NET 10: SDK)
  • Publish/pack with --no-build and upload the output as the single artifact that later stages (deploy, release) consume. Never rebuild for deployment. No --output flag: with the artifacts layout from templates/Directory.Build.props, publish output lands at artifacts/publish/<Project>/release for a single-TFM, non-RID publish (the pivot gains _<tfm>/_<rid> suffixes otherwise) and pack output at artifacts/package/<configuration>, with no project segment (see project-structure.md). Keep the deploy artifact deploy-only: test projects set <IsPublishable>false</IsPublishable> as templates/projects/Example.Library.Tests.csproj does, otherwise a solution-level publish writes the test host and a second copy of every referenced library into artifacts/publish. (Microsoft Learn: Artifacts output layout)
# Least privilege at the top: the default token is read/write on contents unless
# the repository says otherwise, and a build job needs neither.
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
        with:
          global-json-file: global.json
      - run: dotnet restore
      - run: dotnet build --no-restore --configuration Release -warnaserror
      - run: dotnet test --no-build --configuration Release --report-xunit-trx --results-directory artifacts/test-results
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        if: ${{ !cancelled() }}
        with:
          name: test-results
          path: artifacts/test-results
      - run: dotnet publish --no-build --configuration Release
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: app
          path: artifacts/publish

(The checkout and setup-dotnet pins are the ones this repository runs; upload-artifact is illustrative. Resolve every SHA against the current release when copying, and check the major while you are there. A pinned SHA never tells you it has gone stale, which is why the pinning opinion above and a dependency bot are one policy, not two.)

Artifact signing

Spend your signing budget on provenance and credential hygiene, not author-signing ceremony. For NuGet packages: ship deterministic builds with Source Link, symbols, and package validation enabled; publish with a least-privilege, short-lived credential (trusted publishing / scoped API key stored as a repository secret) rather than a long-lived org-wide key. (Meziantou: Publishing a NuGet package using GitHub Actions) Author-sign packages only if your organisation already operates certificate infrastructure. nuget.org repository-signs everything it serves, so for most publishers author signing adds cost without a consumer who verifies it. (Microsoft Learn: Sign a NuGet package) On the consuming side, pinning is the protection that pays: locked restore, pinned SDK, SHA-pinned actions.

Dependency updates

Use Renovate. One bot, one shared config preset reused across every repository, and it updates the things .NET repos actually pin: NuGet packages (grouped, with lock-file maintenance), the SDK version in global.json, .nuspec dependencies, and the action SHAs the pinning opinion above creates. (Meziantou: Sharing the Renovate configuration across multiple projects, Meziantou: Update dependencies in nuspec with Renovate) Dependabot lost on one axis: no cross-repository shared configuration, so every repo drifts its own policy. Whatever the bot, updates merge only through the same pipeline above: an update PR that skips --locked-mode restore and tests is supply-chain exposure, not hygiene.

House: The bot's dependency dashboard issue stays open permanently. It is bot-managed state, not a task, and closing it is not an opt-out, since Renovate recreates it on the next run and spends a fresh issue number and another round of notifications arriving back where it started. Keep it open because it is the only view of updates that exist but have no pull request yet: anything held behind prConcurrentLimit or waiting on a failing update branch appears there and nowhere else, and its checkbox is how you force an off-schedule run without pushing a config change. On a solution large enough to gate updates behind dependencyDashboardApproval, the dashboard stops being a status readout and becomes the control surface that releases work, and closing it would break the workflow, not merely lose visibility. Renovate's dependencyDashboardAutoclose closes the issue whenever nothing is pending; it loses because opening and closing an issue on every cycle costs more attention than one permanently open issue ever does, and because it hides a growing backlog exactly when the backlog is the signal. If a stale-issue bot starts nagging, exempt the dashboard by its label rather than closing it.

Two conditions keep that true at size. Do not treat the dashboard's detected-dependency list as an audit surface. GitHub caps an issue body at 65,536 characters, so a repository with enough manifests to matter is one whose list Renovate has already trimmed; assert what the bot does and does not manage in a config test or a scheduled dry run, where truncation cannot silently pass. And keep the pending list short and owned: a permanently open dashboard carrying hundreds of unactioned entries is one everybody learns to skip, which is a defect in the grouping and scheduling policy rather than in the issue. Fix it upstream: group related bumps, widen the schedule until the queue drains, and name the team that works it.