Writing Custom Tasks
|
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. |
When build logic outgrows Exec and inline code, it becomes a task assembly: a class library whose classes
implement ITask, compiled once, tested like any other code, and shipped in a NuGet package together with the
.targets file that calls it. Ledgerline does exactly this: the Ledgerline.Build package adds a ValidateLedger
task that fails any build in which a *.ledger.csv journal does not balance. This page walks through that task with
MSBuild 18.9 (.NET SDK 10.0.402) and the Microsoft.Build.Utilities.Core 18.9.6 package; the packaging side is
detailed on
Props, Targets, Tasks and Analyzers in Packages.
Anatomy of a task
A task is a public class that implements Microsoft.Build.Framework.ITask (an Execute() method plus the
BuildEngine and HostObject properties). In practice it derives from Microsoft.Build.Utilities.Task, which
implements the plumbing and provides Log, a TaskLoggingHelper. MSBuild creates an instance for each call, sets the
properties named by the element’s attributes, calls Execute(), reads the [Output] properties and discards the
instance.
| Member | Role |
|---|---|
public settable properties |
Parameters. |
|
MSBuild fails the call if the parameter is not set. |
|
Readable with |
|
Returns |
|
Diagnostics that reach every logger, with code, file, line and column. |
The Ledgerline.Build project
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>true</IsPackable>
<PackageId>Ledgerline.Build</PackageId>
<Version>1.1.0</Version>
<Description>MSBuild task that checks that every *.ledger.csv journal in a project balances, at build time.</Description>
<PackageTags>ledger;msbuild;build;validation</PackageTags>
<!-- An MSBuild task package: no lib/ folder, the task assembly goes to tasks/. -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
<BuildOutputTargetFolder>tasks</BuildOutputTargetFolder>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddTaskAssembly</TargetsForTfmSpecificContentInPackage>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<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" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Ledgerline\JournalLine.cs;..\Ledgerline\LedgerReader.cs;..\Ledgerline\Ledger.cs;..\Ledgerline\IsExternalInit.cs" LinkBase="Shared" />
<None Include="buildTransitive\Ledgerline.Build.targets" Pack="true" PackagePath="build/;buildTransitive/" />
</ItemGroup>
<Target Name="_AddTaskAssembly">
<ItemGroup>
<TfmSpecificPackageFile Include="$(TargetPath)" PackagePath="tasks/$(TargetFramework)" />
</ItemGroup>
</Target>
</Project>
The project decisions that matter for a task:
-
netstandard2.0— the one target that loads both inmsbuild.exe(.NET Framework 4.7.2+) and in the .NET MSBuild thatdotnet builduses. Anet10.0task would load only in the latter (or, in Visual Studio 2026, through the .NET task host, see below). -
Microsoft.Build.Utilities.CoreandMicrosoft.Build.FrameworkwithPrivateAssets="all"andExcludeAssets="runtime"— compile against them, but never copy or ship them: MSBuild provides its own copies at run time, and a second copy causes type-identity conflicts. -
No
lib/folder (IncludeBuildOutputfalse) and the DLL packed intotasks/netstandard2.0/by the_AddTaskAssemblytarget, so consumers do not get a compile reference to a build-time assembly. -
DevelopmentDependency— consumers getPrivateAssets="all"automatically, so the task does not flow to their own packages. -
SuppressDependenciesWhenPacking— the package needs no dependencies at run time. -
The library’s source files are compiled into the task assembly (
LinkBase="Shared"), so the task has no dependency on theLedgerlinepackage — loading package dependencies inside MSBuild is fragile.
The task:
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
namespace Ledgerline.Build;
/// <summary>An MSBuild task that fails the build when a ledger journal does not balance.</summary>
/// <remarks>
/// The task uses no process-wide state (it reads absolute paths from the FullPath metadata and never touches the
/// current directory or environment variables), so it can run in-process in a multithreaded build (MSBuild 18.6+, -mt).
/// </remarks>
[MSBuildMultiThreadableTask]
public sealed class ValidateLedger : Microsoft.Build.Utilities.Task
{
/// <summary>Gets or sets the journal files to check.</summary>
[Required]
public ITaskItem[] Files { get; set; } = Array.Empty<ITaskItem>();
/// <summary>Gets the number of journal lines read across all files.</summary>
[Output]
public int LineCount { get; private set; }
/// <inheritdoc />
public override bool Execute()
{
foreach (var file in Files)
{
var path = file.GetMetadata("FullPath");
IReadOnlyList<JournalLine> lines;
try
{
lines = LedgerReader.ReadFile(path);
}
catch (FormatException ex)
{
Log.LogError(null, "LL0001", null, path, 0, 0, 0, 0, "Malformed journal: {0}", ex.Message);
continue;
}
LineCount += lines.Count;
foreach (var entry in Ledger.FindUnbalancedEntries(lines))
{
Log.LogError(null, "LL0002", null, path, 0, 0, 0, 0, "Entry '{0}' does not balance.", entry);
}
Log.LogMessage(MessageImportance.Normal, "Checked {0} lines in {1}.", lines.Count, file.ItemSpec);
}
return !Log.HasLoggedErrors;
}
}
Microsoft.Build.Utilities.Task is written in full because Task alone is ambiguous with
System.Threading.Tasks.Task under implicit usings — the first build of this project failed with
error CS0104: 'Task' is an ambiguous reference between 'Microsoft.Build.Utilities.Task' and 'System.Threading.Tasks.Task'.
Registering and calling it
The package’s buildTransitive/Ledgerline.Build.targets declares the task with UsingTask and calls it before the
compiler, incrementally:
<Project>
<PropertyGroup>
<LedgerlineValidateJournals Condition="'$(LedgerlineValidateJournals)' == ''">true</LedgerlineValidateJournals>
<_LedgerlineTaskAssembly>$(MSBuildThisFileDirectory)..\tasks\netstandard2.0\Ledgerline.Build.dll</_LedgerlineTaskAssembly>
</PropertyGroup>
<UsingTask TaskName="Ledgerline.Build.ValidateLedger" AssemblyFile="$(_LedgerlineTaskAssembly)" />
<ItemGroup>
<LedgerJournal Include="**/*.ledger.csv" Exclude="$(DefaultItemExcludes)" />
</ItemGroup>
<Target Name="LedgerlineValidateJournals"
BeforeTargets="CoreCompile"
Condition="'$(LedgerlineValidateJournals)' == 'true' and '@(LedgerJournal)' != ''"
Inputs="@(LedgerJournal)"
Outputs="$(IntermediateOutputPath)ledgerline.validated">
<ValidateLedger Files="@(LedgerJournal)">
<Output TaskParameter="LineCount" PropertyName="LedgerlineLineCount" />
</ValidateLedger>
<Message Importance="high" Text="Ledgerline: $(LedgerlineLineCount) journal lines balance." />
<Touch Files="$(IntermediateOutputPath)ledgerline.validated" AlwaysCreate="true" />
<ItemGroup>
<FileWrites Include="$(IntermediateOutputPath)ledgerline.validated" />
</ItemGroup>
</Target>
</Project>
UsingTask maps the element name to a class (TaskName is the full name; the element can use the short one) in an
assembly located with AssemblyFile (a path, here relative to the .targets file through
$(MSBuildThisFileDirectory)) or AssemblyName. The $(LedgerlineValidateJournals) property is the documented way to
let consumers switch the feature off.
Source: MSBuild: Register tasks, UsingTask element.
Errors in the canonical format
Log.LogError(subcategory, code, helpKeyword, file, line, column, endLine, endColumn, message, args) produces a
diagnostic in the format MSBuild, IDEs and CI annotations understand: origin(line,col): category code: text. A journal
with an unbalanced entry added to the consumer app:
date,entry,account,debit,credit,memo
2026-03-01,E9,1000-Cash,99.00,,Sale
2026-03-01,E9,4000-Sales,,90.00,Sale
dotnet build -tl:off -v:m
~/ledgerline/samples/Ledgerline.Report/march.ledger.csv : error LL0002: Entry 'E9' does not balance. [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]
Build FAILED.
The origin is the journal file, so an IDE opens it on double-click; passing a line number instead of 0 would add
(3,1). Use your own code prefix (LL) rather than MSB, and keep codes stable so consumers can suppress them with
NoWarn or WarningsAsErrors.
Three failures every task author meets
An exception escapes Execute() — MSB4018 with the stack trace. A journal item pointing to a file that does
not exist (the task catches FormatException, not I/O exceptions):
~/.nuget/packages/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets(18,5): error MSB4018: The "ValidateLedger" task failed unexpectedly. [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]
~/.nuget/packages/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets(18,5): error MSB4018: System.IO.DirectoryNotFoundException: Could not find a part of the path '~/ledgerline/samples/Ledgerline.Report/archive/2025.ledger.csv'. [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]
~/.nuget/packages/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets(18,5): error MSB4018: at Interop.ThrowExceptionForIoErrno(ErrorInfo errorInfo, String path, Boolean isDirError) [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]
…
~/.nuget/packages/ledgerline.build/1.1.0/buildTransitive/Ledgerline.Build.targets(18,5): error MSB4018: at Ledgerline.LedgerReader.ReadFile(String path) [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]
Catch expected failures and report them with Log.LogError (or LogErrorFromException) and a code; let only bugs
surface as MSB4018.
The assembly cannot be loaded — MSB4062, typically a wrong AssemblyFile path or a missing dependency:
<UsingTask TaskName="Ledgerline.Build.ValidateLedger" AssemblyFile="tasks/netstandard2.0/Ledgerline.Build.dll" />
~/ledgerline/errs/load.proj(4,5): error MSB4062: The "Ledgerline.Build.ValidateLedger" task could not be loaded from the assembly ~/ledgerline/errs/tasks/netstandard2.0/Ledgerline.Build.dll. Could not load file or assembly '~/ledgerline/errs/tasks/netstandard2.0/Ledgerline.Build.dll'. The system cannot find the file specified.
~/ledgerline/errs/load.proj(4,5): error MSB4062: Confirm that the <UsingTask> declaration is correct, that the assembly and all its dependencies are available, and that the task contains a public class that implements Microsoft.Build.Framework.ITask.
false without an error — MSB4181:
public override bool Execute()
{
Log.LogMessage(MessageImportance.High, "Checking the ledger...");
return false; // failed, but no error was logged
}
Checking the ledger...
~/ledgerline/errs/false.proj(14,5): error MSB4181: The "SilentFailure" task returned false but did not log an error.
return !Log.HasLoggedErrors; avoids both mistakes at once.
Packaging the task
Packed with dotnet pack, the package has no lib/ folder, the task under tasks/ and the targets file twice:
907 2026-10-23 18:30 Ledgerline.Build.nuspec
297 2026-10-23 17:20 README.md
680 2026-10-23 17:20 icon.png
1248 2026-10-23 17:21 build/Ledgerline.Build.targets
1248 2026-10-23 17:21 buildTransitive/Ledgerline.Build.targets
15360 2026-10-23 18:30 tasks/netstandard2.0/Ledgerline.Build.dll
build/<PackageId>.targets is imported into projects that reference the package directly; buildTransitive/ also
into projects that get it transitively. When both exist, NuGet uses buildTransitive/ (the nuget.g.targets of
Ledgerline.Report imports buildTransitive/Ledgerline.Build.targets). The file must be named after the package ID, or
pack warns:
<sdk>/NuGet.Build.Tasks.Pack.targets(226,5): error NU5129: Warning As Error: - At least one .targets file was found in 'build/', but 'build/Ledgerline.Build.targets' was not.
That warning appeared during this work for an unexpected reason: the first version of the project wrote
PackagePath="build\", and on Linux the backslash produced an entry named build//Ledgerline.Build.targets. Use
forward slashes in PackagePath. Changing the task’s behaviour also means a new version (here 1.1.0): a package
version that is already in the global-packages folder is never extracted again, so republishing 1.0.0 with new content
would leave every consumer on the old DLL.
.NET tasks in msbuild.exe: the .NET task host
msbuild.exe and Visual Studio run on .NET Framework, so before .NET 10 a task built only for .NET could not load
there. Since .NET 10 and Visual Studio 2026, a task can declare that it targets .NET and be run in a .NET task host
process. Not run here (it needs msbuild.exe):
<UsingTask TaskName="MyTask"
AssemblyFile="path\to\MyTask.dll"
Runtime="NET"
TaskFactory="TaskHostFactory" />
TaskHostFactory always runs the task out of process, which costs inter-process communication. The documentation’s
refinement declares the task twice: with the task host when $(MSBuildRuntimeType) is Full (msbuild.exe), and in
process when it is Core (dotnet build). Ledgerline.Build keeps netstandard2.0 instead, which loads in-process
in both and in older Visual Studio versions.
Multithreaded tasks (MSBuild 18.6+)
With -mt (-multiThreaded, MSBuild 18.6 and later), MSBuild builds several projects in parallel inside one
process instead of one process per node. Tasks then share the process’s current directory and environment
variables, so MSBuild runs in-process only tasks that declare themselves safe; others go to a separate task-host process,
which works but is slower.
-
Mark the class with
[MSBuildMultiThreadableTask](fromMicrosoft.Build.Framework). This is enough when the task uses no process-wide state — asValidateLedger, which works only with absolute paths fromFullPathmetadata. -
If the task needs the project directory or environment variables, also implement
IMultiThreadableTaskand use itsTaskEnvironmentproperty (GetAbsolutePath,GetEnvironmentVariable,SetEnvironmentVariable,ProjectDirectory) instead ofEnvironmentand relative paths.AbsolutePathis a validated path struct that converts tostring. Initialise the property toTaskEnvironment.Fallback, so the task also works outside MSBuild (unit tests). -
MSBuild recognises the attribute by name only, so a task that must load in MSBuild versions older than 18.6 can declare its own copy of the attribute and guard every
TaskEnvironmentuse.
The Ledgerline consumer builds the same way with and without -mt:
dotnet build -tl:off -v:m -mt --no-incremental
Ledgerline: 4 journal lines balance.
Build succeeded.
Common mistakes
-
Shipping
Microsoft.Build*.dllwith the task — usePrivateAssets="all"andExcludeAssets="runtime". -
A task that targets only
net10.0— does not load inmsbuild.exebefore Visual Studio 2026; prefernetstandard2.0. -
Task dependencies on other packages — MSBuild does not resolve them; embed or avoid them.
-
Relative paths and
Environment.CurrentDirectory— wrong under-mtand in Visual Studio; useFullPathmetadata orTaskEnvironment. -
Republishing the same package version with a changed task — consumers keep the cached copy.
-
Visual Studio keeps task assemblies loaded — a rebuilt task is picked up only after restarting it (or building with
-nodeReuse:falsefrom the command line).