For my current project I wanted to add some information from Git as well as some additional build environment info into my assemblies at compile time.
My usual approach to this is adding some additional steps in my build process. I learned a lot about MSBuild from Sayed I. Hashimi (@sayedihashimi) who wrote the book Inside the Microsoft Build Engine: Using MSBuild and Team Foundation Build (by the way a must-read if you want to dig into how MSBuild works). MSBuild is very powerful and easy to extend, and so I think it's the best way to solve this.
Since I developed some MSBuild tasks and targets for internal stuff at my place of work I started to create MSBuild stuff in a way that I like to call unobtrusive MSBuild. My idea was to design my MSBuild project extensions in a way that they can be used by just adding a project include and optionally adding some configuration properties right before the include. This keeps them portable, reusable and flexible enough to be used in slightly different environments.
So, what I wanted to achieve was to
- add the Git commit # to the AssemblyInfo.
- add the Git branch name to the AssemblyInfo.
- add the current date and time of the build to the AssemblyInfo.
- add some information about the build server to the AssemblyInfo.
- be able to easily define the Version number of my project in a single place.
- optionally be able to automatically update the version number without a lot of hazzle.
Actually, it is very easy to determine all of the required information using the MSBuild Community Tasks. They have tasks to gather all these informations and write them to MSBuild properties. If you're missing one piece of information, just fork the repo, add it and make a pull request 😉
The next step is to write these information into your assembly. The version information usually is located in the
AssemblyInfo.cs file in the
Properties folder. The most common approach found on the internet is to modify this file in a PreBuild step. While I do this at my workplace and it's working smoothly I still feel that modifying the versioned files before building your application is probably not the very best solution.
There are in fact some little problems with this idea: The file could be changed by someone and perhaps disturb the parsing and modification logic (if done badly). Or, which is probably the most annoying thing: You have to revert the changes after your build, because someone could accidentally commit this automatically modified file back to the repository, killing potential placeholders for content replacement if you don't revert.
After all, if you can achieve the same desired result without modifying your files, this is probably a better idea.
This is where the 'unobtrusive MSBuild' idea kicks in. For my new project I made use of the fact that MSBuild first determines all properties throughout the project files, then evaluates the items and then the targets. What I do is to set up a bunch of properties that only kick in when they are not already set by the calling project (convention over configuration). I then extend the build process by injecting my targets into the
BuildDependsOn and in this case
CleanDependsOn arrays of targets to execute.
What I did was in fact a simple 'divide and conquer' of the problem. First, I removed the default
AssemblyFileVersion attributes completely from my
AssemblyInfo.cs file. I never saw a project where those two attributes had different values and so those are actually redundant information. Instead of taking care of the version information directly in the
AssemblyInfo.cs file I added two new files to the
Properties folder (
VersionSuffix.txt) and set them to
BuildAction: none. In those files I keep track of the actual version number and an optional suffix like -beta or -preview. Of course those file names are configurable by setting the corresponding properties in the project file, but they are expected in the Properties folder by default. This could be used to place them into your solution folder, add the property and use this single file for all projects in the solution.
I also removed the
AssemblyCopyright attribute. It usually contains the current year or a range of years ending with the current. And honestly, who thinks about adjusting the year in all his
AssemblyInfo.cs files when it becomes january? Yeah, no one. Not even us. And so I thought I will set up the initial text in the project file and just extend it by the current year the assembly is built in. I also could have put this in an extra file, but usually the copyright note isn't changed as often as the version, and so in this very rare cases one can also manually adjust the project file.
The information from these files as well as the Git information obtained through the community tasks are then written into a new, completely auto-generated file (default name is
Properties/AutoGeneratedAssemblyInfo.cs) that will be injected into the ItemGroup of files to be build. It will also be deleted when the target
Clean is called.
For everything to work, I need a small tweak to my solution so that the MSBuild Community Tasks are available for my project extension.
In one of my previous blog posts I already mentioned that I tricked MSBuild a little bit. I can't declare
UsingTask directives in an included project file when the corresponding assembly is not there at the time I declare it. Even worse, I can't include a project file that is not already there. Since we're using the MSBuildTasks NuGet package, we need to make sure it's already downloaded before it's included in our project file. For that I added another dummy project to my solution with the only purpose to have NuGet download the MSBuildTasks package before the solution will build the actual project.
Step 1: Global Properties
Since properties are evaluated first, I also put them first in my project file. I declared the path where the MSBuild Community Tasks can be found to include them later on, and I define all file names I need throughout the process. All properties only kick in when they don't already have a value, so everything is configurable.
Additionally, I usually set an [MyExtensionName]Enabled property for the whole extension. It's default condition is a little prerequisite check. The process is enabled only if everything is configured correctly and the required files exist. This way it is also possible to simply deactivate the extension on demand when building the project via command-line by just passing
/p:SetVersionInfoEnabled=false to the call.
Step 2: Build process extension
After setting up the configuration I import the MSBuildTasks project so that all the community tasks are available.
After this I added a little workaround. In the current version of the MSBuildTasks NuGet package a
UsingTask directive is missing for the task that reads the current branch that is checked out in Git. So I do the declaration myself. When the package gets updated, this directive is doubled and will break the build. In this case this UsingTask can simply be removed.
After everything is set up, I extend the
CleanDependsOn item groups with the corresponding targets, so that they are called at the right time.
Step 3: The actual work
Let's start with the easy part: The cleanup. This target only starts if the auto generated assembly info file exists and deletes it. That's it.
The actual creation of the file also is pretty much straight forward. Within the target I also follow my path: Set up the (local) properties, items, and then do the work by calling the tasks.
What I do is to read the file contents of the
Version.txt and optionally
VersionSuffix.txt files into properties. I get the current date and time as well as the year by calling through to the .NET DateTime object.
The actual Git work is deferred to the MSBuild Community Task library. All those tasks are executed and write their output into properties as well. The same goes for the
computer tasks from the community project, which reads information about the current machine into properties.
The next thing is to actually cheat the project a little bit. It needs to include the file we're going to generate in it's build process. For this I simply modify the
compile ItemGroup to add this newly generated file to the compilation process that will be executed later.
After all the properties and items are set up for this target, I simply call the
AssemblyInfo task which writes the new auto-generated file. And that's it.
The project extension code
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <MSBuildCommunityTasksPath Condition=" '$(MSBuildCommunityTasksPath)' == '' ">$(SolutionDir)packagesMSBuildTasks.188.8.131.52tools</MSBuildCommunityTasksPath> <GeneratedAssemblyInfoFile Condition=" '$(GeneratedAssemblyInfoFile)' == '' ">$(MsBuildProjectDirectory)PropertiesGeneratedAssemblyInfo.cs</GeneratedAssemblyInfoFile> <VersionFile Condition=" '$(VersionFile)' == '' ">$(MsBuildProjectDirectory)PropertiesVersion.txt</VersionFile> <VersionSuffixFile Condition=" '$(VersionSuffixFile)' == '' ">$(MsBuildProjectDirectory)PropertiesVersionSuffix.txt</VersionSuffixFile> <AssemblyCopyright Condition="'$(AssemblyCopyright)' == ''"></AssemblyCopyright> <SetAssemblyVersionEnabled Condition=" Exists('$(MSBuildCommunityTasksPath)MSBuild.Community.Tasks.targets') AND Exists($(VersionFile)) ">true</SetAssemblyVersionEnabled> <SetAssemblyVersionEnabled Condition=" '$(SetAssemblyVersionEnabled)' == '' ">false</SetAssemblyVersionEnabled> <BuildDependsOn Condition=" '$(SetAssemblyVersionEnabled)' == 'true' "> SetAssemblyVersion; $(BuildDependsOn) </BuildDependsOn> <CleanDependsOn Condition=" '$(SetAssemblyVersionEnabled)' == 'true' "> $(CleanDependsOn); SetAssemblyVersionClean </CleanDependsOn> </PropertyGroup> <Import Condition=" '$(SetAssemblyVersionEnabled)' == 'true' " Project="$(MSBuildCommunityTasksPath)MSBuild.Community.Tasks.targets" /> <!-- Workaround for missing task declaration in MSbuildTasks project (pull request already sent) --> <UsingTask Condition="Exists($(MSBuildCommunityTasksLib))" AssemblyFile="$(MSBuildCommunityTasksLib)" TaskName="MSBuild.Community.Tasks.Git.GitBranch" /> <Target Name="SetAssemblyVersion"> <PropertyGroup> <Version>$([System.IO.File]::ReadAllText($(VersionFile)))</Version> <VersionSuffix Condition="Exists('$(VersionSuffixFile)')">$([System.IO.File]::ReadAllText($(VersionSuffixFile)))</VersionSuffix> <VersionSuffix Condition=" '$(VersionSuffix)' == '' "></VersionSuffix> <BuildTime>$([System.DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss"))</BuildTime> <AssemblyCopyrightText Condition=" '$(AssemblyCopyright)' != '' ">$(AssemblyCopyright) $([System.DateTime]::UtcNow.Year)</AssemblyCopyrightText> <AssemblyCopyrightText Condition=" '$(AssemblyCopyrightText)' == '' "></AssemblyCopyrightText> </PropertyGroup> <ItemGroup> <Compile Include="$(GeneratedAssemblyInfoFile)" /> </ItemGroup> <GitVersion LocalPath="$(SolutionDir)"> <Output TaskParameter="CommitHash" PropertyName="CommitHash" /> </GitVersion> <GitBranch LocalPath="$(SolutionDir)"> <Output TaskParameter="Branch" PropertyName="GitBranch" /> </GitBranch> <Computer> <Output TaskParameter="Name" PropertyName="BuildMachineName" /> <Output TaskParameter="OSPlatform" PropertyName="BuildMachineOSPlatform" /> <Output TaskParameter="OSVersion" PropertyName="BuildMachineOSVersion" /> </Computer> <AssemblyInfo CodeLanguage="CS" OutputFile="$(GeneratedAssemblyInfoFile)" AssemblyVersion="$(Version)" AssemblyFileVersion="$(Version)" AssemblyInformationalVersion="$(Version)$(VersionSuffix)-$(GitBranch)-$(CommitHash), built $(BuildTime) UTC on: $(BuildMachineName), $(BuildMachineOSPlatform) v$(BuildMachineOSVersion)" AssemblyCopyright="$(AssemblyCopyrightText)" /> </Target> <Target Name="SetAssemblyVersionClean" Condition="Exists($(GeneratedAssemblyInfoFile))"> <Delete Files="$(GeneratedAssemblyInfoFile)" /> </Target> </Project>
It is very easy to write MSBuild .targets files that simply will hook up dynamically into your build process. By defining your properties only when they are not already set you can give your users a list of property names and what their value is used for, and so your extension is completely configurable. I hope this post gives you a head-start on unobtrusive MSBuild.