Writing Custom Tasks

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.

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. string, bool, ITaskItem and ITaskItem[] are native; other types (int, DateTime, arrays) are converted from the string with ChangeType, and a failed conversion is an error before Execute is called.

[Required]

MSBuild fails the call if the parameter is not set.

[Output]

Readable with <Output TaskParameter="…" />.

Execute()

Returns true on success. Returning false without logging an error is itself an error (MSB4181).

Log.LogError, LogWarning, LogMessage, LogErrorFromException, HasLoggedErrors

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 in msbuild.exe (.NET Framework 4.7.2+) and in the .NET MSBuild that dotnet build uses. A net10.0 task would load only in the latter (or, in Visual Studio 2026, through the .NET task host, see below).

  • Microsoft.Build.Utilities.Core and Microsoft.Build.Framework with PrivateAssets="all" and ExcludeAssets="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 (IncludeBuildOutput false) and the DLL packed into tasks/netstandard2.0/ by the _AddTaskAssembly target, so consumers do not get a compile reference to a build-time assembly.

  • DevelopmentDependency — consumers get PrivateAssets="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 the Ledgerline package — 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.

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.

Source: MSB4018, MSB4062, MSB4181.

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] (from Microsoft.Build.Framework). This is enough when the task uses no process-wide state — as ValidateLedger, which works only with absolute paths from FullPath metadata.

  • If the task needs the project directory or environment variables, also implement IMultiThreadableTask and use its TaskEnvironment property (GetAbsolutePath, GetEnvironmentVariable, SetEnvironmentVariable, ProjectDirectory) instead of Environment and relative paths. AbsolutePath is a validated path struct that converts to string. Initialise the property to TaskEnvironment.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 TaskEnvironment use.

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*.dll with the task — use PrivateAssets="all" and ExcludeAssets="runtime".

  • A task that targets only net10.0 — does not load in msbuild.exe before Visual Studio 2026; prefer netstandard2.0.

  • Task dependencies on other packages — MSBuild does not resolve them; embed or avoid them.

  • Relative paths and Environment.CurrentDirectory — wrong under -mt and in Visual Studio; use FullPath metadata or TaskEnvironment.

  • 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:false from the command line).