MSBuild¶
dotnet build looks like a single command doing one thing. Underneath, it's
invoking MSBuild — the build engine that has shipped with every version of
.NET and Visual Studio since 2003 — against your .csproj file, which is
itself an MSBuild project file, not a magic format the dotnet CLI
invented. Anyone who's opened a .csproj from before 2017 and one from
after has seen two wildly different amounts of XML for what's nominally the
same job; that gap is the SDK-style rewrite, and it's worth understanding
both what changed and why the underlying engine didn't.
The whole model in one page¶
Before getting into the XML, separate the layers that one dotnet build
command hides:
dotnet CLI
│
├── normally restore packages → obj/project.assets.json
│
└── invoke MSBuild
│
├── evaluate the project and all imports
│ → properties and item lists
│
└── execute the requested target graph
→ tasks such as Csc, Copy, and Exec
The .NET CLI is the user-facing command. MSBuild is the engine. The
.csproj, SDK files, NuGet-provided build files, and
Directory.Build.* files are inputs to that engine. NuGet restore
resolves packages and records their assets for MSBuild to consume (--no-restore
skips the implicit restore when those assets are already current). Targets
then arrange work, and tasks perform it.
This gives unfamiliar errors somewhere to live:
- a surprising property or missing file is usually an evaluation problem;
- a target running in the wrong order is an execution graph problem;
- a missing package assembly is often a restore or asset-selection problem;
- a compiler diagnostic comes from the task MSBuild eventually invoked.
The rest of the chapter opens each box in that diagram.
A .csproj is a build graph, not project metadata¶
The pre-2017 .csproj listed every source file explicitly:
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="Utils.cs" />
<Compile Include="Models\User.cs" />
</ItemGroup>
The SDK-style format that shipped with .NET Core/5+ inverts this: C# files
are included by default via a glob, so a typical modern .csproj is nearly
empty:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
Sdk="Microsoft.NET.Sdk" is the important part — it imports a large set of
default targets, properties, and item globs that used to be spelled out by
hand (or generated by Visual Studio) in every project file. This is Maven's
"convention over configuration" move, arriving at MSBuild about 13 years
later: implicit defaults instead of explicit line-by-line declarations,
with an escape hatch (adding <ItemGroup>/<PropertyGroup> entries) for
anything the default doesn't cover.
An "SDK" is just more MSBuild, stacked¶
Sdk="Microsoft.NET.Sdk" doesn't select a compiler or a runtime — it
resolves to a directory (shipped with the .NET SDK, or fetched as a
NuGet package for other SDKs) containing plain .props and .targets
files, imported at the very start and very end of your project file
respectively:
Sdk.props, evaluated first, sets defaults your file can still override
(default output paths, default language version, the implicit item
globs for .cs files). Sdk.targets, imported last, defines the actual
Compile, Build, and Publish targets that do the work, wired up via
BeforeTargets/AfterTargets/DependsOnTargets hooks that your own
project-level targets can slot into. There is no separate "SDK engine" —
Microsoft.NET.Sdk is authored in exactly the XML this chapter has been
showing, just a great deal more of it, and reading it (it lives under the
installed SDK's Sdks/Microsoft.NET.Sdk/Sdks or .../targets folders) is
a legitimate way to answer "where does this default actually come from,"
the same way mvn help:effective-pom answers it for Maven.
This is also why there are so many different Sdk= values —
Microsoft.NET.Sdk.Web, Microsoft.NET.Sdk.Razor,
Microsoft.NET.Sdk.BlazorWebAssembly, Microsoft.NET.Sdk.Worker,
Microsoft.NET.Sdk.WindowsDesktop (WPF/WinForms) — each is its own
NuGet-distributed stack of .props/.targets, and most of them work by
importing Microsoft.NET.Sdk themselves and layering additional targets
on top, rather than reimplementing project building from scratch. A
Blazor WebAssembly project is, mechanically, an ordinary SDK-style .NET
project plus an extra import that adds targets for producing a
_framework/ folder of WASM-runnable assemblies — "SDK" here means
"a reusable pile of MSBuild logic packaged like a library," not a
separate build system underneath the one this chapter describes.
Tracing the implicit glob to its actual source¶
The claim above — "C# files are included by default via a glob" — isn't
hand-waving; it's one specific ItemGroup, in one specific file, shipped
with the SDK. On this machine (.NET SDK 10.0.101) it's
Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.DefaultItems.props:
<ItemGroup Condition=" '$(EnableDefaultItems)' == 'true' ">
<Compile Include="**/*$(DefaultLanguageSourceExtension)"
Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)"
Condition=" '$(EnableDefaultCompileItems)' == 'true' " />
...
</ItemGroup>
$(DefaultLanguageSourceExtension) is .cs for the C# SDK (.vb/.fs for
the others) — this line is the entire "why does adding a .cs file to the
folder just work" mechanism, no IDE magic involved, and it's disableable
per-project with <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
for anyone who wants the pre-2017-style explicit <Compile Include> list
back.
$(DefaultItemExcludes) is worth following too, since it's assembled
piecemeal across several .targets files and explains a recurring "why
isn't my file compiling" report. The entry that matters most lives in
Microsoft.NET.DefaultOutputPaths.targets:
bin/ and obj/ are excluded from every implicit glob, on purpose —
without this, a build would glob-include its own previous output back into
the next compile, including generated .cs files MSBuild itself writes
into obj/. It's also why copying or extracting a stray .cs file into
bin//obj/ (an easy mistake in a script that shells out to the build)
silently produces a file that never compiles: not a bug in the glob,
working exactly as specified, just non-obvious until this file has been
read once.
Properties, items, targets, tasks — the four building blocks¶
Everything in MSBuild is one of these:
- Properties are named values —
<TargetFramework>net8.0</TargetFramework>is a property. Read them with$(TargetFramework). - Items are named lists of files or values —
<Compile>,<PackageReference>. Read them with@(Compile). - Targets are named, ordered groups of work with their own dependency relationships — the MSBuild equivalent of a Gradle task or a Make rule.
- Tasks are the actual units of execution a target invokes —
Csc(the C# compiler task),Copy,Exec.
A hand-written target makes the shape concrete:
<Target Name="PrintVersion" BeforeTargets="Build">
<Message Text="Building version $(Version)" Importance="high" />
</Target>
BeforeTargets="Build" is MSBuild's dependency mechanism — it says "run
this before the Build target," without needing to modify Build itself
or know its internal implementation. AfterTargets is the mirror image.
This is deliberately looser than Make's or Gradle's explicit dependsOn:
it lets you hook into a target defined somewhere in an imported .targets
file you've never opened, which is both the main way people customize a
.NET build and the main way two unrelated NuGet packages' BeforeTargets
hooks end up firing in an order nobody explicitly chose.
Two phases: evaluation, then execution¶
MSBuild processes a project in two strictly separated passes, and most "why did my property/item not take effect" confusion comes from not knowing which pass you're editing.
Evaluation runs first, top to bottom, through the project file and
every file it <Import>s: properties are assigned in document order,
item globs (<Compile Include="**/*.cs" />) are expanded against the
filesystem as they're encountered, and Condition attributes are
evaluated using whatever property values exist at that point in the
file. Nothing here runs a target or a task — it's pure data assembly,
building up the in-memory property and item tables that targets will read
from later.
Execution runs second: MSBuild determines which targets to run and in
what order (from DependsOnTargets, BeforeTargets, AfterTargets, and
the target(s) requested on the command line), then runs their tasks in
order, in a single pass over the already-evaluated property and item
values.
The consequence that catches people: a <PropertyGroup> or item
modification written inside a target only exists during execution, and
can't retroactively change an item whose condition was decided during
evaluation, even though both look like ordinary XML in the same file:
<PropertyGroup>
<IncludeExtra>false</IncludeExtra>
</PropertyGroup>
<ItemGroup>
<ReportInput Include="Extra.txt"
Condition="'$(IncludeExtra)' == 'true'" />
</ItemGroup>
<Target Name="EnableExtraAndReport" BeforeTargets="Build">
<PropertyGroup>
<IncludeExtra>true</IncludeExtra>
</PropertyGroup>
<Message Text="Report inputs: @(ReportInput)" Importance="high" />
</Target>
This looks like it should print Extra.txt: the target sets
IncludeExtra to true before its Message task reads @(ReportInput).
It prints an empty list. ReportInput's condition was already evaluated
against the earlier value false, before any target ran. Changing the
property during execution does not rewind evaluation and reconsider the
item.
Passing the value early enough produces the intended result:
The fix is not a different target hook. The real value must come from the command line or an earlier property/import so evaluation can see it when the item is created.
Properties overwrite; items accumulate¶
Document order affects properties and items differently. A later property assignment normally replaces the earlier value:
<PropertyGroup>
<ConfigurationName>First</ConfigurationName>
<ConfigurationName>Second</ConfigurationName>
</PropertyGroup>
$(ConfigurationName) ends as Second. Items form lists, so repeated
Include operations append:
@(ReportInput) contains both files. Remove subtracts matching items and
Update changes metadata on items already present. In an SDK-style project,
changing metadata on an implicitly included source usually uses Update,
not Include:
Using Include="Generated.cs" would try to add a second copy of a file the
SDK's **/*.cs glob already included, producing the NETSDK1022 duplicate
item error.
Targets form a graph too — just via imports, not a single file¶
A real .csproj is rarely just the file you're looking at. The Sdk
attribute alone pulls in Sdk.props and Sdk.targets — files that live in
the installed .NET SDK, not the repo — and any NuGet package with build
logic (analyzers, source generators, native interop) adds its own
.targets file via <Import>. dotnet build -bl produces a binary log
(openable with the MSBuild Structured Log
Viewer) that's the actual answer to "what ran and
in what order" — reading the .csproj alone tells you the project's own
targets, not the full graph assembled from every SDK and package import,
which is exactly why binary log inspection, not .csproj reading, is the
real debugging tool once a build does something surprising.
For a searchable text representation, preprocess the project:
effective.xml expands the imports into one large file, with comments
marking where each imported section came from. It answers "which definition
did MSBuild evaluate, and in what order?" A binary log answers the different
question "what happened while that evaluated project executed?" Search the
preprocessed file for a property or target definition; use the binary log
for task parameters, timings, and execution-time values.
Modern MSBuild can also query evaluated state without temporary Message
targets:
$ dotnet msbuild MyApp.csproj -getProperty:TargetFramework,OutputPath
{
"Properties": {
"TargetFramework": "net8.0",
"OutputPath": "bin/Debug/net8.0/"
}
}
$ dotnet msbuild MyApp.csproj -getItem:Compile
These commands inspect evaluation. If a property is assigned later inside a target, request that target too or use a binary log to observe its execution-time value.
Items carry metadata, not just filenames¶
An item isn't just a path — it can carry arbitrary key/value metadata, which is how MSBuild expresses per-file build behavior without a separate target for every file:
<ItemGroup>
<Content Include="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
CopyToOutputDirectory is metadata on this specific Content item —
%(CopyToOutputDirectory) inside a target reads it back per-item during a
Copy task's batching. This item/metadata model is the part of MSBuild
with the least direct equivalent in Make, Maven, or Gradle: those systems
mostly bind behavior to file type or task type; MSBuild lets a single
file carry its own build instructions inline.
PackageReference, traced from XML to compiler argument¶
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> doesn't
turn into a compiler argument directly — there's a concrete multi-step
pipeline between them, spread across a restore step and a build step that
only agree via a file on disk, and every step of it is inspectable.
1. dotnet restore resolves the dependency graph and writes it to
obj/project.assets.json — not just to the NuGet cache. It's a JSON map
from every resolved package to the actual files it contributes for each
purpose (compile-time reference, runtime asset, build-time .targets,
analyzer, ...). For the package above, restored for real, the relevant
slice is:
"targets": {
"net10.0": {
"Newtonsoft.Json/13.0.3": {
"type": "package",
"compile": {
"lib/net6.0/Newtonsoft.Json.dll": { "related": ".xml" }
},
"runtime": {
"lib/net6.0/Newtonsoft.Json.dll": { "related": ".xml" }
}
}
}
}
2. The ResolvePackageAssets task reads that file back during build —
a separate MSBuild invocation from restore, wired up in
Microsoft.PackageDependencyResolution.targets. Its CompileTimeAssemblies
output — one entry per "compile" entry above — becomes the
ResolvedCompileFileDefinitions item list.
3. ResolveLockFileReferences folds those into ordinary @(Reference)
items — the same item type a hand-written <Reference Include="System.Xml"
/> produces, carrying a HintPath metadata value that points at the actual
DLL on disk. Dropping a one-line diagnostic target into a throwaway project
confirms the whole chain end to end:
<Target Name="PrintReferences" AfterTargets="ResolveLockFileReferences">
<Message Text="REF: %(Reference.Identity) HintPath=%(Reference.HintPath)" Importance="high" />
</Target>
REF: /home/.../.nuget/packages/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll
HintPath=/home/.../.nuget/packages/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll
— resolved straight into the shared package cache under ~/.nuget/packages/,
with no copy into the project folder. Csc then reads @(Reference) like
any other input item, with no idea that some entries came from a NuGet
package and others from a hand-written <Reference> element — by the time
compilation runs, that distinction is already gone. This is also the answer
to "how can a build compile against a dependency without dotnet restore
ever copying a file into the repo": it doesn't need to. MSBuild references
packages in place from the global cache; project.assets.json is just the
map that tells it where.
MSBuild as a small programming language, not just config¶
XML syntax hides it, but MSBuild has real expression evaluation baked
into $() and @(), enough that project authors write actual logic
without ever leaving project-file syntax.
Property functions call real .NET static or instance methods directly from a property expression:
<PropertyGroup>
<BuildTimestamp>$([System.DateTime]::Now.ToString("yyyyMMdd"))</BuildTimestamp>
<IsRelease>$(Configuration.ToUpperInvariant() == 'RELEASE')</IsRelease>
</PropertyGroup>
$([System.DateTime]::Now...) is calling into the BCL from inside an XML
attribute — a deliberately whitelisted set of "safe" types and methods,
not arbitrary reflection, but real method calls with real return values
nonetheless.
Item functions and transforms do the equivalent for lists — filter, dedupe, reshape:
->'%(Extension)' transforms each item to just its extension metadata;
->Distinct() then dedupes the resulting list — a small pipeline of list
operations, expressed inline, doing in one line what would otherwise need
a custom task.
And for logic too involved for expressions, UsingTask with
RoslynCodeTaskFactory lets a project file define a task as inline C#,
compiled on the fly the first time it's used:
<UsingTask TaskName="DoubleNumber" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(RoslynTargetsPath)\Microsoft.Build.Tasks.CodeTaskFactory.dll">
<ParameterGroup>
<Input ParameterType="System.Int32" Required="true" />
<Output ParameterType="System.Int32" Output="true" />
</ParameterGroup>
<Task>
<Code Type="Fragment" Language="cs">
Output = Input * 2;
</Code>
</Task>
</UsingTask>
Put together, these three mechanisms are why a "just a config file" format
regularly ends up hosting real conditional logic, string manipulation, and
even inline compiled code. A custom build step in MSBuild isn't only a
prebuilt task DLL loaded via UsingTask AssemblyFile=... — it can be C#
written directly in the project file, with no separate task project,
build, or publish step at all.
Incremental targets: MSBuild's own timestamp check¶
MSBuild's built-in targets (Build, Compile, etc.) don't recompile
everything on every invocation, and the mechanism is Make's, transplanted
almost unchanged: a Target can declare Inputs and Outputs, and if
every file in Outputs is newer than every file in Inputs, MSBuild
skips the target's tasks entirely and logs Skipping target "X" because
all output files are up-to-date.
<Target Name="GenerateVersionFile"
BeforeTargets="CoreCompile"
Inputs="$(MSBuildProjectFile)"
Outputs="$(IntermediateOutputPath)version.g.cs">
<WriteLinesToFile File="$(IntermediateOutputPath)version.g.cs"
Lines="// $(Version)" Overwrite="true" />
</Target>
BeforeTargets="CoreCompile" connects the custom target to the build graph.
Without that hook—or a DependsOnTargets edge or an explicit
-target:GenerateVersionFile request—declaring the target alone would not
cause it to run. With the hook in place, two builds using
dotnet build -v:detailed show the incremental decision:
Building target "GenerateVersionFile" completely.
Input file "MyApp.csproj" is newer than output file "obj/Debug/net8.0/version.g.cs".
Skipping target "GenerateVersionFile" because all output files are up-to-date
with respect to the input files.
First run: the output doesn't exist yet, so the target runs in full.
Second run, nothing touched: -v:detailed logs the exact
newer-than-comparison MSBuild made and the target is skipped entirely —
no WriteLinesToFile task execution, no timestamp change on
version.g.cs.
This is exactly the timestamp-comparison model the Make chapter describes
— with the same failure mode: a build step that writes an output whose
declared Inputs don't actually capture everything the task body reads
(an environment variable, a file the task opens without declaring it)
will go stale silently, because MSBuild only ever compares the files it
was told about. dotnet build -v:detailed surfaces the up-to-date check
MSBuild actually performed, which is the direct way to confirm whether a
suspiciously-fast build skipped real work or genuinely had nothing to do.
Batching: one target, secretly many invocations¶
Referencing an item's metadata with %() instead of @() inside a task
doesn't just read a value — it triggers batching: MSBuild groups the
item list into buckets with identical metadata values and runs the
containing task (or, if the reference is on the Target element itself,
the whole target) once per bucket, not once total.
Because %(Content.RelativeDir) differs per file, this single <Copy>
element actually executes once per distinct destination directory, each
invocation handling only the items sharing that value — not one Copy
call per file, and not one call for everything at once either. Given
three Content items — config.json at the project root, and
icon.png/banner.png both under assets/ — a verbose build log shows
this one XML element firing as two separate Copy invocations, not
three and not one:
Task "Copy"
Copying file from "config.json" to "bin/Debug/net8.0/config.json".
Done executing task "Copy".
Task "Copy"
Copying file from "assets/icon.png" to "bin/Debug/net8.0/assets/icon.png".
Copying file from "assets/banner.png" to "bin/Debug/net8.0/assets/banner.png".
Done executing task "Copy".
One bucket per distinct RelativeDir value — the empty string, and
assets\ — with both assets/ files landing in the same invocation
because they share a bucket, while the root-level file gets its own.
This is usually invisible, because it produces the result people already
expect ("preserve relative paths on copy"), but it's the mechanism behind
two recurring surprises: a task that appears to run "more than once" in a
verbose log with no corresponding loop anywhere in the XML, and a task
that's supposed to batch (see all items at once, e.g. to produce a single
combined output) but was accidentally written with %() instead of @()
and now runs once per item instead.
Building many projects at once: /m and the project graph¶
A solution isn't one MSBuild evaluation — it's many .csproj files, each
its own independent evaluation/execution pass, linked by
<ProjectReference> items that tell MSBuild which other projects need to
build first. dotnet build on a solution (or msbuild -m) builds this
project-level graph in parallel: -m (or -maxCpuCount) spins up
multiple MSBuild worker nodes, and independent projects — ones without a
ProjectReference path between them — build concurrently, each in its
own node/process.
This is a second, coarser dependency graph sitting above the
target-level graph described earlier, and the two are easy to conflate:
a ProjectReference makes the targets in A that consume B's output wait
for the required targets in B. It does not mean every part of A waits for
the whole of B: evaluation and work without that dependency may overlap,
especially in a graph build. Nor does it specify ordering within either
project's own target graph. This is also where custom tasks that rely on
process-wide static state get quietly broken —
a task that stashes something in a static field expecting a later target
in the same build to read it can end up running in a different node
process entirely once /m parallelizes across projects, with no shared
memory between them.
dotnet build vs. dotnet publish vs. MSBuild directly¶
dotnet build compiles and produces a runnable-in-place output —
sufficient for local development, dotnet run, and F5 in an IDE. dotnet
publish gathers a deployment-shaped directory: the application, its
.deps.json/.runtimeconfig.json, content files, and selected dependency
assets. By default this is framework-dependent—the destination still
needs a compatible .NET runtime:
A self-contained publish explicitly includes a runtime for one runtime identifier, making the output larger and platform-specific:
$ dotnet publish -c Release -r linux-x64 --self-contained true
# bin/Release/net8.0/linux-x64/publish/
Single-file, trimming, ReadyToRun, and native AOT are additional publish
choices, not optimizations every publish automatically applies. build
optimizes for the development loop; publish computes the output intended
for deployment. Treating those outputs as interchangeable—copying a random
bin/Release directory to production, for example—is the common mistake.
msbuild the raw executable still exists underneath dotnet build and
dotnet publish both — they're a friendlier CLI wrapper that sets the
right default properties and invokes the same engine. dotnet build
-property:Configuration=Release and msbuild -p:Configuration=Release are
doing the same underlying work; the dotnet CLI just exists so most .NET
developers never need to know MSBuild's own command-line syntax at all.
A practical debugging sequence¶
When a build is surprising, start with the cheapest observation that can answer the question:
# What value or item list did evaluation produce?
dotnet msbuild MyApp.csproj -getProperty:OutputPath
dotnet msbuild MyApp.csproj -getItem:Compile
# Which imported file assigned or defined it?
dotnet msbuild MyApp.csproj -preprocess:effective.xml
# Which targets and tasks actually ran?
dotnet build MyApp.csproj -bl:build.binlog
# Why was an incremental target run or skipped?
dotnet build MyApp.csproj -verbosity:detailed
Open build.binlog in MSBuild Structured Log Viewer and search for the
property, item, target, task, or error code. The viewer reconstructs the
evaluation and execution trees and is normally more useful than increasing
console verbosity to diagnostic, whose interleaved output becomes
difficult to follow in a parallel build.
For a clean-room comparison, remove bin/ and obj/ and rebuild—but do not
make that the permanent remedy. If cleaning fixes the build, the useful
next question is which target failed to declare or refresh the intermediate
state that made cleaning necessary.
Where people actually get burned¶
- Multi-targeting and conditional items. A library targeting both
net8.0andnetstandard2.0uses<TargetFrameworks>(plural) plus aConditionon items orPackageReferences that only apply to one target:
<PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="8.0.0"
Condition="'$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup>
Forgetting the Condition on a framework-specific package reference —
because System.Text.Json ships built into the runtime from net8.0
onward, but not on netstandard2.0 — is a build that succeeds for one
target and fails to restore, or worse, compiles with the wrong API
surface, for the other. Each TargetFramework in the list gets its own
full evaluation pass, so a Condition typo'd against the wrong TFM
string doesn't error, it just silently never matches.
- Central Package Management (
Directory.Packages.props) moving version numbers out of individual.csprojfiles is MSBuild's answer to the same problem Maven's<dependencyManagement>solves — a shared version pin across many projects:
<!-- Directory.Packages.props, at the repo root -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
<!-- some-project.csproj — no Version attribute, it comes from above -->
<PackageReference Include="Newtonsoft.Json" />
A half-migrated project that retains Version="13.0.3" on
PackageReference does not silently win: restore reports NU1008 and
tells you to move the version to PackageVersion. An intentional
exception uses VersionOverride:
Repositories can disable overrides with
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
when the central pin is meant to be absolute.
- Directory.Build.props/Directory.Build.targets are auto-imported by
every project below them in the directory tree, with no explicit
<Import> needed:
<!-- repo-root/Directory.Build.props -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Every .csproj in every subfolder underneath repo-root/ picks up
Nullable/TreatWarningsAsErrors automatically — genuinely useful for
repo-wide settings, and a common source of "why does this property have
a value I never set in this project" the first time someone encounters
one three directories up from the .csproj they're actually looking at,
since there's no <Import> line in the project file itself pointing
back to it.