Shipping a cross-platform MSBuild task in a NuGet package
MSBuild allows users to write and register their own tasks. Tasks, unlike targets, can be written in C# and can perform build operations that would be impossible to write in MSBuild’s XML dialect. In this post, I’m going walk through the key pieces of how to write an MSBuild task that works on both the .NET Core command line and in Visual Studio, and then how to bundle that task into a NuGet package so the task can be shared and installed automatically into projects.
TL;DR sample code for a cross-platform MSBuild task that installs via NuGet is available here: https://github.com/natemcmaster/msbuild-tasks.
Primer
If you are not clear on terms such as tasks, targets, properties, or runtimes, I’d recommend you first check out the dozens of docs and other blogs that explain these concepts. I’d recommend starting with the MSBuild Concepts article.
Foundations: MSBuild.exe vs ‘dotnet msbuild’
Before we go too far, you must first understand the different between “full” MSBuild (the one that powers Visual Studio) and “portable” MSBuild, or the one bundled in the .NET Core Command Line.
Full MSBuild
This version of MSBuild usually lives inside Visual Studio.
e.g. C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\Bin\MSBuild.exe
.
It also ships in Mono 5 on macOS and Linux.
Characteristics:
- Runs on .NET Framework.
- Visual Studio uses this when you execute “Build” on your solution or project.
- Typically only available on Windows, but Mono 5 ships MSBuild now too.
- On Windows, it supports the widest range of project types.
- On Mono, it supports a subset of project types and doesn’t have NuGet 4 (yet).
dotnet msbuild
This version of MSBuild is bundled in the .NET Core Command Line.
e.g. C:\Program Files\dotnet\sdk\1.0.4\MSBuild.dll
. This version runs when
you execute dotnet restore
, dotnet build
, or dotnet test
. These are just command-line sugar
for dotnet msbuild /target:Restore
, dotnet msbuild /clp:Summary
, and dotnet msbuild /target:VSTest
.
Characteristics:
- Runs on .NET Core.
- Available on Windows, macOS, and Linux.
- Visual Studio does not directly invoke this version of MSBuild.
- Currently only supports projects that build using Microsoft.NET.Sdk.
Step 1 - write the task
An MSBuild task can be implemented in C#. MSBuild can load and run any public class that implements
Microsoft.Build.Framework.ITask
. You can compile against this API by referencing the NuGet package
Microsoft.Build.Framework.
There are also helpful abstract classes available by referencing Microsoft.Build.Utilities.Core.
<!-- GreetingTasks.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard1.6;net46</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Framework" Version="15.1.1012" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="15.1.1012" />
</ItemGroup>
</Project>
In this example, I’m using Microsoft.Build.Utilities.Task
to implement ITask
. This base class
provides an API for accessing the MSBuild logger.
// SayHello.cs
using Microsoft.Build.Framework;
namespace MSBuildTasks
{
public class SayHello : Microsoft.Build.Utilities.Task
{
public override bool Execute()
{
Log.LogMessage(MessageImportance.High, "Aloha");
return true;
}
}
}
You can compile this project with two commands:
dotnet restore GreetingTasks.csproj
dotnet build GreetingTasks.csproj
Step 2 - use the task
The assembly compiled in step 1 contains the SayHello
task. To use this in MSBuild,
you must first explicitly register the task by name, then invoke the task from a target.
Create a new project file test.proj
in your project folder with the contents below. The UsingTask
line registers the task with MSBuild, and the target Build
invokes the
SayHello
task.
<!-- test.proj -->
<Project DefaultTargets="Build">
<UsingTask TaskName="MSBuildTasks.SayHello" AssemblyFile=".\bin\Debug\netstandard1.6\GreetingTasks.dll" />
<Target Name="Build">
<SayHello />
</Target>
</Project>
Pro-tip: if you haven’t noticed already, I always use
\
as directory separators. MSBuild will normalize these on Linux and macOS to/
.
On command line, execute this command (notice it is “msbuild”, not “build”.)
dotnet msbuild test.proj
This should display a console message, “Aloha”.
Step 3 - vary the task assembly based on MSBuild runtime type
The problem
As explained above in Primer, MSBuild can run on
.NET Framework or .NET Core. In step 2, we used dotnet msbuild
and the netstandard1.6 task assembly.
But this won’t work if we use MSBuild.exe
.
To see this blow up for yourself, using the code from step 2 and try this:
- open the Developer Command Prompt for VS 2017 or (add MSBuild.exe to your path)
- execute
MSBuild.exe test.proj
error MSB4018: The “SayHello” task failed unexpectedly. System.IO.FileNotFoundException: Could not load file or assembly ‘System.Runtime, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a’ or one of its dependencies. The system cannot find the file specified. File name: ‘System.Runtime, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a’ at MSBuildTasks.SayHello.Execute()
The problem, of course, is that bin/Debug/netstandard1.6/GreetingTasks.dll
is compiled for
.NET Standard 1.6. MSBuild.exe runs on .NET Framework, which is not compatible with .NET Standard 1.6.
To fix this error, you could change the UsingTask
line to use the net46 assembly instead.
<UsingTask TaskName="MSBuildTasks.SayHello" AssemblyFile=".\bin\Debug\net46\GreetingTasks.dll" />
This would make MSBuild.exe work, but then the reverse problem happens with dotnet msbuild
.
The solution
You can vary which task assembly loads based on MSBuild’s runtime type using the pre-defined property MSBuildRuntimeType
.
In dotnet msbuild
, its value will be Core
.
In MSBuild.exe
, it will be Full
.
In msbuild
in Mono 5, it will be Mono
.
(Old versions of MSBuild may not set this property, so you can’t count on “Full” to be specified.)
Here is one way to use that property to vary the assembly path:
<!-- test.proj -->
<Project DefaultTargets="Build">
<PropertyGroup>
<TaskAssembly Condition=" '$(MSBuildRuntimeType)' == 'Core'">.\bin\Debug\netstandard1.6\GreetingTasks.dll</TaskAssembly>
<TaskAssembly Condition=" '$(MSBuildRuntimeType)' != 'Core'">.\bin\Debug\net46\GreetingTasks.dll</TaskAssembly>
</PropertyGroup>
<UsingTask TaskName="MSBuildTasks.SayHello" AssemblyFile="$(TaskAssembly)" />
<Target Name="Build">
<SayHello />
</Target>
</Project>
Now, both dotnet msbuild test.proj
and MSBuild.exe test.proj
will work.
Step 4 - shipping your task in a NuGet package
Package layout
Our final sample NuGet package will have the following layout:
- GreetingTasks.nupkg
- build/
+ GreetingTasks.props
- buildMultiTargeting/
+ GreetingTasks.props
- tasks/
- netstandard1.6/
+ GreetingTasks.dll
- net46/
+ GreetingTasks.dll
NuGet will automatically import the build/(package id).props
file is imported into projects when
the project has a single TargetFramework
. It will import buildMultiTargeting/(package id).props
when the project has multiple TargetFrameworks
. (FYI - NuGet will also import build/(package id).targets
and buildMultiTargeting/(package id).targets
. .props
files are imported near the top of the file and
.targets
files near the bottom.)
Also, we’ve put the assemblies in tasks/
instead of lib/
. If there were in lib/
, NuGet would
automatically add a compile-time reference to these assemblies. We don’t really want developers
writing code that depends on our task, so we’ll hide these files in tasks/
which is a non-standard
NuGet folder.
The MSBuild files
The contents of build/GreetingTasks.props
will look similar to the code we added in test.proj
.
<!-- build/GreetingTasks.props -->
<Project TreatAsLocalProperty="TaskFolder;TaskAssembly">
<PropertyGroup>
<TaskFolder Condition=" '$(MSBuildRuntimeType)' == 'Core' ">netstandard1.6</TaskFolder>
<TaskFolder Condition=" '$(MSBuildRuntimeType)' != 'Core' ">net46</TaskFolder>
<TaskAssembly>$(MSBuildThisFileDirectory)..\tasks\$(TaskFolder)\GreetingTasks.dll</TaskAssembly>
</PropertyGroup>
<UsingTask TaskName="MSBuildTasks.SayHello" AssemblyFile="$(TaskAssembly)" />
</Project>
Pro-tip: it’s good to use
TreatAsLocalProperty
if you using common names likeTaskFolder
. It isolates your file from the rest of the project’s settings.
To avoid duplicating content, the buildMultiTargeting/GreetingTasks.props
only needs to contain an
Import.
<!-- buildMultiTargeting/GreetingTasks.props -->
<Project>
<Import Project="..\build\GreetingTasks.props" />
</Project>
csproj
To acheive this layout using the csproj, we will change our GreetingTasks.csproj
to look like this.
<!-- GreetingTasks.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard1.6;net46</TargetFrameworks>
<!-- Suppresses the warnings about the package not having assemblies in lib/*/.dll.-->
<NoPackageAnalysis>true</NoPackageAnalysis>
<!-- Change the default location where NuGet will put the build output -->
<BuildOutputTargetFolder>tasks</BuildOutputTargetFolder>
</PropertyGroup>
<ItemGroup>
<!-- pack the props files -->
<Content Include="build\GreetingTasks.props" PackagePath="build\" />
<Content Include="buildMultiTargeting\GreetingTasks.props" PackagePath="buildMultiTargeting\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Framework" Version="15.1.1012" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="15.1.1012" />
<!-- marks all packages as 'local only' so they don't end up in the nuspec -->
<PackageReference Update="@(PackageReference)" PrivateAssets="All" />
</ItemGroup>
</Project>
Pro-tip: set
PrivateAssets="All"
on the PackageReferences. Otherwise, these will be added to your package’s list of dependencies. These packages are not required when your task is installed; they are only used when you compile your project.
Step 5 - pack
To build and package the target, you can use dotnet pack
or MSBuild /t:Pack
.
dotnet pack GreetingTasks.csproj --output ./ --configuration Release
Step 6 - install
Now that you have a *.nupkg file, you can upload it NuGet.org or your own feed. Users can install this task as a package reference.
<ItemGroup>
<PackageReference Include="GreetingTasks" Version="1.0.0" />
</ItemGroup>
What happens when you install
When a user executes NuGet restore, it will download and extract the package to the global NuGet cache.
%USERPROFILE%\.nuget\packages\GreetingTasks\1.0.0\
It will also generate a file in obj/$(MSBuildProject).nuget.g.props
which is automatically included
in your csproj. This file will contain this line:
<Import Project="$(NuGetPackageRoot)greetingtasks/1.0.0/build/GreetingTasks.props" Condition="Exists('$(NuGetPackageRoot)greetingtasks/1.0.0/build/GreetingTasks.props')" />
When a user loads the project, your task will automatically load your *.props files from the NuGet cache.
Next steps - adding targets
All you’ve done so far is add a task to the project, but a user still has to use it. Or, if you know where you want your task to execute, you can add a targets file to your project too.
As discussed above, NuGet will automatically import MSBuild files from build/GreetingTasks.props
.
It will also import build/GreetingTasks.targets
and buildMultiTargeting/GreetingTasks.targets
.
By MSBuild convention, it is best to put targets in *.targets files, and to put properties and
UsingTask
calls in *.props files.
- GreetingTasks.nupkg
- build/
+ GreetingTasks.targets
You can add your own targets to this file.
<!-- build/GreetingTasks.targets -->
<Project>
<!-- this will automatically run after the 'Build' target -->
<Target Name="RunMyGreeting" AfterTargets="Build">
<SayHello />
</Target>
</Project>
Something you should know
This approach has limitations. If you need an external dependency from your task, like Newtonsoft.Json, you may run into issues due to MSBuild’s limited capability to load them.
Read more about this here: MSBuild tasks with dependencies.
Closing
I’ve posted the fully-working sample as a GitHub project here: https://github.com/natemcmaster/msbuild-tasks.
If you want to see a “real world” example of a project that uses this approach, checkout the following projects:
- Yarn.MSBuild
- BuildBundlerMinifier, specifically these files.