ALM for SharePoint Apps: Customizing the Build Process with a Custom Workflow Activity

This post shows how to create a custom customize the build process for provider-hosted apps with a custom workflow activity that will update the appmanifest.xml file for a provider-hosted app to point to different web servers.

Overview

This is part 3 in a series on ALM for SharePoint Apps.

In the previous two posts, I showed you the basic configuration of a TFS Team Build 2012 server leveraging Team Foundation Service in the cloud, how to create a basic build process, and how to configure continuous integration by leveraging a project from CodePlex.  This post focuses on customizing the build process with a custom workflow activity that updates the appmanifest.xml file as part of a build.

The appmanifest.xml file for a provider-hosted app includes the URL to the web server that contains the logic for the app.  It looks like this:

 <Properties>
  <Title>ALMDemo</Title>
  <StartPage>~remoteAppUrl/Pages/Default.aspx?{StandardTokens}</StartPage>
</Properties>

When you press F5, Visual Studio replaces that URL when it deploys the app to point to your development web server.  When you deploy the app outside Visual Studio, you need to update that ~remoteAppUrl token with the actual URL for your app.  When is the right time to do this?  What if you have different URLs for dev, test, and prod environments?  One solution might be to use a consistent URL and manage this through DNS, but that puts a bit of a burden on the infrastructure side of things.  You could update lots of host file entries, but again that is kind of difficult to manage.  The solution I came up with was to manage this as part of the build process.  Visual Studio 2012 does not provide a way to do this out of box, so we need to create a custom workflow activity to accomplish this.

NOTE: None of this is necessary.  I am showing you to help you understand the mechanics of how things could be done, but there is a much easier way to do this using app publishing as described in a follow-up post, Part 4 – ALM for SharePoint Apps: Understanding Provider Hosted App Publishing.

Create a New Workflow Activity Library Project

The first step is to Create a new Workflow Activity Library project in Visual Studio 2012.

image

Add references to the following assemblies:

  • Microsoft.TeamFoundation.Build.Client
  • Microsoft.TeamFoundation.Build.Workflow
  • System.Drawing

The Microsoft.TeamFoundation.Build.* assemblies can be found at:

C:\Program Files (x86)\Microsoft Visual Studio 11.0\Common7\IDE\ReferenceAssemblies\v2.0

Add a new activity named UpdateAppManifestRemoteAppUrl.xaml.

image

Next, add a new Code Activity named ReplaceInFile.cs.

image

Next, delete the default activity, Activity1.xaml.  Your project will look like this:

image

Now that we have the project structure, let’s implement the details.

Implement the ReplaceInFile Activity

The ReplaceInFile activity was stolen borrowed from Jim Lamb’s blog post on creating a custom workflow activity for TFS Build 2010 to update build versions.  The activity is perfect for our needs.  Rather than redirect you to download his source and extract the code, I am posting the code here.  Again, all credit to Jim Lamb for his work, I honestly wouldn’t have thought about the file attributes issue that his code addresses.

 using System;
using System.Activities;
using System.IO;
using System.Text.RegularExpressions;

using Microsoft.TeamFoundation.Build.Client;

namespace AppBuildActivities
{

    /// <summary>
    /// Workflow activity that replaces all occurrences of a regular expression
    /// in a text file with the specified text.
    /// </summary>
    [BuildActivity(HostEnvironmentOption.Agent)]
    public sealed class ReplaceInFile : CodeActivity
    {
        #region Properties

        /// <summary>
        /// Specify the path of the file to replace occurences of the regular 
        /// expression with the replacement text
        /// </summary>
        [RequiredArgument]
        public InArgument<string> FilePath
        {
            get;
            set;
        }

        /// <summary>
        /// Regular expression to search for and replace in the specified
        /// text file.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> RegularExpression
        {
            get;
            set;
        }

        /// <summary>
        /// Text to replace occurrences of the specified regular expression with.
        /// </summary>
        [RequiredArgument]
        public InArgument<string> Replacement
        {
            get;
            set;
        }

        #endregion Properties

        #region Methods

        /// <summary>
        /// Executes the logic for this workflow activity
        /// </summary>
        /// <param name="context">Workflow context</param>
        /// <exception cref="ArgumentException">If the specified context does not
        /// contain a FilePath and a RegularExpression, an ArgumentException 
        /// is thrown.</exception>
        protected override void Execute(CodeActivityContext context)
        {
            // get the value of the FilePath in argument from the workflow context
            String filePath = FilePath.Get(context);

            // throw if the file path is null or empty
            if (String.IsNullOrEmpty(filePath))
            {
                throw new ArgumentException(
                    "Specify a path to replace text in", "FilePath");
            }

            // throw if the specified file path doesn't exist
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(
                    "File not found", filePath);
            }

            // get the value of the RegularExpression in argument
            String regularExpression = context.GetValue(RegularExpression);

            // throw if the regular expression is null or empty
            if (String.IsNullOrEmpty(regularExpression))
            {
                throw new ArgumentException(
                    "Specify an expression to replace", "RegularExpression");
            }

            var regex = new Regex(regularExpression);
            var replacement = Replacement.Get(context) ?? String.Empty;

            // ensure that the file is writeable
            FileAttributes fileAttributes = File.GetAttributes(filePath);
            File.SetAttributes(filePath, fileAttributes & ~FileAttributes.ReadOnly);

            // perform the actual replacement
            String contents = regex.Replace(File.ReadAllText(filePath), replacement);

            File.WriteAllText(filePath, contents);

            // restore the file's original attributes
            File.SetAttributes(filePath, fileAttributes);
        }

        #endregion Methods
    }
}

I am not going to provide much detail on this, because the code is pretty straightforward.  The workflow activity accepts arguments for a file path, a regular expression for the thing we want to replace, and the value to replace it with. It then sets the file attributes so that we can update the file, and reverts the file attributes back to what they were when done.

Implement the UpdateAppManifestRemoteAppUrl Activity

Where the previous activity managed everything through code, we will now create a XAML activity that will manage everything declaratively.  The basic structure of our new workflow activity is:

  • Validate arguments <Sequence>
  • Find Matching Files <FindMatchingFiles>
  • Handle Matching Files <If>

I start by creating the basic structure by dragging each activity to the diagram and renaming each.

image

Once we have the basic structure, we can define the implementation for each step as well as define what parameters our workflow activity will require.

Implement Activity Arguments

The activity really only needs two arguments: the path to a directory to look for the appmanifest.xml file, and the value to replace the ~remoteAppUrl value with.  Click the Arguments tab and add two parameters:

Name Direction Argument Type
DirectoryForSource In String
RemoteAppUrl In String

Here’s what it looks like in Visual Studio 2012.

image

The arguments will be used by the workflow that will contain the activity to pass parameters to our workflow activity.

 

Implement the Validate Arguments Sequence

This sequence will check the incoming parameters and do some basic validation.  Add two If conditions and rename them to something more descriptive than “If”.  The first If condition, “Validate Directory for Source”, will check to see if the parameter exists and if it is a valid directory. The Condition will be:

 (string.IsNullOrEmpty(DirectoryForSource)) || (!System.IO.Directory.Exists(DirectoryForSource))

If the parameter is null or empty, or it’s not a valid path, then we’ll use a Throw activity to throw the following exception.

 new ArgumentException("Please provide a valid path to the source code","DirectoryForSource")

This will look like the following:

image

The next If condition, “Validate Remote App URL”, will just check to see if the parameter value was provided.  The Condition:

 string.IsNullOrEmpty(RemoteAppUrl)

We add a Throw activity again, with the exception:

 

 new ArgumentException("Please provide a value to replace the ~remoteAppUrl value with", "RemoteAppUrl")

The complete implementation for the Validate Arguments sequence now looks like:

image

Implement the FindMatchingFiles Activity

The FindMatchingFiles activity is an activity from TFS Build.  This activity will find matching files based on a pattern and will return the value.  For the MatchPattern property, we give a value:

 String.Format("{0}\\**\\appmanifest.xml", DirectoryForSource)

The Result from the activity needs to be stored in a variable.  Go to the Variables tab at the bottom of the designer and create a new variable, MatchingFiles of type IEnumerable<string>.  When choosing the type, you can use Browse for .NET Types to choose the type:

image

The variable looks like this in the designer.

image

Finally, assign the MatchingFiles variable to the Result property of the FindMatchingFiles activity.

image

Implement the Handle Matching Files Sequence

Once we have the matching files from TFS, we will test to see if there are, in fact, any matching files.  If not, let’s add a build message to say none were found.  If they were found, we iterate through them and use our ReplaceInFile code activity to replace the contents.

The If Condition will simply be:

 MatchingFiles.Any()

In the Then branch, add a ForEach<string> to iterate over the MatchingFiles variable.  Rename the ForEach to something more descriptive, like “For Each File in Matching Files”.  The Body will contain a sequence, “Process Matching Files”.  Inside this sequence, add a WriteBuildMessage activity with the description:

 "Replacing ~remoteAppUrl value in " + item + " with " + RemoteAppUrl

At this point, we haven’t yet built the solution.  Build the solution, and our ReplaceInFile activity should show up in the toolbox.  The ReplaceInFile activity now shows up in the toolbox.  Add it below the WriteBuildMessage activity with the following parameters:

  • FilePath: item
  • Regular Expression: “~remoteAppUrl”
  • Replacement: RemoteAppUrl

image

 

In the Else condition for Handle Matching Files, add a WriteBuildWarning activity with the message:

 "No appmanifest.xml files were found in the directory " + DirectoryForSource

The complete Handle Matching Files If activity looks like:

image

At this point, we’re done with the XAML activity, the complete activity looks like:

image

Build the solution again to make sure there are no compile errors.  Finally, sign the assembly with a strong name (Project Properties / Signing)

image

Add the Solution to Source Control

This part isn’t relevant to our solution, but check it into source control.

image

Configure CustomAssemblies for the Build Controller

This part, however, is relevant.  We have a new workflow activity that we’ll include as part of a build process template.  There are multiple ways to deploy our activity, such as registering in the GAC or deploying to one of the folders for TFS.  A better way is to manage it through TFS.

Add a new folder to the root of the project in source control explorer called “CustomAssemblies”.

image

Check in the pending change, and then go look at your folder on disk and there’s a new folder CustomAssemblies.

image

Go to the bin directory for your project that you just built and copy the AppBuildActivities.dll and AppBuildActivities.pdb files to the CustomAssemblies folder.

image

Now go back to Source Control Explorer.  Right-click on the CustomAssemblies folder and choose “Add New Items to Folder” and add the two assemblies.

image

Check in pending changes.  Now that the assemblies are in source control, go to the Builds menu in Team Explorer and choose Actions / Manage Build Controllers.

image

Go to your build controller and click Properties, then provide the version control path to custom assemblies.

image

We just told TFS how to find our custom workflow activity.

Update the Process Template

In my previous article ALM for SharePoint Apps: Implementing Continuous Integration I showed how to create a build process template using a copy of an existing template, and named my template “MyAppBuildProcessTemplate.xml”.  Open the build definition, go to the Process tab, and expand the Build process template section.

image

Click the link to the build process template to navigate to source control, and then open the file for editing.  The build process template is a workflow.

Start by adding a parameter to the workflow, RemoteAppUrl.  Go to the Arguments tab and add a new argument RemoteAppUrl of type string.

image

We want to add our custom activity to the workflow, but the activity is not yet showing the Toolbox.  Right-click the Toolbox and choose “Add New Tab” and call it “Custom Build Activities”.  Then right click in that new tab and click “Choose Items”.  Navigate to the CustomAssemblies folder that you created previously and choose the AppBuildActivities.dll file.

image

image

Our activity now shows on the Toolbox, and we can now drag it onto the design surface.

Note: If your activity cannot drag onto the design surface, here is what I got to work.  There may be another way, but I’ll admit this one cost me a few dollars in the swear jar.  When you edit the workflow, it’s not part of a project, so it cannot resolve the assembly reference.  Putting the assembly in the GAC ensures it can be resolved.

  1. Delete the custom activity from the toolbox
  2. Close Visual Studio 2012
  3. Go to the Team Foundation Server Administration Console and restart the build controller (to clear the cache).
  4. Open the Developer Command Prompt for VS2012.  Change directory to the CustomAssemblies folder and run the following command:

gacutil /i AppBuildActivities.dll

image

Now open Visual Studio, open the process template, and add your activity back onto the toolbar.This time you should be able to drag the activity to the design surface.  Note that we only needed to do this during the design process, we will be able to remove the assembly from the GAC and TFS will be able to resolve the assembly through the CustomAssemblies path we configured earlier.

Finding where to drop the activity can be a challenge.  On the workflow design surface, choose “Collapse All”.  Scroll down about halfway and expand the “Run On Agent” sequence, and then expand the “Initialize Workspace” sequence.  We want to add our custom activity just below the “Get Workspace” activity.

image

The properties for the UpdateAppManifestRemoteUrl activity are:

  • DirectoryForSource: SourcesDirectory
  • RemoteAppUrl: RemoteAppUrl

image

Save the workflow and check it in.

I took the extra step of then removing the assembly from the GAC to prove that the assembly is, in fact, located from the source control path that we configured previously and not the GAC.  I also restarted Visual Studio for good measure.

image

Edit the Build Definition

Go back to the build definition and choose edit.  Go to the Process tab, and a new parameter should show up, “RemoteAppUrl”.  Give it a value to replace the ~remoteAppUrl token in the appmanifest.xml file with.  The value I used was https://almdev.contoso.lab, no quotes around it.

image

 

Also double-check that your appmanifest.xml file in the project being built actually has the ~remoteAppUrl property in it, otherwise we did all this hard work for nuthin’.

image

Cross Your Fingers

The last step… initiate a new build.  Notice that it shows our activity

image

And once everything is complete, we see the happy report.

image

Go to the Drop folder.  Notice that it contains an app.publish folder which contains our app package.  Make a copy, rename the file to ZIP.

image

Open the file, and then open the appmanifest.xml file to verify the ~remoteAppUrl has been replaced.

image

Go to the link in the report to trust the app.

image

Finally open the app…

image

And now you can breathe.  It all worked.

Summary

Think about everything that we’ve done in this series.  We are now using TFS in the cloud with a TFS Team Build 2012 server in a virtual machine.  TFS Build downloads the source and runs through a custom workflow.  In that workflow, just after downloading the source, our custom workflow activity changes the appmanifest.xml based on parameters from the build configuration.  The source is built, and the .app package contains the correct <StartPage></StartPage> URL that points to the environment that corresponds to the build.  The app is automatically deployed to SharePoint and it is deployed to the IIS server hosting our app.

What does all this mean for us as developers?  We write our code, smoke test it on our local machine, and check the source in.  The build process kicks off, builds the source, and deploys the app to an environment for testing.  Maybe next up we should look at how we can add some testing to make sure everything works.

The next post will show why this post was just an indulgent diversion to help you understand what’s possible with TFS Team Build 2012.  The next post shows that there is a much easier way to do this using app publishing: Part 4 – ALM for SharePoint Apps: Understanding Provider Hosted App Publishing.

For More Information

Create a Custom WF Activity to Sync Version and Build Numbers (source for the ReplaceInFile activity)

Customize Team Build 2010 – Part 16: Specify the relative reference path

ALM for SharePoint Apps: Configuring a TFS Build Server with Team Foundation Service

ALM for SharePoint Apps: Implementing Continuous Integration

AppBuildActivities.zip