TFS Build and Checked-In Assemblies

23 February 2011 - TFS

Even though our core dev team is quite small, time cockpit's code base has grown quite a bit. It has grown in such a way, that having all of the code in one solution file and working with it from a day-to-day basis is not a feasible solution. As we have three different areas we work on, Data Layer (DL), Signal Trackers (ST) and the User Interface (UI) itself, we have created seperated solutions for each area. This would work fine just as it is, but the problem is, that ST depends on DL and the UI depends on the ST and DL Layers.

Checked-in Assemblies

As the top levels do not want to open the other solutions everytime they get a latest version from tfs and recompile, we have (development) assemblies checked into TFS as if they were 3rd party. It now becomes the responsibility of the lower-level contributor to update the assemblies (releasing them using a shell script). This works really great for local development, but for TFS Build we still require a single solution as we wan to build our code completely from Scratch in the build process and not use the checked-In assemblies. To make this work we modify the references to our checked-in assemblies in the csproj msbuild file.

Modifying the Assembly Reference

When adding a new reference to an assembly that is checked into your TFS, Visual Studio usually adds a relative HintPath and it looks something like this:

<Reference Include="TimeCockpit.Common">
    <HintPath>..\Assemblies\.Net\3.5\TimeCockpit\TimeCockpit.Common.dll</HintPath>  
</Reference>

Note that we remove the Version and thumbprint information on the assembly as we do our checking at load-time using MEF anyhow. The HintPath is there interesting thing. It tells Visual Studio where to look first. We use this HintPath to tell it to look either in the folder containing the checked in Assemblies or the folder with the newly built assembly from the build process, depending on whether we are currently building using Visual Studio or from TFS Build. To do this, we introduce a variable called TimeCockpitAssemblies which points to the base path where the assemblies are checked in. If that variable is not passed from the Build Process, we assume, that we are being called from Visual Studio and therefore create a default Variable. To do this, we add a Property to the standard property section (valid for any build configuration) at the top of the msbuild file:

<PropertyGroup>
    <!-- some other properties here, omitted for clarity -->
    <TimeCockpitAssemblies Condition=" '$(TimeCockpitAssemblies)' == '' ">
        ..\..\Assemblies\.Net\3.5\TimeCockpit\$(Configuration)
    </TimeCockpitAssemblies>
</PropertyGroup>

As you can see from the other properties in any csproj File, this is the standard way to give default values to variables within msbuild (it is done for Configuration and Platform as well). As you can see, we also use the $(Configuration) variable in the standard reference path, so that we can build Release as well as Debug builds from Visual Studio (although we really do not use this at the moment..). We can now use that variable in our HintPath and therefore allow an external caller of the msbuild file to override the default location for Timecockpit reference assemblies:

<Reference Include="TimeCockpit.Common">
    <HintPath>$(TimeCockpitAssemblies)\TimeCockpit.Common.dll</HintPath>  
</Reference>

For building, we create a solution, called AutomatedBuild.sln in our case, that builds all of the code that we want to have automatically built for a release. All seems fine. Well, there's a nitpick, one that's a real problem: Build dependencies are resolved anymore, as we do not have a explicit project reference. If the file is checked into TFS, it is our task to update it, but if it is built from scratch, MSBuild does not have a clue which project to build first as it does not have a Project Reference anymore. So you end up with having projects failing to build because a reference cannot be found, but when you look at the output folder the file is there because it is built after the failed project. You say: "Okay, no problem, let's add a project reference and it'll be happy". Well, that would work, but it will entirely remove the reason we have checked-in assemblies, as Visual Studio would build the project reference again, anytime we build the project. Even worse, it would build the project reference, and still use the checked-in assembly (haha). So you say: "Ok, no problemo, I know my visual studio and it has Project Dependencies that are specified in the Solution file". Bam, you hit the wall. MSBuild 4.0 does not follow project references stored in Solution files. Yes, we could rant about this forever, but that won't get us further. So what to do?

The Solution: Conditional Project References

When you look at a project reference in MSBuild, it looks something like this:

<ItemGroup>
    <ProjectReference Include="..\WPF\TimeCockpit.Data.QueryLanguage\TimeCockpit.Data.QueryLanguage.csproj">
        <Project>{082E4440-66D9-47C4-9C6C-882FAC320337}</Project>
        <Name>TimeCockpit.Data.QueryLanguage</Name>
    </ProjectReference>
 </ItemGroup>

Now, properties in ItemGroups can have conditions (just like any tag in MSBuild). This allows us to conditionally add the project reference, and the condition is quite simple as well: If our hint path file already exists, its already built and does not need to be added as a project reference. For Visual Studio builds this is always true, as the checked-in assembly will always be checked out along with the rest of the source tree. For TFS Build the path pointed to by the HintPath might exist if the project has been built before the current project, otherwise we tell it to build before building the current project:

<ItemGroup>
    <ProjectReference Condition="!Exists('$(TimeCockpitAssemblies)\TimeCockpit.Web.Common.dll')" Include="..\..\TimeCockpit.Web.Management\TimeCockpit.Web.Common\TimeCockpit.Web.Common.csproj"> 
        <Project>{7E374234-04A1-4BC2-9902-90D8F05A856F}</Project> 
        <Name>TimeCockpit.Web.Common</Name> 
    </ProjectReference> 
</ItemGroup>

Yes, it is somewhat cumbersome to do this for all references in a bigger solution, but it is only requried for assemblies that you have checked-in as well as build from scratch depending on the sitatuion. I have currently only used it to "push" the semi-random build order in VS into a working order, but I will eventually add the project reference to all assemblies. Note Visual Studio also understands this, so it will not load the project reference. It will also work correctly when you use the UI to add another project reference: The condition will remain, I just checked. Hope that helps speeding up your local development-cycle!