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.jsondecides 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
PackageReferencecan 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, andPrivateAssets="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-modeso a lock-file drift fails the build rather than floating a version, but only where the repo commitspackages.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 withNU1004. - Build once, in
Release, with-warnaserror. Keep the switch even though the template setsTreatWarningsAsErrors: 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:TreatWarningsAsErrorsis 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-changesas a step. (Meziantou: Enforce .NET code style in CI, Meziantou: The Roslyn analyzers I use) - Test via
dotnet teston Microsoft.Testing.Platform, which requires thetest.runneropt-in inglobal.json(see testing); without it the SDK routes through VSTest, and on .NET 10 that fails before the build, so--no-buildcannot 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-trxwith--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 trxis rejected under MTP (see testing.md). (Microsoft Learn: What's new in .NET 10: SDK) - Publish/pack with
--no-buildand upload the output as the single artifact that later stages (deploy, release) consume. Never rebuild for deployment. No--outputflag: with the artifacts layout from templates/Directory.Build.props, publish output lands atartifacts/publish/<Project>/releasefor a single-TFM, non-RID publish (the pivot gains_<tfm>/_<rid>suffixes otherwise) and pack output atartifacts/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 intoartifacts/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.