BuildCheck, Change Waves and Best Practices

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

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.

MSBuild accepts almost any XML that parses, so most mistakes in build logic are not errors — they are surprises that appear months later. This page covers three tools for keeping build logic healthy with MSBuild 18.9 (.NET SDK 10.0.402): BuildCheck, an analyzer for build scripts; change waves, the switch that turns off recent behaviour changes when an upgrade breaks a build; and the published best-practice and security guidance.

BuildCheck

BuildCheck was introduced with the .NET 9 SDK (MSBuild 17.12). -check (also /check) runs a set of rules while the build evaluates and executes, and reports violations as ordinary warnings or messages with BCxxxx codes. Its extensibility API lives in the Microsoft.Build.Experimental.BuildCheck namespace, so this page treats the feature as experimental: the rule set, the defaults and the configuration format can still change.

Rule Default severity What it reports

BC0101

Warning

Two projects share an OutputPath or IntermediateOutputPath.

BC0102

Warning

Two tasks write the same file.

BC0103

Suggestion

A property gets its value from an environment variable.

BC0201

Warning

A property is read but never set.

BC0202

Warning

A property is read before the line that first sets it.

BC0203

None

A property is set but never read.

Only BC0101 and BC0102 have their own documentation pages; the others are described only in the rule list.

Running it

Two projects, A and B, contain the mistakes BuildCheck is designed to find:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- Both projects write to the same folder. -->
    <OutputPath>$(MSBuildThisFileDirectory)../shared-bin/</OutputPath>
    <LedgerTitle>$(LEDGERLINE_TITLE)</LedgerTitle>
    <ReportName>$(ReportPrefix)-trial-balance</ReportName>
    <ReportPrefix>ledger</ReportPrefix>
  </PropertyGroup>
</Project>
dotnet build Bc.slnx -check -nologo -v:m -tl:off
~/bc/B/B.csproj(7,5): warning BC0202: https://aka.ms/buildcheck/codes#BC0202 - Property: 'ReportPrefix' first declared/initialized at ~/bc/B/B.csproj (8,5) used before it was initialized.
~/bc/A/A.csproj(7,5): warning BC0202: https://aka.ms/buildcheck/codes#BC0202 - Property: 'ReportPrefix' first declared/initialized at ~/bc/A/A.csproj (8,5) used before it was initialized.
MSBUILD : warning BC0101: https://aka.ms/buildcheck/codes#BC0101 - Projects A.csproj and B.csproj have conflicting output paths: ~/bc/shared-bin/net10.0/. [~/bc/A/A.csproj]
  B -> ~/bc/shared-bin/net10.0/B.dll
~/bc/B/B.csproj(6,5): warning BC0201: https://aka.ms/buildcheck/codes#BC0201 - Property: 'LEDGERLINE_TITLE' was accessed, but it was never initialized.
~/bc/A/A.csproj(6,5): warning BC0201: https://aka.ms/buildcheck/codes#BC0201 - Property: 'LEDGERLINE_TITLE' was accessed, but it was never initialized. [~/bc/B/B.csproj]
  A -> ~/bc/shared-bin/net10.0/A.dll

Build succeeded.
    5 Warning(s)
    0 Error(s)

Each finding is real:

  • BC0101 — with a shared output folder, the two projects overwrite each other’s deps.json, PDBs and copied dependencies, and a parallel build can fail with file-lock errors (MSB3026/MSB3027).

  • BC0202 — ReportName reads ReportPrefix one line before it is set, so ReportName is -trial-balance. Properties are evaluated in file order (Properties).

  • BC0201 — LEDGERLINE_TITLE is neither set in the project nor in the environment, so LedgerTitle is empty.

With the environment variable set, BC0201 disappears and BC0103 appears instead, as a message (its default severity is "suggestion"):

LEDGERLINE_TITLE=Ledgerline dotnet build A/A.csproj -check -nologo -v:m -tl:off
~/bc/A/A.csproj(6,5): message BC0103: https://aka.ms/buildcheck/codes#BC0103 - Property is derived from environment variable: 'LEDGERLINE_TITLE'. Properties should be passed explicitly using the /p option.
~/bc/A/A.csproj(7,5): warning BC0202: https://aka.ms/buildcheck/codes#BC0202 - Property: 'ReportPrefix' first declared/initialized at ~/bc/A/A.csproj (8,5) used before it was initialized.

BC0102 needs two tasks that write one file. Two Copy tasks are enough:

<Project>
  <Target Name="Build">
    <Copy SourceFiles="a.txt" DestinationFiles="out/summary.txt" />
    <Copy SourceFiles="b.txt" DestinationFiles="out/summary.txt" />
  </Target>
</Project>
~/bc2/dw.proj(4,5): warning BC0102: https://aka.ms/buildcheck/codes#BC0102 - Tasks Copy and Copy from projects dw.proj and dw.proj write the same file: ~/bc2/out/summary.txt.

Run against Ledgerline, -check reported findings in files the repository does not own. BC0102 came from the xUnit v3 package’s buildTransitive targets, which copy xunit.abstractions.dll to the test output twice; BC0202 came from the SDK’s Microsoft.NET.ApiCompat.targets, which reads EnablePackageValidation before the project file sets it (the SDK import order, not a bug in the project):

~/ledgerline/…/xunit.v3.core.mtp-v2/4.0.2/buildTransitive/xunit.v3.core.mtp-v2.targets(128,5): warning BC0102: https://aka.ms/buildcheck/codes#BC0102 - Tasks Copy and Copy from projects Ledgerline.Tests.csproj and Ledgerline.Tests.csproj write the same file: ~/ledgerline/tests/Ledgerline.Tests/bin/Debug/net10.0/xunit.abstractions.dll.
…/sdk/10.0.402/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.ApiCompat.targets(18,14): warning BC0202: https://aka.ms/buildcheck/codes#BC0202 - Property: 'EnablePackageValidation' first declared/initialized at ~/ledgerline/src/Ledgerline/Ledgerline.csproj (12,5) used before it was initialized. [~/ledgerline/src/Ledgerline/Ledgerline.csproj::TargetFramework=netstandard2.0]

Treat the first -check run on an existing repository as a triage list, not a gate.

Configuring severities

Rules are configured in .editorconfig with build_check.<rule>.severity (none, suggestion, warning, error), in a section that matches the project file:

root = true

[*.csproj]
build_check.BC0202.severity = error
~/bc/A/A.csproj(6,5): warning BC0201: https://aka.ms/buildcheck/codes#BC0201 - Property: 'LEDGERLINE_TITLE' was accessed, but it was never initialized.
~/bc/A/A.csproj(7,5): error BC0202: https://aka.ms/buildcheck/codes#BC0202 - Property: 'ReportPrefix' first declared/initialized at ~/bc/A/A.csproj (8,5) used before it was initialized.

A trap observed with MSBuild 18.9: BC0201, BC0202 and BC0203 come from one check. Setting build_check.BC0201.severity = none silenced BC0202 as well (0 Warning(s)), even though only BC0201 was named. To quieten one of the three, lower it to suggestion instead of none, and re-run -check after any configuration change to see what still reports.

Change waves

A change wave is a group of potentially breaking MSBuild behaviour changes, named after the MSBuild version that introduced them. They are on by default; setting the environment variable MSBUILDDISABLEFEATURESFROMVERSION (also spelled MSBuildDisableFeaturesFromVersion) to a wave turns off that wave and every later one. It is an escape hatch for an upgrade that broke a build, not a configuration setting: waves rotate out after about two releases, and then the new behaviour is permanent.

$([MSBuild]::AreFeaturesEnabled('x.y')) shows the effect directly:

<Project>
  <Target Name="Show">
    <Message Importance="high" Text="18.4=$([MSBuild]::AreFeaturesEnabled('18.4')) 18.8=$([MSBuild]::AreFeaturesEnabled('18.8')) 18.9=$([MSBuild]::AreFeaturesEnabled('18.9')) 18.10=$([MSBuild]::AreFeaturesEnabled('18.10'))" />
  </Target>
</Project>
for v in '' 18.9 17.11 99.0 garbage; do
  dotnet build-server shutdown >/dev/null
  MSBUILDDISABLEFEATURESFROMVERSION=$v dotnet msbuild cw.proj -t:Show -nologo -v:m
done
  18.4=True 18.8=True 18.9=True 18.10=True
  18.4=True 18.8=True 18.9=False 18.10=False
  18.4=False 18.8=False 18.9=False 18.10=False
MSBUILD : warning MSB4272: Environment variable MSBUILDDISABLEFEATURESFROMVERSION is set to a version that is out of rotation. Defaulting to Change Wave version: 18.9. Entered value: 99.0. Current Change Waves: [17.10, 17.12, 17.14, 18.3, 18.4, 18.5, 18.6, 18.7, 18.8, 18.9].
  18.4=True 18.8=True 18.9=False 18.10=False
MSBUILD : warning MSB4271: Environment variable MSBUILDDISABLEFEATURESFROMVERSION is set to an invalid format. Enabling all change wave versions. Entered value: garbage. Current Change Waves: [17.10, 17.12, 17.14, 18.3, 18.4, 18.5, 18.6, 18.7, 18.8, 18.9].
  18.4=True 18.8=True 18.9=True 18.10=True

The five runs, in order:

  1. Unset — every wave is on.

  2. 18.9 — 18.9 and later are off; 18.8 and earlier stay on.

  3. 17.11 — not a wave; rounded up to the next valid wave (17.12), which turns off everything from 17.12.

  4. 99.0 — out of rotation; clamped to the highest wave (18.9) with warning MSB4272.

  5. garbage — invalid format; every wave stays on, with warning MSB4271.

The warnings list the waves MSBuild 18.9 still knows: 17.10 to 18.9. The documentation page already lists an 18.10 wave, which belongs to a later MSBuild than the baseline.

Two operational details matter:

  • Persistent processes keep the value they started with. Run dotnet build-server shutdown (or end msbuild.exe worker processes and close Visual Studio) after changing the variable, or reused nodes build with the old value — the reason the loop above shuts the servers down first.

  • Waves 17.10, 17.12, 17.14 and 18.3 are removed in the MSBuild release that accompanies .NET 11. Builds that rely on disabling one of them must be fixed before upgrading.

The full list of features in each wave (for example, 18.9 blocks typed ResX entries in Mark-of-the-Web files with MSB3821; 18.8 makes ResolveAssemblyReference resolve relative paths against the project directory) is on the change waves page.

Best practices

The MSBuild best-practices page is short; the rest of this list is drawn from the pages of this section.

  • Defaults with a condition. <LedgerRoot Condition="'$(LedgerRoot)' == ''">…</LedgerRoot> lets a caller override the value with -p: or an earlier import.

  • Explicit items in hand-written projects. SDK-style projects handle globs correctly; in a non-SDK project, prefer explicit file lists.

  • Settings in Directory.Build.props, targets in Directory.Build.targets (Repository-Wide Configuration).

  • Inputs and outputs on every custom target that produces files (Incremental Builds and Batching).

  • == 'true' tests and quoted operands in conditions (Conditions).

  • -check in a scheduled CI job to catch new shared outputs, double writes and environment-sourced properties.

Security

A build runs code. Project files, imported .props/.targets files, inline and compiled tasks, and the build, buildTransitive, buildMultitargeting and analyzers assets of every NuGet package all execute with the rights of the account that runs the build — and opening a project in an IDE runs evaluation and restore too.

Only build, restore or open source you trust. MSBuild imports Directory.Build.props, Directory.Build.targets and Directory.Build.rsp from every parent folder up to the root of the drive, and loads extension files from the user profile (MSBuildUserExtensionsPath), so a writable folder above your clone or a tampered profile can inject build logic. Use a dedicated account, container or VM for builds of untrusted code.

The security guidance lists the controls:

  • Write access. Install MSBuild (Visual Studio, the .NET SDK, Build Tools) where only trusted users can write, and keep the folders above the source tree writable only by the build account.

  • A sentinel Directory.Build.props. An empty <Project /> at the repository root stops the upward search before it reaches a shared parent folder.

  • Packages. Know the build assets your packages add; use ExcludeAssets="build;buildTransitive" (or an explicit IncludeAssets) where you do not need them (Supply-Chain Security).

  • Command line and environment. Know which switches, response files and environment variables reach the build; loggers named on the command line are loaded as code. -noAutoResponse disables Directory.Build.rsp and MSBuild.rsp.

  • User extensions. ImportUserLocationsByWildcardBefore<ImportingFileNameWithNoDots> set to false opts out of the user-profile import for that file.

Common mistakes

  • Turning BuildCheck findings into errors on the first run — many come from SDK-imported logic; triage first.

  • build_check.BC0201.severity = none to silence one rule — also silenced BC0202 here.

  • Setting MSBUILDDISABLEFEATURESFROMVERSION permanently — the waves rotate out and the variable then stops having an effect.

  • Changing the variable without shutting down build servers — reused nodes keep the old value.

  • Building an untrusted clone "just to look at it" — restore and build execute its logic.