MSBuild Props, Targets, Tasks and Analyzers in Packages
|
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. |
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/(orbuild/<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.
Source: Known package types.
Common mistakes
-
A task or analyzer assembly in
lib/— every consumer references it. -
Shipping
Microsoft.Build.*.dllin the task package — version conflicts with the running MSBuild; useExcludeAssets="runtime". -
build/only — the logic stops at the first project; shipbuildTransitive/too when it should flow. -
Hard-coded paths — use
$(MSBuildThisFileDirectory). -
Unconditional properties — consumers can’t override them; use
Condition="'$(X)' == ''".