Customizing the Build

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 offers more than ten places to put build logic, from a file next to one project to a file that affects every build on a machine. They differ in scope (one project, a folder tree, a solution, a user, a machine), in timing (before or after the project body) and in who sees them (command line only, or Visual Studio too). Choosing the narrowest one that works keeps builds predictable. This page shows each on Ledgerline with MSBuild 18.9 (.NET SDK 10.0.402); the repository-root files as a whole are described on Repository-Wide Configuration.

The extension points

File or property Scope Imported IDE

Directory.Build.props

projects in the folder and below (nearest file only)

early, from Microsoft.Common.props

yes

Directory.Build.targets

same

late, from Microsoft.Common.targets, after package targets

yes

<project>.csproj.user

one project, one developer (not committed)

by the common targets, in inner and outer builds

yes

Directory.Solution.props / .targets, before.<sln>.sln.targets / after.<sln>.sln.targets

command-line solution builds

into the solution metaproject

no

Directory.Build.rsp, MSBuild.rsp

command-line builds under the folder / every msbuild.exe build

as switches, before evaluation

no

CustomBeforeMicrosoftCommonProps/Targets, CustomAfterMicrosoftCommonProps/Targets (and the CSharp variants)

whatever sets the property (a build server, a -p: switch)

before/after Microsoft.Common.props/.targets

if set

Custom.Before.Microsoft.Common.Targets (etc.) in the MSBuild folder; $(MSBuildUserExtensionsPath)/…/ImportBefore/*

every build by that MSBuild installation / user

at those points

yes

Directory.Build.props and .targets in depth

The search starts in the project’s folder and walks up to the root, stopping at the first file found (case-sensitive on Linux and macOS). Three consequences:

  • A nested file replaces the outer one for its subtree unless it imports it with $([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../')) (multi-level merging, shown on Repository-Wide Configuration).

  • An empty file stops the search — the way to keep a subtree (vendored code, a tool with its own conventions) out of the repository defaults:

    <Project>
      <!-- Empty: stops the upward search, so this subtree ignores the repository defaults. -->
    </Project>
    cd tools/legacy
    dotnet msbuild -getProperty:Nullable -getProperty:TreatWarningsAsErrors -getProperty:DirectoryBuildPropsPath
    {
      "Properties": {
        "Nullable": "",
        "TreatWarningsAsErrors": "false",
        "DirectoryBuildPropsPath": "~/ledgerline/tools/legacy/Directory.Build.props"
      }
    }
  • The search also leaves the repository: a Directory.Build.props in a parent folder of a CI agent’s work directory takes part in every build below it. A root-level file (even an empty one) protects against that, which the MSBuild security guidance recommends.

ImportDirectoryBuildProps/ImportDirectoryBuildTargets=false turn the import off; DirectoryBuildPropsPath and DirectoryBuildTargetsPath show which file was used, or point to another one. For troubleshooting, -pp shows where the file was imported and -getProperty:DirectoryBuildPropsPath which one.

Why TargetFramework is unreliable in Directory.Build.props

Directory.Build.props is read before the project body. Whether it sees TargetFramework depends on where the value comes from. To show it, Ledgerline's Directory.Build.props was given <TfmSeenInProps>[$(TargetFramework)]</TfmSeenInProps>, its Directory.Build.targets the same line as TfmSeenInTargets plus a target that prints both before CoreCompile (and the .user file of the next section). Building the CLI tool, which targets net10.0 and references the three-TFM library:

dotnet build src/Ledgerline.Cli -tl:off -v:m | grep "props saw"
cd src/Ledgerline
dotnet msbuild -getProperty:TfmSeenInProps -getProperty:TfmSeenInTargets
  props saw [net10.0], targets saw [net10.0], user file says hello from Ledgerline.csproj.user
  props saw [], targets saw [net10.0], user file says
{
  "Properties": {
    "TfmSeenInProps": "[]",
    "TfmSeenInTargets": "[]"
  }
}
  • The first line is the referenced library’s inner build: the outer build passes TargetFramework as a global property, which exists before any file is read, so Directory.Build.props sees it.

  • The second line is the single-target CLI project: TargetFramework is set in its project body, after Directory.Build.props — so the props file sees nothing, the targets file sees net10.0.

  • The JSON is the library’s outer build, where TargetFramework is empty everywhere.

A condition on $(TargetFramework) in Directory.Build.props therefore works in some projects and silently fails in others. Put it in Directory.Build.targets or in the project file.

The .user file

Ledgerline.csproj.user, next to the project, is imported automatically (in inner and outer builds) and is meant for one developer’s machine — the default .gitignore excludes it. The demonstration above set a property in it:

<Project>
  <PropertyGroup>
    <LedgerlineLocalNote>hello from Ledgerline.csproj.user</LedgerlineLocalNote>
  </PropertyGroup>
</Project>

Its value appears only in the library’s lines; the CLI project has no .user file, hence the empty user file says. Visual Studio stores per-user settings (start-up arguments, the selected debug profile) in the same file, so keep manual additions small.

Custom before/after files

For logic that must apply to every project on a build machine without touching repositories, the common targets import the files named by CustomBeforeMicrosoftCommonProps, CustomBeforeMicrosoftCommonTargets, CustomAfterMicrosoftCommonProps and CustomAfterMicrosoftCommonTargets (plus language-specific CustomBefore/AfterMicrosoftCSharpTargets). The property can be set on the command line:

<Project>
  <Target Name="MachineWideHook" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
    <Message Importance="high" Text="CustomAfterMicrosoftCommonTargets: built $(MSBuildProjectName) for $(TargetFramework)" />
  </Target>
</Project>
dotnet build src/Ledgerline -tl:off -v:m -p:CustomAfterMicrosoftCommonTargets=$PWD/../machine/Custom.After.Microsoft.Common.targets
  props saw [net8.0], targets saw [net8.0], user file says hello from Ledgerline.csproj.user
  props saw [netstandard2.0], targets saw [netstandard2.0], user file says hello from Ledgerline.csproj.user
  CustomAfterMicrosoftCommonTargets: built Ledgerline for net8.0
  CustomAfterMicrosoftCommonTargets: built Ledgerline for netstandard2.0
  props saw [net10.0], targets saw [net10.0], user file says hello from Ledgerline.csproj.user
  CustomAfterMicrosoftCommonTargets: built Ledgerline for net10.0

Without the property, files with the default names in the MSBuild folder (Custom.After.Microsoft.Common.targets and so on) are used, and per-user files can be dropped into $(MSBuildUserExtensionsPath) (%LOCALAPPDATA%\Microsoft\MSBuild on Windows) under …/ImportBefore/ or …/ImportAfter/. The documentation warns that a broken machine-wide file can stop Visual Studio from building anything; ImportUserLocationsByWildcardBefore<File> properties switch the user locations off. AlternateCommonProps replaces Microsoft.Common.props entirely.

Response files

MSBuild.rsp next to MSBuild.exe (none ships in the .NET SDK) and Directory.Build.rsp in the project folder or a parent (MSBuild 15.6+) add switches to command-line builds; %MSBuildThisFileDirectory% (with percent signs on every OS) refers to the .rsp file’s folder. Visual Studio ignores both. -noAutoResponse disables them; explicit @file response files still apply. Example and output are on Repository-Wide Configuration.

Solution-level files

Directory.Solution.props/.targets and the before/after.<solution>.sln.targets files extend the in-memory solution metaproject of command-line builds only; they do not reach the projects and are ignored by Visual Studio. See Solutions and .slnx.

Props or targets: the rule

  • Directory.Build.props — defaults that projects and the SDK read: language version, Nullable, TreatWarningsAsErrors, package metadata, ArtifactsPath. A project can override them.

  • Directory.Build.targets — targets, item changes, and properties that depend on project values (TargetFramework, IsPackable, OutputType, anything set by a package). They win over the project.

Common mistakes

  • A nested Directory.Build.props that forgets the parent — the repository defaults disappear for that subtree.

  • TFM-dependent logic in Directory.Build.props — see above.

  • Build-changing switches in Directory.Build.rsp — the IDE builds differently.

  • Machine-wide Custom.After… files on developer machines — invisible, surprising, hard to debug; use them only on build servers, and prefer the property form.

  • Committing .user files — they are per developer.