Advanced MSBuild: Introduction

In my current project I solved some business and technical requirements with MSBuild rather than C# code a couple of times.
Additionally, I employed MSBuild-based techniques to make life in a big repo less repetitive and more consistent.

The above involved among others:

MSBuild crash course

To follow the posts in this series, it is necessary to understand a few core concepts of MSBuild.
MSBuild is essentially a declarative programming language with horrible syntax.

Fortunately, it is well-documented these days.
That is why I will only briefly touch the most important concepts.
For anything more please refer to the documentation.

Properties

You can create global string variables.
In MSBuild they are called properties and have to be declared inside a property group.

<PropertyGroup>
  <MyOwnProperty>Hello</MyOwnProperty>
</PropertyGroup>

You can then use the global string variable like this:

<PropertyGroup>
  <MyOtherProperty>$(MyOwnProperty) World</MyOtherProperty>
</PropertyGroup>

Items

You can create arrays of objects.
In MSBuild they are called items and have to be declared inside an item group.

<ItemGroup>
  <MyItem Include="Hello" />
  <MyItem Include="World" />
</ItemGroup>

You can then use the item in the declaration of other items or properties.

<ItemGroup>
  <!-- creates a new array with an exclamation mark appended to each item of the source array -->
  <ShoutingEachWord Include="@(MyItem->'%(Identity)!')" />
</ItemGroup>

<PropertyGroup>
  <AllWordsWithDefaultSemicolonSeparator>@(MyItem)</AllWordsWithDefaultSemicolonSeparator>
  <AllWordsWithSpaceSeparator>@(MyItem, ' ')</AllWordsWithSpaceSeparator>
</PropertyGroup>

Items can be treated as a key-value store by declaring metadata.
Most of the showcased techniques will only use items as strings.

Both properties and items can be defined conditionally using the Condition attribute in the PropertyGroup or ItemGroup respectively.

Tasks

A task is something that can be executed and is usually written in C#.
A task has properties and items as its inputs and outputs.
Some tasks are shipped along with MSBuild.
A task is executed as part of a target.

Targets

A target is a group of properties, items and tasks.
A target can declare dependencies on other targets.
At build-time MSBuild will resolve the dependency trees of targets for the given project and then execute all targets in the order required by the trees.

Imports

A project file can import all of the above from another file.
The other file may not define an SDK (see below).
Files that contain things that are supposed to be imported early in the project file have the file extension .props by convention.
Files that contain things that are supposed to be imported late in the project file have the file extension .targets by convention.
This is only a convention.
All files can be imported at any time and are treated equally.

This is how such an imported file can look like:

<Project>
  <PropertyGroup>
    <MyImportedProperty>Imported</MyImportedProperty>
  </PropertyGroup>
</Project>

This is how it is imported assuming the file is named “SomeProps.props” and lives in the same directory as the importing file:

<Import Project="SomeProps.props" />

Import files can import other files themselves.

SDKs

A modern SDK-style project is based on exactly one so-called SDK.
An SDK is a collection of predefined properties, items, targets and tasks.
An SDK also responds to certain properties, items and item-metadata declared by the consuming project.
Those are called “well-known”.
Some of them have SDK-specific default values.
Some of them are hooks for the consuming project to define.
You can think of them as the API of the SDK.

This is a minimal project file using the .NET SDK.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>
</Project>

The TargetFramework property is a well-known property that is used by the .NET SDK.
It has no default value and needs to be defined by the project.

Note that using the Sdk attribute above is just shorthand for importing the SDK’s props and targets files.
It is equivalent to this.

<Project>
  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
  
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

  <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
</Project>

In an average project file based on a .NET SDK there are project dependencies and package dependencies.
They are declared via an item group containing ProjectReference and PackageReference items.
There is nothing special about those items other than that they are well-known to the .NET SDK and used by the SDK to determine how to build the project.
The items can be manipulated just like any other items.

Bringing it all together

The following defines a custom target Print that will be triggered before the well-known Build target that is defined by the .NET SDK.
It uses the Message task that comes with MSBuild to print the values of all the sample properties and items defined above.

<Target Name="Print" BeforeTargets="Build">
  <Message Importance="high" Text="MyOtherProperty: $(MyOtherProperty)" />
  <Message Importance="high" Text="ShoutingEachWord: @(ShoutingEachWord, ' ')" />
  <Message Importance="high" Text="AllWordsWithDefaultSemicolonSeparator: $(AllWordsWithDefaultSemicolonSeparator)" />
  <Message Importance="high" Text="AllWordsWithSpaceSeparator: $(AllWordsWithSpaceSeparator)" />
  <Message Importance="high" Text="MyImportedProperty: $(MyImportedProperty)" />
</Target>

It will lead to the following build output

1>MyOtherProperty: Hello World
1>ShoutingEachWord: Hello! World!
1>AllWordsWithDefaultSemicolonSeparator: Hello;World
1>AllWordsWithSpaceSeparator: Hello World
1>MyImportedProperty: Imported

I hope this suffices to follow the posts of this series.
Each post will cover the concepts necessary for the described technique in appropriate detail.