Team Build DevEnv Task

Because many Visual Studio project types are not supported in MSBuild, many Team Build users end up needing to invoke DevEnv directly.  There is a fair amount of confusion about how to do this best / simplest - I've written two posts (here and here) on the issue already! 

As such, I thought it would be helpful to write a DevEnv task that could be used to invoke DevEnv from MSBuild during the course of a Team Build build, along with some guidance on how to use it best.  I'll go through how to use the attached DLL for those who just want to use the task, go into a few chunks of the source code for those interested in how the task works, and then go through an example of how to use the task to build a setup project (*.vdproj).  A note for the lawyers: I can make no guarantees as to the awesomeness of this task or lack thereof - it is provided "as is" with no warranties and confers no rights. 

How To Use It

Note that this task uses the Team Build Orcas (Visual Studio 2008) Object Model, and as such will not work with Team Build v1 (Visual Studio 2005).  Beta 1 can be downloaded here, and Beta 2 will be available in fairly short order.  To use the task, download the attached zip file and extract OrcasMSBuildTasks.dll.  You'll then need to either:

  1. Add OrcasMSBuildTasks.dll to source control in the same directory as your TfsBuild.proj file.  If you go this route, add the following <UsingTask> declaration to your TfsBuild.proj file:

    <UsingTask TaskName="DevEnv" AssemblyFile="OrcasMSBuildTasks.dll" />

  2. Install OrcasMSBuildTasks.dll to a known location on each of your build machines - C:\Program Files\MSBuild\Microsoft\VisualStudio\TeamBuild, for example.  If you go this route, add the following <UsingTask> declaration to your TfsBuild.proj file(s):

    <UsingTask TaskName="DevEnv" AssemblyFile="C:\Program Files\MSBuild\Microsoft\VisualStudio\TeamBuild\OrcasMSBuildTasks.dll" />

You should then be able to use the DevEnv task in your Team Build build scripts (TfsBuild.proj files) just as you would any other task.  Here is the recommended approach for incorporating the task into your build process - see the Building a Setup Project section below for a specific example and a discussion of the aproach:

   <PropertyGroup>
    <CustomizableOutDir>true</CustomizableOutDir>
  </PropertyGroup>

  <Target Name="AfterCompileSolution">

    <DevEnv TeamFoundationServerUrl="$(TeamFoundationServerUrl)" 
            BuildUri="$(BuildUri)"
            Solution="$(Solution)"
            SolutionConfiguration="$(Configuration)"
            SolutionPlatform="$(Platform)" 
            Target="Build" 
            Version="9" />

    <ItemGroup>
      <SolutionOutputs Condition=" '%(CompilationOutputs.Solution)' == '$(Solution)' " Include="%(RootDir)%(Directory)**\*.*" />
      <!-- Add any additional outputs which need to be copied here. -->
    </ItemGroup>

    <Copy SourceFiles="@(SolutionOutputs)" DestinationFolder="$(TeamBuildOutDir)" />

  </Target>

If you want to modify the task, the source code is also available in the attachment.  Just open up the solution in Visual Studio, adjust the assembly references for Microsoft.TeamFoundation.Build.Client and Microsoft.TeamFoundation.Client if necessary, and you should be all set.

The DevEnv Task Details

Rather than implement this thing from scratch (as in my previous post), I decided to leverage the work the MSBuild team did in creating the ToolTask abstract class, which "...provides default implementations for the methods and properties of a task that wraps a command line tool".  Various MSBuild tasks are derived from ToolTask, including AL, CSC, etc. 

 public class DevEnv : ToolTask

The important property/method overrides for classes that derive from ToolTask are:

  • ToolName , which gives the name of the command line tool to be executed.  In our case this is DevEnv.com (not DevEnv.exe - see here for why).
  • GenerateFullPathToTool , which locates the appropriate command line executable (when the user hasn't explicitly pointed to it, using the ToolPath property).

The DevEnv task uses the registry key value for HKLM\Software\Microsoft\VisualStudio\<version>.0, where version is a property that can be set for the task.  This way, you can execute VS 2005 if needed (version 8.0), VS 2008 if needed (version 9.0 - this is the default), etc.  Here's the full method:

 /// <summary>
/// Determines the full path to DevEnv.com, if this path has not been explicitly specified by the user.
/// </summary>
/// <returns>The full path to DevEnv.com, or just "DevEnv.com" if it's not found.</returns>
protected override string GenerateFullPathToTool()
{
    string regKey = string.Format(CultureInfo.InvariantCulture, m_vsInstallRegKeyTemplate, m_version);
    string path = string.Empty;

    using (RegistryKey key = Registry.LocalMachine.OpenSubKey(regKey))
    {
        if (key != null)
        {
            path = key.GetValue("InstallDir") as String;
        }
    }

    if (string.IsNullOrEmpty(path))
    {
        return ToolExe;
    }
    else
    {
        return Path.Combine(path, ToolExe);
    }
}

These few overrides would be enough to get the task up and running.  To make it a bit more useful for Team Build users, I've added additional logic in an override of the LogEventsFromTextOutput method that detects the projects being built, errors and warnings encountered, etc. and adds the appropriate corresponding information to the Team Build database.  Here's the full method:

         /// <summary>
        /// Log standard error and standard out. Overridden to add build steps for important messages and to detect errors and warnings.
        /// </summary>
        /// <param name="singleLine">A single line of stderr or stdout.</param>
        /// <param name="messageImportance">The importance of the message. Controllable via the StandardErrorImportance and StandardOutImportance properties.</param>
        protected override void LogEventsFromTextOutput(String singleLine, MessageImportance messageImportance)
        {
            Match match;

            // Add build steps for important messages.
            if (messageImportance == MessageImportance.High)
            {
                BuildStep.Add("DevEnv Message", singleLine, DateTime.Now, BuildStepStatus.Succeeded);
            }

            // Detect project compilation and insert a build step and compilation summary.
            match = ProjectCompilationRegex.Match(singleLine);

            if (match.Success)
            {
                // Update the existing project build step, if we have one.
                UpdateProjectBuildStep();

                CompilationSummary = ConfigurationSummary.AddCompilationSummary();
                CompilationSummary.ProjectFile = match.Groups["Project"].Value;

                ProjectBuildStep = BuildStep.Add(CompilationSummary.ProjectFile, "DevEnv is building project " + CompilationSummary.ProjectFile, DateTime.Now);
            }
            // Detect static analysis errors and warnings and update the compilation summaries.
            else if (StaticAnalysisErrorRegex.IsMatch(singleLine))
            {
                if (CompilationSummary != null)
                {
                    CompilationSummary.StaticAnalysisErrors++;
                }
                m_errorEncountered = true;
            }
            else if (StaticAnalysisWarningRegex.IsMatch(singleLine))
            {
                if (CompilationSummary != null)
                {
                    CompilationSummary.StaticAnalysisWarnings++;
                }
            }
            // Detect errors and warnings and update the compilation summaries.
            else if (ErrorRegex.IsMatch(singleLine))
            {
                if (CompilationSummary != null)
                {
                    CompilationSummary.CompilationErrors++;
                }
                m_errorEncountered = true;
            }
            else if (WarningRegex.IsMatch(singleLine))
            {
                if (CompilationSummary != null)
                {
                    CompilationSummary.CompilationWarnings++;
                }
            }

            // Call the ToolTask implementation to make sure events get logged to the attached MSBuild loggers.
            base.LogEventsFromTextOutput(singleLine, messageImportance);
        }

Note the general approach here, which is to use regular expressions to match specific events in stdout and stderr and then take action accordingly.  Obviously this approach is not nearly as powerful as the rich eventing MSBuild provides to attached loggers, but it's the best we can do when executing a command line tool like DevEnv!  Here are the strings for the various regular expressions:

         private const String m_projectCompilation = @"Build started: Project: (?<Project>[^,]+), Configuration:";
        private const String m_caWarning = @"warning\s*:?\s*(?<Code>CA[^\s:]+)\s*:\s*(?<Text>.*)$";
        private const String m_caError = @"error\s*:?\s*(?<Code>CA[^\s:]+)\s*:\s*(?<Text>.*)$";
        private const String m_warning = @"warning\s*:?\s*(?<Code>[^\s:]+)\s*:\s*(?<Text>.*)$";
        private const String m_error = @"error\s*:?\s*(?<Code>[^\s:]+)\s*:\s*(?<Text>.*)$";

Again, the full source is available in the attachment.

Building a Setup Project

One of the most common questions we get for Team Build is how to build a setup project.  Blog posts can be found on the topic here and here.  An MSDN walkthrough can be found here.  I get 122 hits on our forums when I do a search for "setup project"

In trying to use my fancy new task to build a setup project, I first tried to copy the MSDN walkthrough and did something like this:

   <UsingTask TaskName="DevEnv" AssemblyFile="OrcasMSBuildTasks.dll" />
  
  <Target Name="AfterCompile">
    <DevEnv TeamFoundationServerUrl="$(TeamFoundationServerUrl)"
            BuildUri="$(BuildUri)"
            Project="$(SolutionRoot)\Setup1\Setup1.vdproj"
            SolutionConfiguration="Debug"
            SolutionPlatform="Any CPU"
            Target="Build"
            Version="8" />
    <Copy SourceFiles="$(SolutionRoot)\Setup1\Debug\Setup1.msi" DestinationFolder="$(OutDir)" />
    <Copy SourceFiles="$(SolutionRoot)\Setup1\Debug\Setup.exe" DestinationFolder="$(OutDir)" />
  </Target>

Unfortunately, I discovered that this did not do exactly what I expected...  My setup project had been defined to copy the project outputs of another project in its solution to a particular spot on the target file system - I imagine this is a fairly common scenario in other setup projects out there!  Well, the setup project in this case didn't find the project outputs where it expected them to be (since Team Build by default redirects them to its Binaries directory), and as such rebuilt the entire solution!  So - not only did my build process waste time in compiling the solution twice, but it also built a setup from completely different binaries than the ones copied out to the drop location...  This is obviously not ideal. 

Luckily, due to some new features in Orcas there is a pretty decent workaround here.  Here is my recommended approach for building setup projects (and any other projects not supported by MSBuild):

   <PropertyGroup>
    <!-- Tell Team Build not to override $(OutDir), so that we can build once from MSBuild and not
         rebuild when DevEnv.com is executed. 
    -->
    <CustomizableOutDir>true</CustomizableOutDir>
  </PropertyGroup>

  <Target Name="AfterCompileSolution">

    <!-- Use the DevEnv task to build our setup project. -->
    <DevEnv TeamFoundationServerUrl="$(TeamFoundationServerUrl)" 
            BuildUri="$(BuildUri)"
            Solution="$(Solution)"
            SolutionConfiguration="$(Configuration)"
            SolutionPlatform="$(Platform)" 
            Target="Build" 
            Version="9" />

    <!-- Copy all compilation outputs for the solution AND the setup project to the Team Build out dir
         so that they are copied to the drop location, can be found by unit tests, etc.
    -->
    <ItemGroup>
      <SolutionOutputs Condition=" '%(CompilationOutputs.Solution)' == '$(Solution)' " Include="%(RootDir)%(Directory)**\*.*" />
      <SolutionOutputs Include="$(SolutionRoot)\Setup1\$(Configuration)\**\*.*" />
    </ItemGroup>

    <Copy SourceFiles="@(SolutionOutputs)"
          DestinationFolder="$(TeamBuildOutDir)" />

  </Target>

This approach works quite nicely, and is extensible to any project type that cannot be built by MSBuild.  Here's how it works:

  • The standard Team Build build process will build the solution, using MSBuild.  Because the new CustomizableOutDir property is set to true (see here for more info on this property) the generated outputs will all end up in their standard locations - crucially, in the same locations they would be in if the build had been done using DevEnv.
  • Any non-MSBuild projects will generate warnings here (which look something like: "warning MSB478: The project file 'foo.vdproj' is not supported by MSBuild and cannot be built."), but these can be safely ignored.
  • The customized AfterCompileSolution will then be executed, and the same solution will be built with the same configuration and platform, but this time it will be built by DevEnv.  The outputs of all the projects that could be built by MSBuild will already exist, and will not be re-generated.  The various projects that could not be built by MSBuild, on the other hand, including our setup project, will be built by DevEnv and should have no trouble finding their dependencies.
  • The outputs generated by the entire build process will be in their default locations (since we set CustomizableOutDir to true) so we then need to copy them to the spot where Team Build expects to find them...  Luckily, another new feature in Team Build for Orcas provides the solution here - the outputs of the build are aggregated into the CompilationOutputs item group and can then be accessed later in the build process.  The SolutionOutputs item group is used to gather up all these build outputs, which are then copied to $(TeamBuildOutDir), which specifies the value Team Build would have used for $(OutDir) if CustomizableOutDir had not been true.

Potential Extensions

I didn't add any logic to write errors and warnings to the various log files generated by Team Build - ErrorsWarningsLog.txt, which is linked to from created work items; Debug.txt (generated for Debug / Any CPU builds) and the other configuration specific files, which are linked to in the build report. 

I didn't bother making the task usable outside of Team Build.  It might be nice to create (a) a version which can be run from MSBuild in any environment, or (b) a version that interacts with Team Build when possible but doesn't require that interaction.

Please let me know what you think, post any issues you run into, etc.

DevEnvTask.zip