Taming TFS - Assembly versioning

By eidias on (tags: tfs, categories: infrastructure)

The assembly version is a number in the form of <Major>.<Minor>.<Build>.<Revision>. What value the placeholders are assigned to is totally up to you, we chose to set the first three numbers manually and set the last one to the change set that the source was built from. At the same time, I didn’t want to limit the options, so by examining the “Build Number Format” input in the default template, I decided to merge the parameters available there with a custom parameter set (the build number format provides a couple of params, but doesn’t provide access to the change set number).

After some experimentation, I’ve decided to create 2 custom activities: one responsible for retrieving the assembly version number and another one to update the AssemblyVersion files. The reason for that, was that I would also like to incorporate the assembly version number in the build name, so it’s going to be useful to have that number in a template variable.

Here’s the number retrieving activity:

   1: [BuildActivity(HostEnvironmentOption.All)]
   2: public class GetAssemblyVersion : CodeActivity
   3: {
   4:     protected static string[] customMacroNames = new[] { "$(Changeset)" };
   5:  
   6:     public InOutArgument<string> AssemblyVersion { get; set; }
   7:  
   8:     protected override void Execute(CodeActivityContext context)
   9:     {
  10:         var assemblyVersion = AssemblyVersion.Get(context);
  11:         var buildDetail = context.GetExtension<IBuildDetail>();
  12:  
  13:         var newVersion = FormatStringToAssemblyVersion(assemblyVersion, buildDetail);
  14:         newVersion = UpdateBuildNumber.FormatStringToBuildNumber(newVersion, buildDetail, null, false);
  15:  
  16:         AssemblyVersion.Set(context, newVersion);
  17:     }
  18:  
  19:     public static string FormatStringToAssemblyVersion(string format, IBuildDetail buildDetail)
  20:     {
  21:         var str = string.Empty;
  22:         if (!string.IsNullOrEmpty(format))
  23:         {
  24:             str = string.Copy(format);
  25:             var tokens = UpdateBuildNumber.ExtractTokens(str);
  26:  
  27:             foreach (var token in tokens)
  28:             {
  29:                 var index = str.IndexOf(token, StringComparison.OrdinalIgnoreCase);
  30:                 if (index < 0) continue;
  31:  
  32:                 var expandedPrefix = str.Substring(0, index);
  33:                 var newValue = ExpandToken(token, expandedPrefix, buildDetail);
  34:                 str = str.Replace(token, newValue);
  35:             }
  36:         }
  37:  
  38:         return str;
  39:     }
  40:  
  41:     public static string ExpandToken(string token, string expandedPrefix, IBuildDetail buildDetail)
  42:     {
  43:         if (string.Equals(token, customMacroNames[0], StringComparison.OrdinalIgnoreCase))
  44:         {
  45:             // buildDetail.SourceGetVersion prepends "C" to changeset number, "L" to label...
  46:             return buildDetail.SourceGetVersion.Substring(1);
  47:         }
  48:  
  49:         return token;
  50:     }
  51: }

(Now, I am aware of the fact that it utilizes the UpdateBuildNumber activity, which comes from “C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\PrivateAssemblies” so it does have a bit of a stench in it, but it beats having to re-write the whole thing by hand)

And the updating one:

   1: [BuildActivity(HostEnvironmentOption.All)]
   2: public class UpdateAssemblyVersion : CodeActivity
   3: {
   4:     private string assemblyInfoFile;
   5:     private string sourcesDirectory;
   6:     private Workspace workspace;
   7:  
   8:     public InArgument<string> AssemblyInfoFile { get; set; }
   9:  
  10:     public InArgument<string> AssemblyVersion { get; set; }
  11:  
  12:     public InArgument<string> ProductVersion { get; set; }
  13:  
  14:     [RequiredArgument]
  15:     public InArgument<string> SourcesDirectory { get; set; }
  16:  
  17:     [RequiredArgument]
  18:     public InArgument<Workspace> Workspace { get; set; }
  19:  
  20:     protected override void Execute(CodeActivityContext context)
  21:     {
  22:         assemblyInfoFile = AssemblyInfoFile.Get(context);
  23:         sourcesDirectory = SourcesDirectory.Get(context);
  24:         workspace = Workspace.Get(context);
  25:  
  26:         var assemblyVersion = AssemblyVersion.Get(context);
  27:         var productVersion = ProductVersion.Get(context);
  28:  
  29:         ReplaceAttributeValue(new[] { "AssemblyVersion", "AssemblyFileVersion" }, @"\("".+""\)", assemblyVersion);
  30:         ReplaceAttributeValue(new[] { "AssemblyInformationalVersion" }, @"\("".+""\)", productVersion);
  31:     }
  32:  
  33:     private void ReplaceAttributeValue(IEnumerable<string> attributes, string pattern, string assemblyVersion)
  34:     {
  35:         foreach (var attribute in attributes)
  36:         {
  37:             var regex = new Regex(attribute + pattern);
  38:  
  39:             foreach (var file in Directory.EnumerateFiles(sourcesDirectory, assemblyInfoFile, SearchOption.AllDirectories))
  40:             {
  41:                 workspace.PendEdit(file);
  42:  
  43:                 var text = File.ReadAllText(file);
  44:                 var match = regex.Match(text);
  45:  
  46:                 if (!match.Success) continue;
  47:  
  48:                 var newText = regex.Replace(text, attribute + "(\"" + assemblyVersion + "\")");
  49:                 File.WriteAllText(file, newText);
  50:             }
  51:         }
  52:     }
  53: }

Now that we have what we need, let’s put it into the template. We’ll be expanding the DefaultTemplate provided with TFS installation – I suggest you make a copy of it and work on that.

First thing – add a template argument called “AssemblyVersion” of type String. I added a default value of "1.0.0.$(Changeset)", you can put your own in there. Insert the “GetAssemblyVersion” activity that we created before “Update Build Number” and set the “AssemblyVersion” activity property to use the “AssemblyVersion” template argument. As this is a in/out param, the value configured in the build definition, will be replaced with what we need after the activity is run.

As mentioned before, I also wanted to see the assembly version in the build name, so, in order to do that, place an “Assign” activity after the “Get Assembly Version” and before “Update Build Number”. Now, here’s a little trick, set the properties in the following manner:

Property Value
To BuildNumberFormat
Value BuildNumberFormat.Replace("$(AssemblyVersion)", AssemblyVersion)

 

And simply use the “$(AssemblyVersion)” placeholder in the “Build Number” input available in build definition.

The resulting template should look like this:

image

Add another template argument called “AssemblyVersionFile” of type String. I have a default value for that set to "CommonAssemblyInfo.cs" – that’s because we use a single assembly info file with all the common properties, that is then linked in each project to be used for versioning. This allows us to keep the common info in one place, which is simply more convenient.

In the build template, locate an activity named “Initialize Workspace”. After it’s last child (that would be “Get Workspace”) inset the “UpdateAssemblyVersion” activity that we created.

image

Set the inputs as follows:

Property Value
AssemblyInfoFile AssemblyVersionFile
AssemblyVersion AssemblyVersion
SourcesDirectory SourcesDirectory
Workspace Workspace

Now when you run the build, the custom activity changes the version number before compiling the projects and as a result of that all the assemblies have a common version number. It’s easier to track them back to the source code (thanks to the change set version) and when you decide to release a new version, create a new build definition for it, change the version number in the “Process” section – easy, configurable, goal achieved.

Cheers