Edit: Recent changes in MSBuild has enabled a new way to set solution wide msbuild logic for each project:
The holy grail for simplifying build systems for me would be to attach a MSBuild targets file to a solution which would be imported into every project. In a corporate setting it has always been hard to educate developers to modify the project file to include custom targets when creating brand new projects of any kind (test, program, installer, etc).
Around 5 years ago I got excited by the article by Sayed Ibrahim Hashimi as it explained a technique to see how MSBuild itself creates a project from a .sln file and how to hook into it by setting the environment variable Set MSBuildEmitSolution=1. I thought this was finally a way to hook in all those custom build steps that we had accumulated over the years.
Looking into this technique further it seems to be a great for solution wide processes (Say running tests, running stylecop on the entire solution) but was too high level to modify assembly resolution or anything project related. I decided to still give it a go to see if I could import targets into a project from this target file.
All the companies I have worked at using MSBuild have often had a need to inject a level of business process into our projects. Some of these seem simple, others not so much. For example at my current place of employment we have the need to tweak a number of parts of the system.
- Set the $(OutDir) based on a number of conditions.
- Set variables such as wixtargets so that we can point to a designated directory, allowing a specific build to use a specific version of Wix.
- Modifying the ReferencePaths variable for assembly resolution
- Custom dependency retrieval logic
- Custom unit test runner logic
- Analyzers such as FXCop and Stylecop
- Nuget package building and project restore
- Custom build server logic such as:
- Strong Signing the delayed signed projects
- Digital Signatures
- Code coverage tools
- Updating assembly version numbers from build server
Looking into the generated project
I was on the look out for an <import> target which i could utilize, most of which originate from Microsoft.Common.Targets. I knew the import with the greatest success would be this line:
<Import Project=“$(CustomBeforeMicrosoftCommonTargets)“ Condition=“‘$(CustomBeforeMicrosoftCommonTargets)’ != ” and Exists(‘$(CustomBeforeMicrosoftCommonTargets)’)“/>
This means if i could find any means to set the property ‘CustomBeforeMicrosoftCommonTargets’ then i could get my hook into each project build.
I have uploaded an example of this scenario to GitHub to follow along. If you go into Example1 directory and run Build.bat it will create the two diagnostic files:
- MySolution.sln.metaproj.tmp – This file contains the template that used to build the target file (Build targets are empty and imports have not been brought in.)
- MySolution.sln.metaproj – The .tmp file has been resolved with all imports and properties fleshed out so that msbuild can execute it.
Looking at the .metaproj file I could see that MSBuild was being called with a set of properties so I thought i could override the target name with my own version of build with a new set of properties. This unfortunately didn’t work as it will remove all targets with reserved names on import.
Then I used a technique I like to call Property Injection. Looking at the metaproj I could see that if i couldn’t change the MSBuild call for the project, i could modify one of the properties to include my property. In the after.MySolution.sln.targets file i include this property.
What this does is when it resolves the property for the build target:
<MSBuild Projects=”@(ProjectReference)” BuildInParallel=”True” Properties=”BuildingSolutionFile=true; CurrentSolutionConfigurationContents=$(CurrentSolutionConfigurationContents); SolutionDir=$(SolutionDir); SolutionExt=$(SolutionExt); SolutionFileName=$(SolutionFileName); SolutionName=$(SolutionName); SolutionPath=$(SolutionPath)” SkipNonexistentProjects=”%(ProjectReference.SkipNonexistentProjects)”>
it will actually include $(SolutionPath) AND $(CustomAfterMicrosoftCommonTargets)!
Now every build is being called will invoke the custom targets. If you clone the git and run build.bat in example1 you will see:
Scope: Project. I performing an action per project from a solution include.
Done Building Project “C:\Dev\Repos\Blog\2015-08 – Solution wide MSBuild target\Example1\Project1\Project1.csproj” (default targets).
Scope: Project. I performing an action per project from a solution include.
Done Building Project “C:\Dev\Repos\Blog\2015-08 – Solution wide MSBuild target\Example1\Project2\Project2.csproj” (default targets).
Why doesn’t this work?
This seems to achieve exactly what I want, with one exception. This does not work from within visual studio, which is kind of a killer.
As another solution if you add a targets that Microsoft.Common.Targets imports (in one of the designated folders) you can get it to import from the solution directory. You can see an example of the file here.
This works from within visual studio and MSBuild command line executions, but I do not want to go to every developers machine and install it. Plus if they don’t install it they could be checking in code which hasn’t gone through quality gates, slowing down the system.
What do I want?
Ultimately I really want the default Microsoft.Common.Targets to be changed to add the following line:
<Import Project=”$(SolutionPath).targets” Condition=”Exists(‘$(SolutionPath).targets’)”/>
<Import Project=”$(SolutionDir)Custom.$(MSBuildThisFile)” Condition=”Exists(‘$(SolutionDir)Custom.$(MSBuildThisFile)’)”/>
This can be placed after line 31 where it imports $(MSBuildProjectFullPath).user. The first allows each solution to have a different custom import, the second would allow any solutions in the directory to use the same import (This is preferable to me, you can filter targets condition based on the solution if needed.) The variables ‘$(SolutionPath)’ and ‘$(SolutionDir)’ are set either via MSBuild when targeting a solution (You can see it in the .metaproj file) or set by the Visual Studio IDE (Stubs can be seen in Microsoft.Common.targets).
Realistically the Microsoft.Common.Targets file should need updating because $(MSBuildProjectFullPath).user should now be checking the new VS2015 user folder of $(SolutionDir)\.vs folder.
What about nuget?
Nuget could actually have a lot of practical use with one further change too. If the import was also to be added to Microsoft.Common.Targets:
<Import Project=”$(MSBuildProjectDirectory)\.import\*” Condition=”Exists(‘$(MSBuildProjectDirectory)\.import’)”/>
<Import Project=”$(SolutionDir).import\*” Condition=”Exists(‘$(SolutionDir).import\’)”/>
This would allow any targets file to be included at a project or solution level and it will be imported into the project.
This solves one of the goals as listed on the blog under Part of the Platform > Goals.
Leave Project Files Alone
Instead of adding assembly references with Hint Paths into projects through the DTE, we want to leave project files alone. This would avoid the XML merge conflicts that arise far too often. Only the package manifest (
packages.config) would be updated when a package is installed.
While it wouldn’t tackle the assembly resolution (which they want to be resolved directly from the packages.config/project.json file) it would still allow custom targets to be imported. An install would place the file there, uninstall would remove the file. With the package name being the target file there wont be conflicts, happy days.
I have made a user voice post about this as a feature request. Please go and vote for it to help this move along!
Update: There has been some progress on a git hub issue from the now open sourced Msbuild project: https://github.com/Microsoft/msbuild/issues/222
Should the change go through it means that while a solution targets wont exist, a file could be placed in the same directory with the same effect, as long as all projects are in folders within the solution direction. Something easily controlled if it is in source control so a win win!