The first official build task

As I stated when I started this whole exercise, I talked about how I wanted to use MSBuild for the post build steps because of the "functionality and extensiblitity that goes along with [it]." The "functionality" I was referring to were the tasks inherently available to msbuild, from Exec to SignFile. With just a little bit of research, it should be clear after my last couple of posts how one can make use of these built-in tasks via a post-build step for setup projects. But what about the issue of "extensibility"? I guess building a bunch of .exe tools (like I did last time) and calling them via an Exec task could be considered "extensible", but I don't think it is. We would like to be able to define our own tasks and group them together, and MSBuild provides this functionality. Now that would be extensible!

MSBuild allows you to create your own dll which defines your specialized set of tasks. There are ways to specify input and even output from those tasks, and a unique way make MSBuild sware of everything. We'll see how to do all of this by modifying our exe solution from last time just a little bit.

Last time, we wrote an executable that took 3 parameters: the database to modify, the media entry, and the name of the signed cabiet file. We would want our task to do something similar. Here is how we might call this new task from our project file:

 <PopulateDigitalSignature
    Database="$(BuiltOutputPath)"
    Media="1"
    Cabinet="%(BuiltCabinetFile.Identity)"
/>

Even though we required all 3 parameters last time around, let's only require 2 this time: the Database and the Cabinet; if the Media value is not supplied, we will use "1" by default.

So, now that we know what our task should look like, how do we go about implementing it? When I first started learning about how to create my own MSBuild task, there were 2 particular MSDN resources I used. First was an Alex Kipman MSDN TV  webisode about MSBuild. Its a little out of date now as some of the MSBuild syntax has changed, but it still details a lot of the basics that I'm going to talk about in the rest of this entry. I also used the "Web Deployment Projects" which shipped out of band with VS 2005. There are probably newer resources out there that one could use (this one looks promising), but hopefully I'll explain things well enough in this blog for you to follow along.

To start with, our task will reside in its own separate Class Library. So let's add a new C# Class library to our solution, calling it SetupProjects.Tasks.

We're going to take baby steps with this so that when things go wrong it will be easy to pin-point the problem. So rather than creating our fully-functional task right away, we'll take incremental steps via a much simpler task, called "SayHello", which will do nothing more than print (what else) "Hello, world!" when building. So, lets rename Class1.cs to something more appropriate: SayHello.cs.

For inspiration on what to do next, let's take a closer look at something we've seen before: the Message task. The Message task extends Microsoft.Build.Tasks.TaskExtension, so it makes sense that our new task would want to do something similar. However, the comment in the docs saying "This class supports the MSBuild infrastructure and is not intended to be used directly from your code" scares me a little bit. So, let's add a reference to Microsoft.Build.Utilities to our project, and have our task implement TaskExtension's parent class, Microsoft.Build.Utilities.Task:

 using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Build.Utilities;

namespace SetupProjects.Tasks
{
  public class SayHello : Task
    {
   }
}

Building this yields an error:

 The type 'Microsoft.Build.Framework.ITask' is defined in an assembly that is not referenced. You must add a reference to assembly 'Microsoft.Build.Framework, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'.

Let's try to make this happy by adding the Microsoft.Build.Framework reference. I did that, re-compiled and got this next compilation gave me this:

 'SetupProjects.Tasks.SayHello' does not implement inherited abstract member 'Microsoft.Build.Utilities.Task.Execute()'

So, we evidently need to add the Execute method, which we see from intellisense returns a bool and takes no parameters. Let's replace the default implementation with something more like what we want to do: a call to System.Console.WriteLine(). We'll also need to to return a value, in this case "true" to indicate success:

 using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Build.Utilities;

namespace SetupProjects.Tasks
{
  public class SayHello : Task
    {
       public override bool Execute()
      {
           System.Console.WriteLine("Hello, world!");
          return true;
        }
   }
}

This is successfully compiled, so we should be ready to use our task. Let's update the SetupSignCabs post-build project file to call this new task as the first item in the SignSetup target:

     <Target Name="SignSetup">
        <SayHello />
    </Target>

Building this project yields another error:

 E:\MWadeBlog\SetupSignCabs\PostBuild.proj(16,9): error MSB4036: The "SayHello" task was not found. Check the following: 1.) The name of the task in the project file is the same as the name of the task class. 2.) The task class is "public" and implements the Microsoft.Build.Framework.ITask interface. 3.) The task is correctly declared with <UsingTask> in the project file, or in the *.tasks files located in the "C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727" directory.

Issues 1 and 2 from above don't appear to be an issue: the project file and class name check out (1), the class is public and extends Microsoft.Build.Utilities.Task which implements the Microsoft.Build.Framework.ITask interface (2). So, issue 3 must be the problem. Lets try to use UsingTask in the project file. Looking at the documentation for UsingTask, we see that it should be necessary to write both the name of the task we want to use as well as the assembly the task is in. The question is, what is the path of the assembly file to use? We have several options.

We could copy the assembly to the same location as our project file, much like we did with the executable we wrote last time. I don't like this solution, as that would require us to copy the assembly file everywhere we wanted to use it.

We could have the task use a relative path to the assembly, but that doesn't seem very good either: it will be hard for future project files to get the location to the file.

So, it seems like we need to move the file to a fully qualified location that all tasks should be able to access. We could do something like the doc suggests, and put our assembly in a file off of the C:\ directory. But that still doesn't seem very robust, as it would require everybody who uses my task to have a C:\ directory.

The docs don't help a whole lot in figuring this out, but the Web Deployment projects article I mentioned before sort of mentions it: there is an MSBuild reserved property which would seem to do the trick: $(MSBuildExtensionsPath). So, let's have a post-build event which will copy our target tasks assembly to this special directory. Open up the Build Events property page of our tasks project, and add the following Post-build event:

 copy "$(TargetPath)" "$(MSBuildExtensionsPath)\$(TargetFileName)"

Building the project seems to get this file copied; now let's have our task use it by adding the following as the first child element of the Project node:

     <UsingTask TaskName="SayHello" AssemblyFile="$(MSBuildExtensionsPath)\SetupProjects.Tasks.dll" />

Which seems to work just fine:

 Build started 11/20/2006 1:39:09 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):


Hello, world!

Build succeeded.
    0 Warning(s)
    0 Error(s)

Sweet! Let's see about adding our real task to this assembly. Let's rename SayHello.cs to PopulateDigitalSignature.cs and add properties to act as task parameters:

 using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Build.Utilities;

namespace SetupProjects.Tasks
{
 public class PopulateDigitalSignature : Task
    {
       private string database = string.Empty;
     private string media = "1";
     private string cabinet = string.Empty;

      public string Database
      {
           get { return database; }
            set { database = value; }
       }

       public string Media
     {
           get { return media; }
           set { media = value; }
      }

       public string Cabinet
       {
           get { return cabinet; }
         set { cabinet = value; }
        }

       public override bool Execute()
      {
           System.Console.WriteLine(string.Format("Database: {0}", Database));
         System.Console.WriteLine(string.Format("Media: {0}", Media));
           System.Console.WriteLine(string.Format("Cabinet: {0}", Cabinet));
           return true;
        }
   }
}

After updating our post-build project file to use the PopulateDigitalSignature task instead of the SayHello task gives us:

 Build started 11/20/2006 1:41:19 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):

Database: 
Media: 1
Cabinet: 

Build succeeded.

More progress. Next, we need to somehow mark the Database and Cabinet parameters as required. I found (via the docs for the ITask interface) that there is a special "Required" attribute (Microsoft.Build.Framework.RequiredAttribute) that we can add to our task properties to get them required:

         [Microsoft.Build.Framework.Required]
        public string Database
      {
           get { return database; }
            set { database = value; }
       }

       public string Media
     {
           get { return media; }
           set { media = value; }
      }

       [Microsoft.Build.Framework.Required]
        public string Cabinet
       {
           get { return cabinet; }
         set { cabinet = value; }
        }

And a build of the setup project shows:

 Build started 11/20/2006 1:42:19 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):

Target SignSetup:
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(17,9): error MSB4044: The "PopulateDigitalSignature" task was not given a value for the required parameter "Database".
Done building target "SignSetup" in project "PostBuild.proj" -- FAILED.

Done building project "PostBuild.proj" -- FAILED.

Build FAILED.

Looks like we need to update our task to use these new required parameters. Let's use the commandline arguments we used from last time:

 Build started 11/20/2006 1:43:58 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):

Database: E:\MWadeBlog\SetupSignCabs\Debug\SetupSignCabs.msi
Media: 1
Cabinet: E:\MWadeBlog\SetupSignCabs\Debug\SETUP.CAB

Build succeeded.

Now all that's left is to copy the content from the exe tool to the task (with a little bit of variable re-naming so that it compiles):

 using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using WindowsInstaller;

namespace SetupProjects.Tasks
{
  public class PopulateDigitalSignature : Task
    {
       private string database = string.Empty;
     private string media = "1";
     private string cabinet = string.Empty;

      [Required]
      public string Database
      {
           get { return database; }
            set { database = value; }
       }

       public string Media
     {
           get { return media; }
           set { media = value; }
      }

       [Required]
      public string Cabinet
       {
           get { return cabinet; }
         set { cabinet = value; }
        }

       public override bool Execute()
      {
           string certificateFileName = string.Empty;
          try
         {
               Type classType = Type.GetTypeFromProgID("WindowsInstaller.Installer");
              Object installerClassObject = Activator.CreateInstance(classType);
              Installer installer = (Installer)installerClassObject;

              Array certificateArray = installer.FileSignatureInfo(Cabinet, 0, MsiSignatureInfo.msiSignatureInfoCertificate);
             certificateFileName = Path.GetTempFileName();
               using (BinaryWriter fout = new BinaryWriter(new FileStream(certificateFileName, FileMode.Open)))
                {
                   fout.Write((byte[])certificateArray);
               }

               Database msi = installer.OpenDatabase(Database, MsiOpenDatabaseMode.msiOpenDatabaseModeTransact);
               View viewCert = msi.OpenView("SELECT * FROM `MsiDigitalCertificate`");
              viewCert.Execute(null);
             View viewSig = msi.OpenView("SELECT * FROM `MsiDigitalSignature`");
             viewSig.Execute(null);

              Record recordCert = installer.CreateRecord(2);
              recordCert.set_StringData(1, "Test");
               recordCert.SetStream(2, certificateFileName);
               viewCert.Modify(MsiViewModify.msiViewModifyInsert, recordCert);

             Record recordSig = installer.CreateRecord(4);
               recordSig.set_StringData(1, "Media");
               recordSig.set_StringData(2, Media);
             recordSig.set_StringData(3, "Test");
                viewSig.Modify(MsiViewModify.msiViewModifyInsert, recordSig);

               msi.Commit();
           }
           catch (Exception ex)
            {
               Log.LogErrorFromException(ex);
              return false;
           }
           finally
         {
               if (!string.IsNullOrEmpty(certificateFileName) && File.Exists(certificateFileName))
             {
                   File.Delete(certificateFileName);
               }
           }

           return true;
        }
   }
}

Note that I also got rid of the System.Console usage in the catch clause: using an MSBuild log is the more MSBuild-way to do this sort of stuff. All that's left is to use update our post-build project file to replace the exe tool with the new build task:

 <Project xmlns="https://schemas.microsoft.com/developer/msbuild/2003">
    <UsingTask TaskName="PopulateDigitalSignature" AssemblyFile="$(MSBuildExtensionsPath)\SetupProjects.Tasks.dll" />
    
    <PropertyGroup>
        <CertificateKeyFile>$(ProjectDir)\..\..\SetupSigning_TemporaryKey.pfx</CertificateKeyFile>
    </PropertyGroup>

    <ItemGroup>
        <BuiltSetupFile Include="$(BuiltOutputDir)\*.*" Exclude="$(BuiltOutputDir)\*.cab" />
    </ItemGroup>
    
    <ItemGroup>
        <BuiltCabinetFile Include="$(BuiltOutputDir)\*.cab" />
    </ItemGroup>
    
    <Target Name="SignSetup">
        <ResolveKeySource
            CertificateFile="$(CertificateKeyFile)">
            <Output TaskParameter="ResolvedThumbprint" PropertyName="_ResolvedCertificateThumbprint" />
        </ResolveKeySource>

        <Exec Command='signtool.exe sign /sha1 $(_ResolvedCertificateThumbprint) "%(BuiltCabinetFile.Identity)"' />
        <PopulateDigitalSignature 
            Database="$(BuiltOutputPath)"
            Cabinet="%(BuiltCabinetFile.Identity)"
        />
        <Exec Command='signtool.exe sign /sha1 $(_ResolvedCertificateThumbprint) "%(BuiltSetupFile.Identity)"' />        
    </Target>
</Project>

(I also changed the location of the pfx file so that it lives in the parent of my solution directory). So, building should work now, right?

 Build started 11/20/2006 1:49:37 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):

Target SignSetup:
    signtool.exe sign /sha1 963C7D3298349519368D1E36AB2BABBE84060DFA "E:\MWadeBlog\SetupSignCabs\Debug\SETUP.CAB"

    Successfully signed: E:\MWadeBlog\SetupSignCabs\Debug\SETUP.CAB
    
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: The "PopulateDigitalSignature" task failed unexpectedly.
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: System.IO.FileNotFoundException: Could not load file or assembly 'Interop.WindowsInstaller, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. The system cannot find the file specified.
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: File name: 'Interop.WindowsInstaller, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018:    at SetupProjects.Tasks.PopulateDigitalSignature.Execute()
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018:    at Microsoft.Build.BuildEngine.TaskEngine.ExecuteTask(ExecutionMode howToExecuteTask, Hashtable projectItemsAvailableToTask, BuildPropertyGroup projectPropertiesAvailableToTask, Boolean& taskClassWasFound)
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: 
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: WRN: Assembly binding logging is turned OFF.
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: To enable assembly bind failure logging, set the registry value [HKLM\Software\Microsoft\Fusion!EnableLog] (DWORD) to 1.
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: Note: There is some performance penalty associated with assembly bind failure logging.
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: To turn this feature off, remove the registry value [HKLM\Software\Microsoft\Fusion!EnableLog].
    E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4018: 
Done building target "SignSetup" in project "PostBuild.proj" -- FAILED.

Done building project "PostBuild.proj" -- FAILED.

Build FAILED.

D'oh! I also need to copy the interop assembly to live next to my task dll. After adding this simple post-build step, our task is finally working:

 Build started 11/20/2006 1:51:04 PM.
__________________________________________________
Project "E:\MWadeBlog\SetupSignCabs\PostBuild.proj" (SignSetup target(s)):

Target SignSetup:
    signtool.exe sign /sha1 963C7D3298349519368D1E36AB2BABBE84060DFA "E:\MWadeBlog\SetupSignCabs\Debug\SETUP.CAB"
    Successfully signed: E:\MWadeBlog\SetupSignCabs\Debug\SETUP.CAB
    
    signtool.exe sign /sha1 963C7D3298349519368D1E36AB2BABBE84060DFA "E:\MWadeBlog\SetupSignCabs\Debug\setup.exe"
    Successfully signed: E:\MWadeBlog\SetupSignCabs\Debug\setup.exe

    signtool.exe sign /sha1 963C7D3298349519368D1E36AB2BABBE84060DFA "E:\MWadeBlog\SetupSignCabs\Debug\SetupSignCabs.msi"
    Successfully signed: E:\MWadeBlog\SetupSignCabs\Debug\SetupSignCabs.msi

Build succeeded.
    0 Warning(s)
    0 Error(s)

MWadeBlog_06_11_22.zip