Customizing .NET Builds – Project Files
In the first article in this series we reviewed the different ways to customize a build with MSBuild. In this article we will look into the first approach, project files.
Customizing a build using a project file involves modifying a project file (e.g. .csproj
or .vbproj
) to contain addition MSBuild tasks or properties to influence how the build works. This approach has the following advantages as discussed previously.
- Easiest customizations to perform.
- Will not break other projects or builds in most cases.
- Does not need to be generic which generally reduces the amount of changes needed.
- Can be easily tested within a local build before using on a build server.
This approach also has the following disadvantages.
- Not maintainable if needed in several different projects.
- Run on every build unless written conditionally which could have a negative impact on local builds.
Problem
You have an application that hosts plugins. The plugins are stored in separate projects but must be copied to a central plugins
folder to be loaded by the host. After a plugin builds it needs to be copied to the appropriate folder. For various reasons having the project output directly to the final folder is not an option.
Solution
After the project builds the build system will copy the output of the build to the pre-defined plugins
folder for the host. Each plugin has its own directory to avoid colliding with other plugins.
A Word About Build Events
Projects can have pre/post-build events. These events are automatically called in the build and allow you to execute external commands from a command script. There are some limits to these events though.
- You only have the option of before and after. If you need to do something in the middle of a build then the events won’t work.
- They have access only to the macros exposed by the build and are limited to the syntax of a command script.
- SDK projects don’t work correctly with them.
If you can get away with using a build event then feel free to do so but we will assume, for this post, that they are insufficient.
MSBuild Basics
To customize a project’s build you have to open the project file. This file is in XML format but may look a little different if you are using the SDK format. Either way this file is read by MSBuild and it controls how the build runs. For an SDK project format a lot of the magic happens in files that are automatically included in the build but for a traditional project format all the included files are referenced directly. It does not really matter for this discussion which format is being used.
A full discussion of how MSBuild works can be found here. The gist of the build process involves MSBuild enumerating the PropertyGroup and Task elements of the project file and executing each section. Properties are the variables of MSBuild. Property groups are where you define the properties you need for the build. Tasks are the commands that are run.
Phases of a Build
MSBuild currently does a two pass build. The first pass, the evaluation phase, evaluates all property groups to set up the initial property values. During the second pass, the execution phase, the tasks are run to perform the actual build. Tasks may define additional properties as they are run.
Properties and tasks can be conditionally run. Conditions allow you to control when a property or task is used. This is very common with properties and common with tasks. It allows a single file to contain any number of tasks and then conditionally decide which ones to run based upon the build that is running. Conditions are specified using the Condition attribute. Conditions typically rely on property values to determine whether they run or not.
Properties
Properties may reference other properties. Properties are referenced using delimiters $(MyProperty)
. Properties are normally calculated up front and then used elsewhere but tasks may also define or adjust the properties.
Properties start out empty so it is common to initialize a property to a default value. However property values can be provided at build time using the command line so properties should respect any overrides provided on the command line.
<PropertyGroup>
<PluginDirectory Condition="'$(PluginDirectory)'==''">$(SolutionDir)\MyPlugin</PluginDirectory>
</PropertyGroup>
Here the property PluginDirectory
is set to the $(SolutionDir)MyPlugin
directory if it hasn’t already been set (by the command line most likely).
Refer to MSDN for a full list of pre-defined properties and available property functions that you can use.
Tasks
Tasks do the actual commands for the builds. There are quite a few built in tasks but most come from including other build files (.targets
files). These are shipped by the build tools for the various languages and by other tools such as some NuGet packages or Visual Studio extensions. There are different categories of tasks
Aggregate Tasks
Aggregate tasks are defined directly in the build file and are really just groups of other tasks. They can be thought of like a function. They are limited to using other tasks but often provide all the functionality that is needed.
<ItemGroup>
<PluginFiles Include="$(TargetDirectory)\*.dll;$(TargetDirectory)\*.pdb" />
</ItemGroup>
<Task Name="CopyFiles">
<Copy SourceFiles="@(PluginFiles)" DestinationFOlder="$(PluginDirectory)" />
</Task>
Inline Tasks
Inline tasks are tasks that are defined directly in the build file as C# or Visual Basic code. To do this the UsingTask has to be added. This task contains the code to be compiled and the task factory to use.
CodeTaskFactory
is the standard factory to use. However it has some limitations. Starting with Visual Studio 15.8 the newer RoslynCodeTaskFactory should be used instead.
<UsingTask TaskName="CopyFilesInline" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<SourceFiles ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
<TargetFolder ParameterType="System.String" Required="true" />
<Overwrite ParameterType="System.Boolean" Required="false" />
</ParameterGroup>
<Task>
<Code Type="Fragment" Language="cs"><![CDATA[
foreach (var file in SourceFiles) {
File.Copy(file.ItemSpec, TargetFolder, Overwrite);
};
]]></Code>
</Task>
</UsingTask>
In this example we used a code fragment which means we just provide the task body. If you need more complex control you can switch up to a full ITask implementation.
Inline tasks have some restrictions.
- Bloat the project file if the task is too complex.
- No editing or debugging support.
- Referencing other assemblies that aren’t part of the standard build is difficult.
For simple work that cannot be done using aggregate tasks then inline tasks may be OK but for more complex code you have to switch to a compiled task.
Compiled Tasks
Compiled tasks are the most powerful option available but require that you compile the code in advance and then ensure it is available at build time. For NuGet-provided project files then the task can be provided as a build tool. For other deployment types this is not as simple.
Compiled tasks are simply classes that implement ITask. To allow MSBuild to find them they must be imported with the UsingTask mentioned earlier.
Compiled tasks have no real restrictions since they can be written as complex as needed, are pre-compiled so won’t slow down the build or bloat the project files and can reference anything they need.
Task ordering
A final thing to be aware of before moving on from tasks is that tasks are executed in the order they appear by default. Ordering is important because some tasks need to run before a build, some during and others after. Since the tasks are defined across multiple files MSBuild defines a set of ordering rules. In general the following attributes can be used on a Task
element to control the ordering.
AfterTargets
specifies the target(s) that must run before this target can execute.BeforeTargets
specifies the target(s) that must run after this target.DependsOnTargets
specifies the target(s) that must run before this target can execute. This is for older targets.
In addition to the above attributes the following attribute apply to the Project
element itself.
InitialTargets
specifies the target(s) to run first before anything else.DefaultTargets
specifies the target(s) to run if no targets are specified on the command line. If not specified then the first target in the file is run.
Unfortunately since MSBuild can build just about anything there are no well-defined targets that you can arbitrarily reference. You have to look at the build target order to figure out where your task needs to run. You can also look at the core build files in GitHub if needed. Some common ones are defined here.
As an example, if I wanted to run a task only after a successful build I would do this. This is equivalent to the post-build event task in the UI.
<Task AfterTargets="AfterBuild">
</Task>
Implementing Versioning
At this point we are ready to implement automatic versioning in the project file. But this post is already long and versioning is more complex than just where to place it so we will stop here. Next time we’ll implement automatic versioning in the project file and that will serve as the base implementation for the remainder of the series.