Using The Custom Assembly Path to Deploy Custom TFS Checkin Policies

First, what is the Custom Assembly Path? In TFS 2010, we added a feature to the Build infrastructure that will automatically copy dlls checked into version control to the Build Machine. This functionality was added so that the custom build process designer didn’t have to visit each and every build machine to deploy his custom activities. However, it’s a pretty good delivery mechanism for just about any file that needs to get to the build machine (for example, you could use this same mechanism to deploy custom task assemblies to the build machine). And to finally answer the question… the custom assembly path is a field on the Build Controller properties window that tells the build controller (and its agents) where to download custom activities from in source control.

So, one problem that we introduced in TFS 2010 with Gated Checkin was how do we evaluate checkin policies on the build machine. If the build machine is going to do the checkin shouldn’t it be under the same rules as the client? To solve that problem we ship with an optional activity called EvaluateCheckinPolicies. It’s not in the default template, but you can easily drop it in after the InitializeWorkspace step (inside the AgentScope). Once you do this, you have to make sure that all check in policies are deployed to the build machine (just like they have to be deployed to the clients). That’s where we begin…

Problem:

I have created a custom check in policy that I want to evaluate during Gated checkins. However, I have 100 build machines to worry about and I am still developing the check in policy (i.e. I know there will be bugs that I have to fix and then redeploy).

Solution:

First, I created a custom check in policy (see this msdn article for more info). I called it “The Always Fails Policy” because the evaluate method always returns a failure. I put it in an assembly called CustomCheckinPolicies.dll. Then, I put the EvaluateCheckinPolicies task into the default template (after the InitializeWorkspace sequence). I enabled the checkin policy on the client (NOT the build machine) and got the following error when I ran a Gated Checkin:

Internal error in The Always Fails Policy. Error loading the The Always Fails Policy policy (The policy assembly 'CustomCheckinPolicies, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' is not registered.). Installation instructions:

I expected this error since the dll wasn’t on the build machine box at all.

Next,  I added the assembly to version control in a “CustomAssemblies” folder and changed the Controller to use this folder as its CustomAssemblyPath. Now this would get the assembly downloaded to the client, but not loaded into memory. So, I also added a dummy Activity to the assembly and marked it with the BuildActivityAttribute to make sure the assembly was loaded by the controller/agent. Unfortunately, this wasn’t enough. I got the same error, even though the assembly was loaded. So, I did what any good developer would do and looked at the source code for Version Control related to Checkin policies (any good TFS developer anyway :)). I noticed that they read the registry and loaded the assembly from that specific location. If it wasn’t found or there wasn’t a registry entry then it failed with the above error.

Finally, I decided to write a real activity (instead of my dummy one) that would copy the assembly to a known location and put in the registry entry to point to it. This worked like a charm and I got the error message that I wanted to see all along:

Always Fails

So, the hard part was really creating the activity to deploy the policy. Here is the code for that activity:

 using System;
using System.Activities;
using System.ComponentModel;
using System.IO;
using Microsoft.TeamFoundation.Build.Client;
using Microsoft.Win32;

namespace CustomCheckinPolicies
{
    [BuildActivity(HostEnvironmentOption.All)]
    public class DeployCustomPolicies : CodeActivity
    {
        [Browsable(true)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
        [Category("Deployment")]
        [Description("Folder to copy assembly to.")]
        public InArgument<String> TargetPath { get; set; }

        [Browsable(true)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
        [Category("Deployment")]
        [Description("Location of assembly.")]
        public InArgument<String> SourcePath { get; set; }

        protected override void Execute(CodeActivityContext context)
        {
            String targetPath = TargetPath.Get(context);
            String sourcePath = SourcePath.Get(context);

            String filename = "CustomCheckinPolicies.dll";
            String source = Path.Combine(sourcePath, filename);
            String target = Path.Combine(targetPath, filename);

            // First see if a copy of the assembly already exists in the target path
            if (File.Exists(target))
            {
                File.Delete(target);
            }

            // Next, make sure the directory exists
            if (!Directory.Exists(targetPath))
            {
                Directory.CreateDirectory(targetPath);
            }

            // Copy the assembly to target path
            File.Copy(source, target);

            // Finally, register the policy for this user 
            using (RegistryKey regKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\VisualStudio\10.0\TeamFoundation\SourceControl", true))
            {
                if (regKey != null)
                {
                    RegistryKey policyKey = regKey.OpenSubKey("Checkin Policies", true);
                    if (policyKey == null)
                    {
                        policyKey = regKey.CreateSubKey("Checkin Policies");
                    }

                    using (policyKey)
                    {
                        policyKey.SetValue(Path.GetFileNameWithoutExtension(filename), target);
                    }
                }
            }
        }
    }
}

And here’s the XAML that I added to the default template (lots of stuff omitted):

 <Activity x:Class="TfsBuild.Process" ...
    xmlns:ccp="clr-namespace:CustomCheckinPolicies;assembly=CustomCheckinPolicies" 
        .
        .
        .
    &lt;mtbwa:AgentScope DisplayName="Run On Agent" MaxExecutionTime="[AgentSettings.MaxExecutionTime]" MaxWaitTime="[AgentSettings.MaxWaitTime]" ReservationSpec="[AgentSettings.GetAgentReservationSpec()]" mva:VisualBasic.Settings="Assembly references and imported namespaces serialized as XML namespaces">
      <mtbwa:AgentScope.Variables>
        .
        .
        .
        <Variable x:TypeArguments="mtbw:BuildEnvironment" Name="buildEnvironment" />
      </mtbwa:AgentScope.Variables>
        .
        .
        .
      <mtbwa:GetBuildEnvironment Result="[buildEnvironment]" />
      <ccp:DeployCustomPolicies TargetPath="c:\custompath" SourcePath="[buildEnvironment.CustomAssemblyPath]" />
      <mtbwa:EvaluateCheckInPolicies Workspace="[Workspace]" />
        .
        .
        .
    </mtbwa:AgentScope>
        .
        .
        .
</Activity>

You may notice in the Windows Workflow XAML snippet above that I use GetBuildEnvironment to the location of the custom assembly path. This is a pretty critical step. That location is different for Controllers and Agents and, in fact, can be different for each build (if a new version of the custom assemblies were checked in while a build was in progress).

As I mentioned in the opening of this post, this same technique could be used to deploy custom tasks to your build machine as well. But that seems even easier, since you don’t have to update the registry to get MSBuild to use them. So, I chose to elaborate on this scenario :)

Happy Coding!