Writing Custom Activities for TFS Build 2010 (Beta 1)


With TFS Build 2010 (Beta 1), we’ve changed the build orchestration language from MSBuild to Windows Workflow Foundation (WF).  As such, you now have a new option for adding custom logic to your build proces – custom WF activities.  I’ve gotten a couple of requests for a blog post with an example, best practices, and so forth, so here goes.

There are four ways (that I know of, at least) to author a custom WF activity – using the WF Designer / XML Editor and composing your custom activity in XAML; composing your custom activity in code; writing a custom CodeActivity; or writing a custom NativeActivity.  This list is more or lest organized by degree of difficulty, and more or less by preference as well. 

The first two approaches involve building a new activity up from existing activities – when possible, this is a good strategy, since it has several advantages over writing custom code to do your work:

  1. Composed activities obviously re-use code to a high degree, which is always a good idea – why reinvent the wheel if you don’t have to?
  2. Composed activities are by their nature cancellable by the workflow runtime, meaning that builds running your custom activity will be easy to stop cleanly.
  3. Composed activities can participate in workflow tracking, meaning that their internal progress can be easily tracked as they execute.  (If you don’t want their progress to be tracked when running in a build, you can turn this behavior off using Microsoft.TeamFoundation.Build.Workflow.Tracking.ActivityTrackingAttribute.)
  4. It’s comparatively easy, at least once you get the hang of it.

I’ll be tackling these approaches in this post, and the CodeActivity and NativeActivity approaches in a subsequent post.  The activity I’ll be writing takes in a server path to a script file and a Workspace object (along with a couple of other optional parameters), executes the script, and returns the exit code.  The assumption, then, is that this activity will be used in a build process at some point after the workspace for the build has been set up and after that workspace has been synched.  Note that a big difference between MSBuild and WF is that WF can hand actual objects around between activities, as opposed to MSBuild which basically just hands strings around. 

To write my activity, I created a new WF ActivityLibrary project, added references to Microsoft.TeamFoundation.Build.Client, Microsoft.TeamFoundation.Build.Common, Microsoft.TeamFoundation.Build.Workflow, Microsoft.TeamFoundation.VersionControl.Client, and Microsoft.TeamFoundation.VersionControl.Common.  I then added the various arguments I wanted my activity to have using the Arguments flyout in the WF designer, dragged and dropped a Sequence activity and the two TFS Build activities I needed, set the appropriate properties, etc.  Here’s what it looks like in the Beta 1 WF Designer:

InvokeScript

And here’s the XAML:

<p:Activity mc:Ignorable="" x:Class="ActivityLibrary1.InvokeScript" xmlns="http://schemas.microsoft.com/netfx/2009/xaml/activities/design" xmlns:__InvokeScript="clr-namespace:ActivityLibrary1;" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mtbwa="clr-namespace:Microsoft.TeamFoundation.Build.Workflow.Activities;assembly=Microsoft.TeamFoundation.Build.Workflow" xmlns:mtvc="clr-namespace:Microsoft.TeamFoundation.VersionControl.Client;assembly=Microsoft.TeamFoundation.VersionControl.Client" xmlns:p="http://schemas.microsoft.com/netfx/2009/xaml/activities" xmlns:sad="clr-namespace:System.Activities.Debugger;assembly=System.Activities" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <x:Members>
    <x:Property Name="Arguments" Type="p:InArgument(x:String)" />
    <x:Property Name="Script" Type="p:InArgument(x:String)" />
    <x:Property Name="WorkingDirectory" Type="p:InArgument(x:String)" />
    <x:Property Name="Workspace" Type="p:InArgument(mtvc:Workspace)" />
    <x:Property Name="ExitCode" Type="p:OutArgument(x:Int32)" />
  </x:Members>
  <p:Sequence DisplayName="Invoke Script From Source Control">
    <p:Sequence.Variables>
      <p:Variable x:TypeArguments="x:String" Name="localScriptPath" />
    </p:Sequence.Variables>
    <mtbwa:ConvertWorkspaceItem DisplayName="Convert Server Path to Local Path" Input="[Script]" Result="[localScriptPath]" Workspace="[Workspace]" />
    <mtbwa:InvokeProcess Arguments="[String.Format(&quot;/c &quot;&quot;{0}&quot;&quot; {1}&quot;, localScriptPath, Arguments)]" DisplayName="Invoke the Script" FileName="[&quot;cmd.exe&quot;]" Result="[ExitCode]" WorkingDirectory="[WorkingDirectory]">
      <mtbwa:InvokeProcess.BeforeExecute>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="commandLine" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildMessage Message="[commandLine]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.BeforeExecute>
      <mtbwa:InvokeProcess.ErrorDataReceived>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="errorData" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildError Message="[errorData]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.ErrorDataReceived>
      <mtbwa:InvokeProcess.OutputDataReceived>
        <p:ActivityAction x:TypeArguments="x:String">
          <p:ActivityAction.Argument>
            <p:Variable x:TypeArguments="x:String" Name="outputData" />
          </p:ActivityAction.Argument>
          <mtbwa:WriteBuildMessage Importance="Normal" Message="[outputData]" />
        </p:ActivityAction>
      </mtbwa:InvokeProcess.OutputDataReceived>
    </mtbwa:InvokeProcess>
  </p:Sequence>
</p:Activity>

You might notice that the various ActivityActions in the XAML (which handle writing the command-line and standard output to the build activity log as messages, and standard error as errors) are not present in the designer – we didn’t get around to implementing a custom designer for the InvokeProcess activity for Beta 1, so for this activity I had to "drop to XAML" and edit the XML directly.  Normally this shouldn’t be required – for the most part you should be able to fully author your custom activities in the WF Designer.  When my activity library project is built, this XAML will be compiled into an activity named InvokeScript (the name comes from the x:Class attribute on the root).

As mentioned above, activities can be composed either in xaml or in code.  To write a composed activity in code, you’ll derive from System.Activities.Activity, or System.Activities.Activity<T>, add whatever arguments and so forth you need, and then override the CreateBody method to return your composed implementation.  Here’s a second version of my InvokeScript activity, written in C# code:

using System;
using System.Activities;
using System.Activities.Statements;
using System.ComponentModel;
using Microsoft.TeamFoundation.Build.Workflow.Activities;
using Microsoft.TeamFoundation.VersionControl.Client;

namespace ActivityLibrary1
{
    public sealed class InvokeScript2 : Activity<Int32>
    {
        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> Arguments { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> Script { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<String> WorkingDirectory { get; set; }

        [Browsable(true)]
        [DefaultValue(null)]
        public InArgument<Workspace> Workspace { get; set; }

        protected override WorkflowElement CreateBody()
        {
            Variable<String> localScriptPath = new Variable<String>();
            Variable<String> commandLine = new Variable<String>();
            Variable<String> outputData = new Variable<String>();
            Variable<String> errorData = new Variable<String>();

            return new Sequence
            {
                Variables = 
                {
                    localScriptPath
                },
                Activities =
                {
                    new ConvertWorkspaceItem
                    {
                        Input = new InArgument<String>(env => this.Script.Get(env)),
                        Workspace = new InArgument<Workspace>(env => this.Workspace.Get(env)),
                        Result = localScriptPath
                    },
                    new InvokeProcess
                    {
                        FileName = new InArgument<String>(env => "cmd.exe"),
                        Arguments = new InArgument<String>(env => String.Format("/c \"{0}\" {1}", localScriptPath.Get(env), Arguments.Get(env))),
                        WorkingDirectory = new InArgument<String>(env => this.WorkingDirectory.Get(env)),
                        Result = new OutArgument<int>(env => this.Result.Get(env)),
                        BeforeExecute = new ActivityAction<String>
                        {
                            Argument = commandLine,
                            Handler = new WriteBuildMessage
                            {
                                Message = commandLine
                            },
                        },
                        OutputDataReceived = new ActivityAction<String>
                        {
                            Argument = outputData,
                            Handler = new WriteBuildMessage
                            {
                                Message = outputData
                            },
                        },
                        ErrorDataReceived = new ActivityAction<String>
                        {
                            Argument = errorData,
                            Handler = new WriteBuildError
                            {
                                Message = errorData
                            },
                        },
                    },
                },
            };
        }
    }
}

The primary difference between this version and the XAML version is that here I’ve elevated the single output argument (ExitCode) to the return value of the activity – hence it derives from Activity<Int32>.  The metaphor for activities is methods and functions – when you have a single output, the standard practice is to elevate it to the return value rather than using an OutArgument (but this is not, so far as I know, possible when writing an activity in XAML). 

Comments (7)

  1. Anonymous says:

    New Edition of RadioTFS – The Beta 1 Show Jakob Ehn on TFS Team Build 2010: Executing Unit Tests The

  2. Anonymous says:

    One of the really nice new features in TFS Build 2010 is support for symbol and source server. While

  3. mbulmahn@web.de says:

    Hallo Aaron,

    very intersting. But how can I write a "real" activity? I mean an activity that consits of pure code. I tried to create a wf library with a class inheried from System.Activitites.NativeActivity but I didn’t find a way to make this custom activity available in the build process editor. Can you give me some hint? Searching MSDN library for this topic was not successfull.

    Best regards,

    Mark

  4. smoomaw says:

    I’m hoping to extend the current build process for a variety of tasks (i.e. add a special work item upon successful completion of a build) to TFS 2010 builds.  So far, I have hit issues stumbling through implementing my changes in the workflow designer.  Do you have further documentation on how to extend the 2010 build process using WF designer and through custom-coded activities?  

    Any and all help is appreciated.

    Scott

  5. kfkyle says:

    Aaron,

    Do you plan on updating this sample for the Release Candidate?  I am having lots of compilation and validation issues with this code sample currently.  I would like to experiment with it for some build automations we plan on pulling forward from our legacy TFS 2005 TFSBuild.proj file.   This solution looks like it may help us with this problem.

    I’ve provided more detail on this on Jim Lamb’s blog as well.  

    http://blogs.msdn.com/jimlamb/archive/2010/02/12/How-to-Create-a-Custom-Workflow-Activity-for-TFS-Build-2010.aspx?CommentPosted=true#commentmessage

  6. Anonymous says:

    If I understand this correctly after Beta you can only use value types (and string) as InArguments.

    This means you can no longer pass in Workspace or you get the following error:

    [ 'Literal<Workspace>': Literal only supports value types and the immutable type System.String.  The type Microsoft.TeamFoundation.VersionControl.Client.Workspace cannot be used as a literal. ]

    Is there an easy workaround for this so I can use Workspace (and other value types) as input for a CodeActivity?