Tidbits

Version from Directory.Build.prop in .NET Framework projects

Most of the time when working with multiple projects in a solution I want to set the version globally using the Directory.Build.props file. That way I only have to update the version in one file, rather than all project files.
While this works great for SDK style projects, it doesn’t work well with .NET Framework projects. Basically, whatever value I have set in the .props file is read once when I open the solution and any changes are ignored until I close and reopen the solution.

As a workaround I use this small target in Directory.Build.targets to actively read the Version property from the .props file and write it into a separate version info file in every Framework project before every build.

Here’s a quick reminder regarding the two Directory.Build files.


<Target Name="SetAssemblyVersion" BeforeTargets="BeforeBuild"  Condition="'$(TargetFramework)'==''">
  <!-- Read the Version property from the .props file -->
  <XmlPeek XmlInputPath="$(MSBuildThisFileDirectory)Directory.Build.props" Query="/n:Project/n:PropertyGroup/n:Version/text()"
           Namespaces="&lt;Namespace Prefix='n' Uri='http://schemas.microsoft.com/developer/msbuild/2003' /&gt;">
    <Output TaskParameter="Result" ItemName="PropVersion"/>
  </XmlPeek>
 
  <!-- Prepare the attribute -->
  <ItemGroup>
    <AssemblyAttributes Include="AssemblyVersion">
      <_Parameter1>@(PropVersion)</_Parameter1>
    </AssemblyAttributes>
  </ItemGroup>
 
  <!-- Write the attribute -->
  <Message Importance="high" Text="Setting assembly version to '@(PropVersion)'" />
  <WriteCodeFragment Language="C#" OutputFile="Properties\AssemblyVersion.cs" AssemblyAttributes="@(AssemblyAttributes)" />
</Target>

Since MSBuild files are basically just XML, we can use XmlPeek to read the content of the Version property. Don’t forget the namespace!
Since the .props and .targets files are right next to each other, we can use the predefined MSBuildThisFileDirectory property to find the path to the .props file.

Then we take that information and write it in a standalone file next to the AssemblyInfo.cs file you get by default. Keep in mind that the target is executed for the project, meaning the path is relative to the .csproject file.
Also remember to remove the AssemblyVersion from the existing AssemblyInfo.cs to avoid errors.

Lastly, we don’t want to run this target for SDK style projects. One of the easiest ways to avoid that is to check the TargetFramework property in the condition.

Tidbits

Add a hammer to Build and Clean

Just show me the code.
Just quick snippets of two MSBuild targets I use variations of to make my life a little easier. They solve two problems:

  1. Output files of dependent projects aren’t copied into the current output.
    Project dependencies are handled really well in most cases, but I do occasionally run into this issue especially in scenarios like
    “A depends on B depends on C”,
    where A has only an indirect dependency on C.
  2. Clean doesn’t remove supplementary files.
    Basically clean only removes files that are generated by the project. Which is nice, don’t get me wrong. But I did spent many frustrating hours taking my code apart only to find out the issue was an outdated resource file in my bin folder.

Here’s a target for the first problem. I stretch the a part because you’ll likely want to adapt it based on your needs and preferences.

<Target Name="CopyDependentOutput" AfterTargets="AfterBuild" Condition="@(_ResolvedProjectReferencePaths)!=''">
  <Message Importance="high" Text="Copying output from: @(_ResolvedProjectReferencePaths->'%(Filename)')"/>
  <Exec Command="ROBOCOPY &quot;%(_ResolvedProjectReferencePaths.RelativeDir) &quot; &quot;$(MSBuildProjectDirectory)\$(OutputPath) &quot; /e > NUL" IgnoreExitCode="true"/>
</Target>

The big chunk of work is already done by one of the many tasks that come with MSBuild and are run as part of your standard build process.
The item _ResolvedProjectReferencePaths contains references to all the projects that needed to be resolved before the current one could be build. It’s all in the name really. The neat part is, it contains all projects, regardless of whether they are referenced directly or indirectly.

The easiest way to transfer all their data for me was to use a windows command, since it can copy files and folders recursively.
Should you also go for this solution, think about evaluating the exit code to abort an active build when the copy process didn’t work. In the above snippet we ignore any error.


And this is an example of how to tackle the second issue:

<Target Name="ClearOutput" AfterTargets="AfterClean">
  <ItemGroup>
    <FilesToDelete Include="$(OutputPath)*.*"/>
    <FoldersToDelete Include="$([System.IO.Directory]::GetDirectories('$(OutputPath)'))"/>
  </ItemGroup>
 
  <Message Importance="high" Text="Cleaning @(FilesToDelete->Count()) file(s) and @(FoldersToDelete->Count()) folder(s)"/>
  <Delete Files="@(FilesToDelete)" Condition="@(FilesToDelete)!=''"/>
  <RemoveDir Directories="@(FoldersToDelete)" Condition="@(FoldersToDelete)!=''"/>
</Target>

A bit more involved this one. The easiest solution would have been to just delete the entire output folder, of course. In my case however, I wanted to keep it intact and just remove its content.

The task is split into two parts: Clear all top level files and then recursively all folders.
The files are easy enough to do with a simple wildcard.
The folders are best listed with a static method call.
Actual removal is done with the predefined MSBuild tasks Delete and RemoveDir for files and directories respectively.


Both those targets are hooked to the end of the Build and Clean tasks through their AfterTargets attributes.
That means, as long as those targets are evaluated at all when our projects are processed, we’re done.
The most direct approach for that would be to add them “as is” in your project files.

The best solution for me though was to make use of Directory.Build.targets.
In case you’re not familiar with it:
Imagine a project in C:\Dev\Foo\Bar.csproj.
When you process that project, MSBuild will iterate upwards through your path (first C:\Dev\Foo\, then C:\Dev\ and finally C:\) until it finds two specific files or has nowhere left to go . Those two files are called Directory.Build.props and Directory.Build.targets.
Should they be found, their content is imported as if your project directly referenced them (which it kind of does if you follow the breadcrumbs through the forest of references and imports).

So what we can do now, is to put a file called Directory.Build.targets at the root of our solution, next to the .sln file, containing something similar to this:
<Project>
  <Target Name="CopyDependentOutput" AfterTargets="AfterBuild" Condition="@(_ResolvedProjectReferencePaths)!=''">
    <Message Importance="high" Text="Copying output from: @(_ResolvedProjectReferencePaths->'%(Filename)')"/>
    <Exec Command="ROBOCOPY &quot;%(_ResolvedProjectReferencePaths.RelativeDir) &quot; &quot;$(MSBuildProjectDirectory)\$(OutputPath) &quot; /e > NUL" IgnoreExitCode="true"/>
  </Target>
 
  <Target Name="ClearOutput" AfterTargets="AfterClean">
    <ItemGroup>
      <FilesToDelete Include="$(OutputPath)*.*"/>
      <FoldersToDelete Include="$([System.IO.Directory]::GetDirectories('$(OutputPath)'))"/>
    </ItemGroup>
 
    <Message Importance="high" Text="Cleaning @(FilesToDelete->Count()) file(s) and @(FoldersToDelete->Count()) folder(s)"/>
    <Delete Files="@(FilesToDelete)" Condition="@(FilesToDelete)!=''"/>
    <RemoveDir Directories="@(FoldersToDelete)" Condition="@(FoldersToDelete)!=''"/>
  </Target>
</Project>

Now all projects within the solution will automatically trigger one of our targets on every Build, Clean or Rebuild.


Some salt to add to this:
These targets add a lot of possibly unneeded overhead to your builds.
Make sure you actually need them and try different filters to limit what they work on.