Random Dev Notes

October 22, 2009

MSBuild Scripts Reworked

Filed under: Development — Tags: — Tom Brothers @ 1:42 am

About a year ago, I spent some time trying to get up to speed with MSBuild so that I could put together a Continuous Integration Server.  At that time I wasn’t able to find any good MSBuild related books with my Safari Books Online subscription so I spent a fair amount of time searching the internet for examples.  I eventually found everything that I needed to write the MSBuild scripts and custom MSBuild Tasks for the CI Server.  Everything has been working great for some time now.  So wouldn’t you know it that when I don’t need a MSBuild book… I finally see one in the “Just Added” section of Safari Books Online.

Inside the Microsoft® Build Engine: Using MSBuild and Team Foundation Build (PRO-Developer)

Needless to say, I just had to read the book to see what I may have overlooked.  As it turns out, I overlooked three important features… Batching, Transformations, and Well-Known Metadata.  So with this new found knowledge I decided to rewrite my build scripts.  The result of this rewrite was a significant reduction in the number of MSBuild scripts and custom MSBuild Tasks.

What follows is an attempt to document my reworked MSBuild scripts…

My initial goal was to rewrite a build script for an ASP.Net MVC Application.  To test my new build scripts, I created a new MVC Application with one simple modification.  I modified the web.config to use separate files for appSettings and connectionStrings.

image

Here is the final build script (Northwind.msbuild) that I came up with for the MVC Application:

 1: <?xml version="1.0" encoding="utf-8" ?>
 2: <project defaulttargets="UpdateSource;Test;Build;" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 3:     <Import Project="Resourcesmain.targets" Condition="$(CIPath)==''" />
 4:
 5:     <PropertyGroup>
 6:         <Application>Northwind</Application>
 7:     </PropertyGroup>
 8:
 9:     <Target Name="UpdateSource" DependsOnTargets="SubversionUpdateTarget" />
 10:     <Target Name="Test" DependsOnTargets="MSTestTarget" />
 11:     <Target Name="Rebuild" DependsOnTargets="BuildSolutionTarget" />
 12: </project>

The build script has three local targets… UpdateSource, Test, and Rebuild and relies on three imported targets… SubversionUpdateTarget, MSTestTarget, and RebuildSolutionTarget.  The three imported targets come from an external file “main.targets” which is imported in line #3.  The local targets do not contain any commands to execute.  Instead, they rely on the imported targets that are specified in the DependsOnTargets attribute to do all the work.  The imported targets work based on a naming convention.  This naming convention requires that a property named Application has a value.  In this example, the Application is defined as “Northwind” as you can see in line #6.

As you can see, there really isn’t much to Northwind.msbuild.  All the real code is in main.targets.  So lets dig into this file…

PropertyGroup: 

 1: <propertygroup>
 2:     <!-- ci root directory -->
 3:     <CIPath>c:_CI</CIPath>
 4:     <!-- used for the application build directory -->
 5:     <CITempPath>$(CIPath)Temp</CITempPath>
 6:     <!-- used for the tests build directory and hold the test results -->
 7:     <CITestPath>$(CIPath)Test</CITestPath>
 8:     <!-- deployment directories and zip files -->
 9:     <CIDeployPath>$(CIPath)Deploy</CIDeployPath>
 10:     <!-- ci source code directories -->
 11:     <CISourceCodePath>$(CIPath)Source</CISourceCodePath>
 12:     <!-- TPI source code directories -->
 13:     <TPISourceCodePath>$(CISourceCodePath)TPI</TPISourceCodePath>
 14:
 15:     <!-- This should be set to true by the CI Server to continue past test errors. This will allow the CI Server to parse and report the test results -->
 16:     <MSTestContinueOnError Condition="$(MSTestContinueOnError)==''">false</MSTestContinueOnError>
 17:
 18:     <!-- full path to svn.exe -->
 19:     <SvnExe>C:Program FilesSubversionbinsvn.exe</SvnExe>
 20:     <!-- full path to mstest.exe -->
 21:     <MSTestExe>c:Program FilesMicrosoft Visual Studio 9.0Common7IDEMSTest.exe</MSTestExe>
 22: </propertygroup>

Defines all global properties.

SubversionUpdateTarget: 

 1: <target name="SubersionUpdateTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
 4:     </PropertyGroup>
 5:
 6:     <Exec Command="&quot;$(SvnExe)&quot; update $(AppSourceCodePath)" />
 7: </target>

Executes Svn.exe to update the AppSourceCodePath.

MSTestTarget: 

 1: <target name="MSTestTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
 4:     </PropertyGroup>
 5:
 6:     <!-- get a list of test projects -->
 7:     <ItemGroup>
 8:         <TestProjects Include="$(AppSourceCodePath)****.Tests.csproj" />
 9:     </ItemGroup>
 10:
 11:     <!-- delete the previous test output -->
 12:     <RemoveDir
 13:         Directories="$(CITestPath)%(TestProjects.Filename)"
 14:         Condition="Exists('$(CITestPath)%(TestProjects.Filename)')" />
 15:
 16:     <!-- build the test projects with the output pointing to the CI test directory -->
 17:     <MSBuild
 18:         Projects="%(TestProjects.Identity)"
 19:         Targets="ReBuild"
 20:         Properties="Configuration=Release;OutDir=$(CITestPath)%(TestProjects.Filename)"/>
 21:
 22:     <!-- get a list of test dlls -->
 23:     <ItemGroup>
 24:         <TestDlls Include="$(CITestPath)$(Application)****.Tests.dll" />
 25:     </ItemGroup>
 26:
 27:     <Exec
 28:         Command="&quot;$(MSTestExe)&quot; /testcontainer:&quot;%(TestDlls.Identity)&quot; /resultsfile:&quot;%(TestDlls.FullPath).xml&quot;"
 29:         Condition="%(TestDlls.Identity) != ''"
 30:         ContinueOnError="$(MSTestContinueOnError)" />
 31:
 32:     <!-- move the results files into the root CI test directory-->
 33:     <Copy
 34:         SourceFiles="%(TestDlls.FullPath).xml"
 35:         DestinationFolder="$(CITestPath)"
 36:         Condition="Exists('%(TestDlls.FullPath).xml')" />
 37:
 38:     <!-- remove the project test directories -->
 39:     <RemoveDir Directories="%(TestDlls.RelativeDir)" />
 40: </target>

This target starts by getting a list of all test projects for the application by assuming a simple naming convention of *.Tests.csproj (line #8).  Next, attempts to delete any prior test builds that might exist (line #12) and then builds the test projects (line #17).  Now with all the test projects built, it gets a list of all the test dlls that were built (line #24)… again using a simple naming convention of *.Tests.dll.  Then it executes each test by calling MSTest.exe (line #27).  The results file is moved up a level out of the test build directory into the main CITestPath directory (line #33) and then the test build directory is deleted (line 39).

BuildSolutionTarget:

 1: <target name="BuildSolutionTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
 4:         <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
 5:         <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
 6:         <DeleteTempDir Condition="$(DeleteTempDir) == ''">true</DeleteTempDir>
 7:     </PropertyGroup>
 8:
 9:     <!-- remove the previous deployment directory -->
 10:     <RemoveDir
 11:         Directories="$(AppDeployPath)"
 12:         Condition="Exists($(AppDeployPath))" />
 13:
 14:     <!-- rebuild -->
 15:     <MSBuild
 16:         Projects="$(AppSourceCodePath)$(Application).sln;"
 17:         Targets="ReBuild"
 18:         Properties="Configuration=Release;WebProjectOutputDir=$(AppDeployPath);OutDir=$(AppTempPath);" />
 19:
 20:     <CallTarget Targets="RenameConfigFilesTarget" />
 21:     <CallTarget Targets="UpdateConfigFilesTarget" />
 22:     <CallTarget Targets="RemoveTempDirTarget" Condition="$(AutoDeleteTempDir) == 'true'" />
 23:     <CallTarget Targets="CreateZipTarget" />
 24: </target>

This target builds the solution with the standard output going into the temp directory and with the web output going to directly to the deployment directory (line #15).  After the build has completed, this targets calls a few targets to create the deployment.

RenameConfigFilesTarget:

 1: <target name="RenameConfigFilesTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
 4:         <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
 5:     </PropertyGroup>
 6:
 7:     <!-- get a list of the config files -->
 8:     <ItemGroup>
 9:         <!-- web applications build directly into $(AppDeployPath) -->
 10:         <ConfigFiles Include="$(AppDeployPath)***.config" />
 11:         <!-- other applications are build into $(AppTempPath) -->
 12:         <ConfigFiles Include="$(AppTempPath)***.config" />
 13:         <!-- don't rename web.config -->
 14:         <ConfigFiles Remove="$(AppDeployPath)**web.config" />
 15:     </ItemGroup>
 16:
 17:     <Copy
 18:         SourceFiles="@(ConfigFiles)"
 19:         DestinationFiles="@(ConfigFiles->'%(FullPath).deploy')" />
 20:
 21:     <Delete Files="@(ConfigFiles)" />
 22: </target>

This target gets a list of all config files in the application deployment directory and the temp directory with the exception of web.confing which is removed from the list.  The files are renamed to include a “.deploy” extension to prevent accidently overwriting these files during the deployment process.

UpdateConfigFilesTarget:

 1: <target name="UpdateConfigFilesTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
 4:     </PropertyGroup>
 5:     <ItemGroup>
 6:         <WebConfigFiles Include="$(AppDeployPath)**web.config" />
 7:     </ItemGroup>
 8:
 9:     <XmlUpdate
 10:         XPath="/configuration/system.web/compilation/@debug"
 11:         XmlFileName="%(WebConfigFiles.FullPath)"
 12:         Value="false"
 13:         Condition="%(WebConfigFiles.Identity) != ''" />
 14: </target>

This target gets a list of web.config files (line #6) in the deployment directory and changes the debug flag to false (line #9).

RemoveTempDirTarget:

 1: <target name="RemoveTempDirTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
 4:     </PropertyGroup>
 5:
 6:     <!-- delete the build output temp directory -->
 7:     <RemoveDir
 8:         Directories="$(AppTempPath)"
 9:         Condition="Exists('$(AppTempPath)')" />
 10: </target>

This target deletes the temp directory.

CreateZipTarget:

 1: <target name="CreateZipTarget" condition="$(Application) != ''">
 2:     <PropertyGroup>
 3:         <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
 4:     </PropertyGroup>
 5:     <ItemGroup>
 6:         <Files Include="$(AppDeployPath)***" />
 7:     </ItemGroup>
 8:
 9:     <Zip Files="@(Files)"
 10:         ZipFileName="$(CIDeployPath)$(Application).zip"
 11:         WorkingDirectory="$(AppDeployPath)"
 12:         Condition="%(Files.Identity) != ''" />
 13: </target>

This target creates a zip of the deployment directory.

main.targets:

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <Import Project="MSBuild.Community.Tasks.Targets" />

    <PropertyGroup>
        <!-- ci root directory -->
        <CIPath>c:_CI</CIPath>
        <!-- used for the application build directory -->
        <CITempPath>$(CIPath)Temp</CITempPath>
        <!-- used for the tests build directory and hold the test results -->
        <CITestPath>$(CIPath)Test</CITestPath>
        <!-- deployment directories and zip files -->
        <CIDeployPath>$(CIPath)Deploy</CIDeployPath>
        <!-- ci source code directories -->
        <CISourceCodePath>$(CIPath)Source</CISourceCodePath>
        <!-- TPI source code directories -->
        <TPISourceCodePath>$(CISourceCodePath)TPI</TPISourceCodePath>

        <!-- This should be set to true by the CI Server to continue past test errors. This will allow the CI Server to parse and report the test results -->
        <MSTestContinueOnError Condition="$(MSTestContinueOnError)==''">false</MSTestContinueOnError>

        <!-- full path to svn.exe -->
        <SvnExe>C:Program FilesSubversionbinsvn.exe</SvnExe>
        <!-- full path to mstest.exe -->
        <MSTestExe>c:Program FilesMicrosoft Visual Studio 9.0Common7IDEMSTest.exe</MSTestExe>
    </PropertyGroup>

    <Target Name="SubversionUpdateTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
        </PropertyGroup>

        <Exec Command="&quot;$(SvnExe)&quot; update $(AppSourceCodePath)" />
    </Target>

    <Target Name="MSTestTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
        </PropertyGroup>

        <!-- get a list of test projects -->
        <ItemGroup>
            <TestProjects Include="$(AppSourceCodePath)****.Tests.csproj" />
        </ItemGroup>

        <!-- delete the previous test output -->
        <RemoveDir
            Directories="$(CITestPath)%(TestProjects.Filename)"
            Condition="Exists('$(CITestPath)%(TestProjects.Filename)')" />

        <!-- build the test projects with the output pointing to the CI test directory -->
        <MSBuild
            Projects="%(TestProjects.Identity)"
            Targets="ReBuild"
            Properties="Configuration=Release;OutDir=$(CITestPath)%(TestProjects.Filename)"/>

        <!-- get a list of test dlls -->
        <ItemGroup>
            <TestDlls Include="$(CITestPath)$(Application)****.Tests.dll" />
        </ItemGroup>

        <Exec
            Command="&quot;$(MSTestExe)&quot; /testcontainer:&quot;%(TestDlls.Identity)&quot; /resultsfile:&quot;%(TestDlls.FullPath).xml&quot;"
            Condition="%(TestDlls.Identity) != ''"
            ContinueOnError="$(MSTestContinueOnError)" />

        <!-- move the results files into the root CI test directory-->
        <Copy
            SourceFiles="%(TestDlls.FullPath).xml"
            DestinationFolder="$(CITestPath)"
            Condition="Exists('%(TestDlls.FullPath).xml')" />

        <!-- remove the project test directories -->
        <RemoveDir Directories="%(TestDlls.RelativeDir)" />
    </Target>

    <Target Name="BuildSolutionTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppSourceCodePath Condition="$(AppSourceCodePath) == ''">$(CISourceCodePath)$(Application)</AppSourceCodePath>
            <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
            <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
            <DeleteTempDir Condition="$(DeleteTempDir) == ''">true</DeleteTempDir>
        </PropertyGroup>

        <!-- remove the previous deployment directory -->
        <RemoveDir
            Directories="$(AppDeployPath)"
            Condition="Exists($(AppDeployPath))" />

        <!-- rebuild -->
        <MSBuild
            Projects="$(AppSourceCodePath)$(Application).sln;"
            Targets="ReBuild"
            Properties="Configuration=Release;WebProjectOutputDir=$(AppDeployPath);OutDir=$(AppTempPath);" />

        <CallTarget Targets="RenameConfigFilesTarget" />
        <CallTarget Targets="UpdateConfigFilesTarget" />
        <CallTarget Targets="RemoveTempDirTarget" Condition="$(AutoDeleteTempDir) == 'true'" />
        <CallTarget Targets="CreateZipTarget" />
    </Target>

    <Target Name="CreateZipTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
        </PropertyGroup>
        <ItemGroup>
            <Files Include="$(AppDeployPath)***" />
        </ItemGroup>

        <Zip Files="@(Files)"
             ZipFileName="$(CIDeployPath)$(Application).zip"
             WorkingDirectory="$(AppDeployPath)"
             Condition="%(Files.Identity) != ''" />
    </Target>

    <Target Name="RemoveTempDirTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
        </PropertyGroup>

        <!-- delete the build output temp directory -->
        <RemoveDir
            Directories="$(AppTempPath)"
            Condition="Exists('$(AppTempPath)')" />
    </Target>

    <Target Name="RenameConfigFilesTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
            <AppTempPath Condition="$(AppTempPath) == ''">$(CITempPath)$(Application)</AppTempPath>
        </PropertyGroup>

        <!-- get a list of the config files -->
        <ItemGroup>
            <!-- web applications build directly into $(AppDeployPath) -->
            <ConfigFiles Include="$(AppDeployPath)***.config" />
            <!-- other applications are build into $(AppTempPath) -->
            <ConfigFiles Include="$(AppTempPath)***.config" />
            <!-- don't rename web.config -->
            <ConfigFiles Remove="$(AppDeployPath)**web.config" />
        </ItemGroup>

        <Copy
            SourceFiles="@(ConfigFiles)"
            DestinationFiles="@(ConfigFiles->'%(FullPath).deploy')" />

        <Delete Files="@(ConfigFiles)" />
    </Target>

    <Target Name="UpdateConfigFilesTarget" Condition="$(Application) != ''">
        <PropertyGroup>
            <AppDeployPath Condition="$(AppDeployPath) == ''">$(CIDeployPath)$(Application)</AppDeployPath>
        </PropertyGroup>
        <ItemGroup>
            <WebConfigFiles Include="$(AppDeployPath)**web.config" />
        </ItemGroup>

        <XmlUpdate
            XPath="/configuration/system.web/compilation/@debug"
            XmlFileName="%(WebConfigFiles.FullPath)"
            Value="false"
            Condition="%(WebConfigFiles.Identity) != ''" />
    </Target>
</Project>

One thing that I haven’t mentioned yet was that the main.targets relies on the Zip and UpdateXml Tasks from the MSBuild Community Tasks Project.

Blog at WordPress.com.