MSBuild Props, Targets, Tasks and Analyzers in Packages

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.

Some packages are not libraries at all: they add MSBuild logic to the projects that reference them (properties, items, targets, custom tasks) or plug Roslyn analyzers and source generators into the compiler. This page packs two of them for Ledgerline — Ledgerline.Build, a task that checks that every journal balances at build time, and Ledgerline.Generators, a source generator — and consumes them, with .NET SDK 10.0.402 (MSBuild 18.9, NuGet 7.9.0). Writing the task itself is covered in Writing Custom Tasks.

Props and targets in packages

A package can contain <PackageId>.props and <PackageId>.targets files in three folders:

  • build/ (or build/<tfm>/) — imported into the project that references the package directly;

  • buildTransitive/ (NuGet 5.0+) — imported into that project and every project that gets the package transitively;

  • buildMultiTargeting/ — imported once into the outer build of a multi-targeted project.

.props files are imported near the top of the project (before the project’s own properties) and .targets near the bottom, through the obj/<project>.nuget.g.props and .nuget.g.targets files that restore generates. Ledgerline.Build ships the same file in build/ and buildTransitive/; the consumer’s generated file imported only the buildTransitive/ one:

<!-- samples/Ledgerline.Report/obj/Ledgerline.Report.csproj.nuget.g.targets -->
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
  <Import Project="$(NuGetPackageRoot)/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets" Condition="Exists('$(NuGetPackageRoot)/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets')" />
</ImportGroup>

A client that knows buildTransitive/ uses it; older clients fall back to build/. Shipping both is the compatible layout. Whether the logic actually flows further also depends on the consumer: PrivateAssets="all" (the default for development dependencies) stops it at the first project.

The file name must be the package ID. Other names in build/ are packed but never imported:

warning NU5129: - At least one .props file was found in 'build/', but 'build/BuildPropsName.props' was not.

What not to put in them: NuGet ignores and must not be given anything that affects restore — properties such as TargetFramework, TargetFrameworkMoniker, TargetPlatformMoniker or AssetTargetFallback, and items such as PackageReference, PackageVersion or PackageDownload. Good package targets also make every setting overridable (Condition="'$(X)' == ''"), prefix private names with _, and declare Inputs/Outputs so they stay incremental.

An MSBuild task package

A task package differs from a library: its assembly must not go to lib/ (consumers would reference it), it compiles against Microsoft.Build.* but must not ship them (MSBuild provides them), and it bundles any other dependency of its own. Ledgerline.Build:

<PropertyGroup>
  <TargetFramework>netstandard2.0</TargetFramework>
  <PackageId>Ledgerline.Build</PackageId>
  <Version>1.1.0</Version>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <DevelopmentDependency>true</DevelopmentDependency>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
  <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddTaskAssembly</TargetsForTfmSpecificContentInPackage>
  <NoWarn>$(NoWarn);NU5100;NU5128</NoWarn>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.Build.Utilities.Core" PrivateAssets="all" ExcludeAssets="runtime" />
  <PackageReference Include="Microsoft.Build.Framework" PrivateAssets="all" ExcludeAssets="runtime" />
  <None Include="buildTransitive\Ledgerline.Build.targets" Pack="true" PackagePath="build/;buildTransitive/" />
</ItemGroup>

<Target Name="_AddTaskAssembly">
  <ItemGroup>
    <TfmSpecificPackageFile Include="$(TargetPath)" PackagePath="tasks/$(TargetFramework)" />
  </ItemGroup>
</Target>
build/Ledgerline.Build.targets
buildTransitive/Ledgerline.Build.targets
tasks/netstandard2.0/Ledgerline.Build.dll

The .targets file finds the task relative to itself, so it works wherever the global-packages folder is:

<PropertyGroup>
  <LedgerlineValidateJournals Condition="'$(LedgerlineValidateJournals)' == ''">true</LedgerlineValidateJournals>
  <_LedgerlineTaskAssembly>$(MSBuildThisFileDirectory)..\tasks\netstandard2.0\Ledgerline.Build.dll</_LedgerlineTaskAssembly>
</PropertyGroup>
<UsingTask TaskName="Ledgerline.Build.ValidateLedger" AssemblyFile="$(_LedgerlineTaskAssembly)" />

A project that references it (PackageReference Include="Ledgerline.Build" PrivateAssets="all") runs the check on every build:

  Ledgerline: 4 journal lines balance.

netstandard2.0 lets one assembly run in both msbuild.exe (.NET Framework) and dotnet build (.NET). A task with dependencies that need modern .NET multi-targets instead (for example net472;net8.0) and picks the folder with $(MSBuildRuntimeType) in the .targets file. The MSBuild tutorial also bundles dependencies into the package (PrivateAssets="all" plus a TargetsForTfmSpecificBuildOutput target that adds @(ReferenceCopyLocalPaths)) and generates a .deps.json so MSBuild loads them consistently.

Analyzer and source-generator packages

Analyzers and source generators are assemblies loaded by the compiler, not by the application, so they go to analyzers/dotnet/cs/ (or /vb/, or analyzers/dotnet/ for both languages), target netstandard2.0, and ship no lib/ assembly:

<PropertyGroup>
  <TargetFramework>netstandard2.0</TargetFramework>
  <LangVersion>latest</LangVersion>
  <IsRoslynComponent>true</IsRoslynComponent>
  <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <DevelopmentDependency>true</DevelopmentDependency>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
  <None Include="$(OutputPath)$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

The first attempt failed with error CS8370: Feature 'file-scoped namespace' is not available in C# 7.3 — a netstandard2.0 project defaults to C# 7.3, so LangVersion is needed for modern syntax. The package then contained only analyzers/dotnet/cs/Ledgerline.Generators.dll and <developmentDependency>true</developmentDependency>. Adding it with the CLI wrote the private-assets form automatically, and the generated type was available:

dotnet package add Ledgerline.Generators --version 1.0.0 --project C2
dotnet run --project Consumer
<PackageReference Include="Ledgerline.Generators" Version="1.0.0">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>
generated by Ledgerline.Generators

A hand-written <PackageReference Include="Ledgerline.Generators" Version="1.0.0" /> also works, but without PrivateAssets="all" the generator flows to every consumer of that project.

NuGet’s analyzer conventions page dates from 2017: it still describes install.ps1/uninstall.ps1 scripts for packages.config and says nothing about source generators. The properties above (IsRoslynComponent, EnforceExtendedAnalyzerRules) come from the Roslyn SDK documentation.

MSBuild SDK packages

A package with PackageType MSBuildSdk and Sdk/Sdk.props and Sdk/Sdk.targets files is a project SDK, used as <Project Sdk="Name/Version"> or through msbuild-sdks in global.json (Imports and Project SDKs). Microsoft.Build.NoTargets and Microsoft.Build.Traversal are examples.

Common mistakes

  • A task or analyzer assembly in lib/ — every consumer references it.

  • Shipping Microsoft.Build.*.dll in the task package — version conflicts with the running MSBuild; use ExcludeAssets="runtime".

  • build/ only — the logic stops at the first project; ship buildTransitive/ too when it should flow.

  • Hard-coded paths — use $(MSBuildThisFileDirectory).

  • Unconditional properties — consumers can’t override them; use Condition="'$(X)' == ''".