BuildCheck, Change Waves and Best Practices
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its 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 |
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 —
ReportNamereadsReportPrefixone line before it is set, soReportNameis-trial-balance. Properties are evaluated in file order (Properties). -
BC0201 —
LEDGERLINE_TITLEis neither set in the project nor in the environment, soLedgerTitleis 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.
Source: BC0101, BC0102, BuildCheck rule list.
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.
Source: BuildCheck rule list.
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:
-
Unset — every wave is on.
-
18.9— 18.9 and later are off; 18.8 and earlier stay on. -
17.11— not a wave; rounded up to the next valid wave (17.12), which turns off everything from 17.12. -
99.0— out of rotation; clamped to the highest wave (18.9) with warning MSB4272. -
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 endmsbuild.exeworker 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 inDirectory.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). -
-checkin a scheduled CI job to catch new shared outputs, double writes and environment-sourced properties.
Source: MSBuild best practices.
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 |
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 explicitIncludeAssets) 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.
-noAutoResponsedisablesDirectory.Build.rspandMSBuild.rsp. -
User extensions.
ImportUserLocationsByWildcardBefore<ImportingFileNameWithNoDots>set tofalseopts out of the user-profile import for that file.
Source: Secure MSBuild usage best practices.
Common mistakes
-
Turning BuildCheck findings into errors on the first run — many come from SDK-imported logic; triage first.
-
build_check.BC0201.severity = noneto silence one rule — also silenced BC0202 here. -
Setting
MSBUILDDISABLEFEATURESFROMVERSIONpermanently — 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.