Integrating MSBuild with CruiseControl.NET

In my last article, I talked about using MSBuild with Web Deployment Projects (WDP), an add-in to Visual Studio that allows you to create MSBuild files for web projects. WDP enables you to manage not only build configuration and merge options, but other tasks such as specifying changes for the application's Web.config file during compilation, changing connection strings, creating virtual directories, and performing other tasks at specific points in the deployment process. Although this adds a lot of flexibility I have found that Web Application Projects (WAP) offers a better solution. WAP provides another web project model option for Visual Studio 2005 that can be used as an alternative to the built-in web-site based solution. It uses the same project, build and compilation semantics as the Visual Studio 2003 web project model. Specifically (from the web site):

  • All files contained within the project are defined within a project file (as well as the assembly references and other project meta-data settings). Files under the web's file-system root that are not defined in the project file are not logically considered part of the web project.
  • All code files within the project are compiled into a single assembly (that gets built and persisted in the \bin directory on each compile).
  • The compilation system uses a standard MSBuild based compilation process. This can be extended and customized using standard MSBuild extensibility rules. You can therefore control the build through the property pages, so for example, you can name the output assembly.

Installing WAP does not modify or affect the behavior of Visual Studio 2005, it simply adds a new project type to Visual Studio. WAP is still in beta and there are some known issues that you should read and be aware of before using it. A new version is due out this month that will fix most of the bugs and problems with the existing version. I will be using WAP in this article as my web project type.

On my build server I'm running Windows Server 2003 with IIS 6.0 with CruiseControl.NET 1.0.1251 installed. You can download CruiseControl.NET here if you don't have it installed. For source control I'm going to use Visual SourceSafe 2005, although you could plug in any source control system (Vault, Subversion, etc.). I'll also be using the MSBuild Community Tasks Project library. The current version is 1.0.0.29, but I'm using a newer version that hasn't been released since I'm a contributing member of the project. You can download the latest version of the source code from the project home page, but you'll have to build the project once you download it. A new version of the library is due out very soon, so stay tuned.

Configuring CruiseControl.NET

Previous to .NET 2.0 being released, I used CruiseControl.NET (CC.NET) with NAnt, both of which are freely available as open source projects. MSBuild and NAnt are both build tools that allow you to perform a myriad of tasks from building solutions to running unit tests, zipping up the output of builds, transforming XML with XSLT, accessing source control, sending email, etc. Both tools are extremely powerful. Now that .NET 2.0 has been released, MSBuild has become the build tool of choice for Visual Studio solutions. After all, the project files that are created by Visual Studio are created for MSBuild. What I am going to do is show you how I have integrated MSBuild with CC.NET to create a continuous integration process.

The continuous integration process I'm going to emulate will look like the following:

The trigger for CC.NET to launch a build will be the source control repository. CC.NET offers several different Trigger blocks that control how a build will get launched. Some of the different types can be an Interval Trigger, a Scheduled Trigger, or a Filter Trigger. The most common trigger used is the Interval Trigger. The Interval Trigger allows you to specify that a build should be run periodically, after a certain amount of time. By default, a build will only be triggered if source control modifications have been detected since the last build. The trigger can also be configured to force a build even if no changes have occurred to the source control repository. Another nice feature is that you can nest trigger types together. For example, you can use a Filter Trigger to specify that a build should not be performed in a certain time period. You can then nest an Interval Trigger to specify the normal build conditions. Here is an example of this in the CC.NET's server configuration file (I'll refer to this as ccnet.config from now on):

    9     <triggers>

   10       <filterTrigger startTime="0:00" endTime="6:00">

   11         <trigger type="intervalTrigger" seconds="300" />

   12       </filterTrigger>

   13     </triggers>


The outer Filter Trigger indicates that the build should not occur from midnight until six in the morning. The inside Interval Trigger then specifies that the build should occur every five minutes.

The next step I need to setup in the ccnet.config file is the retrieval of the source code. Before I show that, I'd like to talk about the structure of the Visual Studio solution. This is important because when CC.NET retrieves the source code it will need to know where the master MSBuild project file resides. Here is a quick glance of the solution in Visual Studio:



There are four projects, a data layer, business layer, presentation layer, and a unit test project. I have also specified a folder for solution items. In this folder I will place the master MSBuild project file, along with other solution-wide items, including a sample strong name key. The project structure inside VSS looks like the following:



When CC.NET gets the latest version of the solution from VSS, it will create a file structure exactly like this. Now that you know where the main MSBuild project file will be when you perform the GET from VSS, you can then specify the location in the ccnet.config file. Here are the SourceControl and MSBuild blocks:

  243     <sourcecontrol type="vss">

  244       <executable>C:\Program Files\Microsoft Visual SourceSafe\ss.exe</executable>

  245       <project>$/DougRohm/Articles/MSBuildAndCCNet/</project>

  246       <username>msbuild</username>

  247       <password>msbuildpass</password>

  248       <ssdir>D:\VSS</ssdir>

  249       <workingDirectory>D:\CCNET_Build\DougRohm\Articles\MSBuildAndCCNet\Source\Dev</workingDirectory>

  250       <applyLabel>true</applyLabel>

  251       <autoGetSource>true</autoGetSource>

  252     </sourcecontrol>

  253 

  254     <tasks>

  255       <msbuild>

  256         <executable>C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\MSBuild.exe</executable>

  257         <workingDirectory>D:\CCNET_Build\DougRohm\Articles\MSBuildAndCCNet\Source\Dev</workingDirectory>

  258         <projectFile>DougRohm.Articles.MSBuildAndCCNet.proj</projectFile>

  259         <buildArgs>/p:BuildType=Dev</buildArgs>

  260         <targets>Build</targets>

  261         <logger>ThoughtWorks.CruiseControl.MsBuild.XmlLogger,C:\Program Files\CruiseControl.NET\webdashboard\bin\ThoughtWorks.CruiseControl.MsBuild.dll</logger>

  262       </msbuild>

  263     </tasks>


The source control task gets the entire solution from VSS and places it into the value of the workingDirectory property. Once that is complete, CC.NET launches the MSBuild task by pointing the workingDirectory and projectFile properties to the source code. I'm also passing a property to the MSBuild project file through the buildArgs property. I use this property in the MSBuild project file to determine certain property settings. I also set the default target so that the MSBuild project file will know which target to execute first.

Configuring the MSBuild Project File

In the MSBuild project file, the first thing I check is the BuildType property that is passed from CC.NET so that I can initialize certain properties for the build:

    3   <!-- BuildType Properties -->

    4   <Choose>

    5     <When Condition="'$(BuildType)' == 'Dev'">

    6       <PropertyGroup>

    7         <DeploymentFolder>C:\Inetpub\Dev</DeploymentFolder>

    8         <DeploymentDocFolder>C:\Inetpub\Dev_Docs</DeploymentDocFolder>

    9         <Configuration>Debug</Configuration>

   10         <DebugSymbols>true</DebugSymbols>

   11         <VersionBuildType>Date</VersionBuildType>

   12         <VersionRevisionType>Increment</VersionRevisionType>

   13       </PropertyGroup>

   14     </When>

   15     <When Condition="'$(BuildType)' == 'Stage'">

   16       <PropertyGroup>

   17         <DeploymentFolder>C:\Inetpub\Stage</DeploymentFolder>

   18         <Configuration>Debug</Configuration>

   19         <DebugSymbols>true</DebugSymbols>

   20         <VersionBuildType>NonIncrement</VersionBuildType>

   21         <VersionRevisionType>NonIncrement</VersionRevisionType>

   22       </PropertyGroup>

   23     </When>

   24     <When Condition="'$(BuildType)' == 'QA'">

   25       <PropertyGroup>

   26         <DeploymentFolder>C:\Inetpub\QA</DeploymentFolder>

   27         <Configuration>Release</Configuration>

   28         <DebugSymbols>false</DebugSymbols>

   29         <VersionBuildType>NonIncrement</VersionBuildType>

   30         <VersionRevisionType>NonIncrement</VersionRevisionType>

   31       </PropertyGroup>

   32     </When>

   33     <When Condition="'$(BuildType)' == 'Prod'">

   34       <PropertyGroup>

   35         <DeploymentFolder>C:\Inetpub\Prod</DeploymentFolder>

   36         <Configuration>Release</Configuration>

   37         <DebugSymbols>false</DebugSymbols>

   38         <VersionBuildType>NonIncrement</VersionBuildType>

   39         <VersionRevisionType>NonIncrement</VersionRevisionType>

   40       </PropertyGroup>

   41     </When>

   42   </Choose>


After determining the value of the 'BuildType' property, I set the deployment folder that the build will use for deployment, the configuration type, whether or not to produce debug symbols, and two values for the 'VersionBuildType' and 'VersionRevisionType'. These two values will be used by the Version task when I strong name the assemblies. I then proceed to setup several other properties and item groups that I will use during the build. I'll go through each one:

   43   <PropertyGroup>

   44     <Configuration Condition="'$(Configuration)' == ''">Debug</Configuration>

   45     <!-- Default build group is 'Build', other is 'NUnit' - See ItemGroup <VSProjects> -->

   46     <BuildGroup Condition="'$(BuildGroup)' == ''">Build</BuildGroup>

   47     <SourcePath>$(MSBuildProjectDirectory)</SourcePath>

   48     <LibraryPath>$(SourcePath)\Libraries</LibraryPath>

   49     <DocumentationPath>$(SourcePath)\Documentation</DocumentationPath>

   50     <BuildPath>$(SourcePath)\Compilation\$(Configuration)</BuildPath>

   51     <UnitTestPath>$(SourcePath)\Compilation\UnitTests</UnitTestPath>

   52     <ProjectNamespace>DougRohm.Articles.MSBuildAndCCNet</ProjectNamespace>

   53     <BuildArchiveFolder>$(MSBuildProjectDirectory)\..\..\Builds\$(BuildType)</BuildArchiveFolder>

   54 

   55     <VssUsername>msbuild</VssUsername>

   56     <VssPassword>msbuildpass</VssPassword>

   57     <VssDatabasePath>\\MyServer\VSS$\srcsafe.ini</VssDatabasePath>

   58 

   59     <NUnitPostfix>-results.xml</NUnitPostfix>

   60     <NUnitReportXslFilename>NUnitReport.xsl</NUnitReportXslFilename>

   61 

   62     <DeveloperBuild Condition="'$(DeveloperBuild)' == ''">False</DeveloperBuild>

   63     <ImportTargets>$(MSBuildExtensionsPath)\MSBuildCommunityTasks\MSBuild.Community.Tasks.Targets</ImportTargets>

   64   </PropertyGroup>


In this property group I'm setting up a number of properties that deal with directory paths, source control username/password, and NUnit processing. I also add a property for the .targets file for the MSBuild Community Tasks Project library. This allows me to have access to all of the MSBuild tasks in that library without having to add a UsingTask element for every task I need to use. I then call the Import element to import all of the tasks:

   66 <Import Condition="Exists($(ImportTargets))" Project="$(ImportTargets)" />


The directory structure on the build server that CC.NET will use looks like the following:



There are a few more items I create before any of the targets are executed. I create two item groups for cleaning the build directories. The first, CleanOutput, cleans the output directories after the projects are compiled. The second item group, CleanSource, is a collection of the directories to clean before compilation takes place. Here are the item groups:

 

  101   <ItemGroup>

  102     <CleanOutput Include="$(BuildPath)" />

  103     <CleanOutput Include="$(DocumentationPath)" />

  104     <CleanOutput Include="$(UnitTestPath)" />

  105   </ItemGroup>

  106 

  107   <ItemGroup>

  108     <CleanSource Include="$(LibraryPath)" />

  109     <CleanSource Include="$(SourcePath)\Business" />

  110     <CleanSource Include="$(SourcePath)\Data" />

  111     <CleanSource Include="$(SourcePath)\Tests" />

  112     <CleanSource Include="$(SourcePath)\Web.UI" />

  113   </ItemGroup>


I then create an item group for all of the projects in the solution:

  115   <!-- VS Projects -->

  116   <ItemGroup>

  117     <VSProjects Include="$(SourcePath)\Data\$(ProjectNamespace).Data.csproj">

  118       <Group>Build</Group>

  119       <Title>$(ProjectNamespace).Data</Title>

  120       <Description>Data layer.</Description>

  121     </VSProjects>

  122 

  123     <VSProjects Include="$(SourcePath)\Business\$(ProjectNamespace).Business.csproj">

  124       <Group>Build</Group>

  125       <Title>$(ProjectNamespace).Business</Title>

  126       <Description>Business layer.</Description>

  127     </VSProjects>

  128 

  129     <VSProjects Include="$(SourcePath)\Web.UI\$(ProjectNamespace).Web.UI.csproj">

  130       <Group>Build</Group>

  131       <Title>$(ProjectNamespace).Web.UI</Title>

  132       <Description>Web presentation layer.</Description>

  133     </VSProjects>

  134 

  135     <VSProjects Include="$(SourcePath)\Tests\$(ProjectNamespace).Tests.csproj">

  136       <Group>NUnit</Group>

  137       <Title>$(ProjectNamespace).Tests</Title>

  138       <Description>Unit test assembly.</Description>

  139     </VSProjects>

  140   </ItemGroup>


For each VSProjects item, I'm also declaring metadata values that I can access during the build. The 'Group' metadata value allows me to separate regular build projects from NUnit test projects. I added that option in case I wanted to run the build file manually and only run the unit tests. This was purely optional and for this article I'll only be using the Build projects (although I do run the unit tests later). I'm also setting metadata for the 'Title' and 'Description' for each project. These values are used for when I strong name the assembly (I'll get to that in a minute).

I then create an item group that I'll use when building the NUnit projects. This collection represents anything that I'll need for generating the NUnit report. These items will get copied to the NUnitTestPath folder declared earlier.

  142   <ItemGroup>

  143     <!-- Content files to copy to the test directory -->

  144     <!-- Provide metadata <SubDirectory> for relative destination directory -->

  145     <NUnitContents Include="$(SourcePath)\$(NUnitReportXslFilename)">

  146       <SubDirectory>$(ProjectNamespace).Tests</SubDirectory>

  147     </NUnitContents>

  148   </ItemGroup>


The last item group I declare is the XSL file that will be used to generate the NUnit report.

  150   <ItemGroup>

  151     <NUnitReportXslFile Include="$(SourcePath)\$(NUnitReportXslFilename)">

  152       <project>$(ProjectNamespace)</project>

  153       <configuration>$(Configuration)</configuration>

  154       <msbuildFilename>$(MSBuildProjectFullPath)</msbuildFilename>

  155       <msbuildBinpath>$(MSBuildBinPath)</msbuildBinpath>

  156       <xslFile>$(SourcePath)\$(NUnitReportXslFilename)</xslFile>

  157     </NUnitReportXslFile>

  158   </ItemGroup>


As I mentioned earlier, you can pass the targets to execute in the MSBuild file from the CC.NET configuration file (ccnet.config) through the 'buildArgs' property. This allows you to call a specific target in the build file, which in this case, is 'Build'. To give you a quick overview of the targets I have in my build file I have them listed here:

1) Build
    2) AssemblyInfo
        3) CheckoutVersionFile
            4) CreateWorkingFolders
5) ParseTokens
6) UnitTest
7) DeployBuild
8) Documentation

I numbered the targets to denote the order in which they are executed. Build is called first, but it depends on AssemblyInfo. AssemblyInfo depends on CheckoutVersionFile. CheckoutVersionFile depends on CreateWorkingFolders. Based on all of that, CreateWorkingFolders executes first, then CheckoutVersionFile, then AssemblyInfo, and then finally Build. The Build target calls ParseTokens and then proceeds to build all of the projects in the solution. After compilation, the Build target then calls the UnitTest target and then DeployBuild. DeployBuild calls the Documentation target internally.

Here is the listing for the Build, AssemblyInfo, CheckoutVersionFile, and CreateWorkingFolders targets:

  160   <Target Name="CreateWorkingFolders" Condition="'$(BuildGroup)' == 'Build'">

  161     <!-- Clean the output folders -->

  162     <RemoveDir Directories="@(CleanOutput)" />

  163 

  164     <MakeDir Condition="!Exists('$(DocumentationPath)')" Directories="$(DocumentationPath)" />

  165     <MakeDir Condition="!Exists('$(BuildPath)')" Directories="$(BuildPath)" />

  166     <MakeDir Condition="!Exists('$(UnitTestPath)')" Directories="$(UnitTestPath)" />

  167   </Target>

  168 

  169   <Target Name="CheckoutVersionFile" Condition="'$(BuildGroup)' == 'Build'"

  170       DependsOnTargets="CreateWorkingFolders">

  171     <!-- Checkout BuildNumber.txt -->

  172     <VssCheckout UserName="$(VssUsername)"

  173           Password="$(VssPassword)"

  174           LocalPath="$(SourcePath)"

  175           Recursive="False"

  176           DatabasePath="$(VssDatabasePath)"

  177           Path="$/DougRohm/Articles/MSBuildAndCCNet/Version.txt" />

  178 

  179     <!-- Get Build and Revision number -->

  180     <Version VersionFile="$(SourcePath)\Version.txt" BuildType="$(VersionBuildType)" RevisionType="$(VersionRevisionType)">

  181       <Output TaskParameter="Major" PropertyName="Major" />

  182       <Output TaskParameter="Minor" PropertyName="Minor" />

  183       <Output TaskParameter="Build" PropertyName="Build" />

  184       <Output TaskParameter="Revision" PropertyName="Revision" />

  185     </Version>

  186     <Message Text="Version: $(Major).$(Minor).$(Build).$(Revision)" />

  187   </Target>

  188 

  189   <Target Name="AssemblyInfo" Condition="'$(BuildGroup)' == 'Build'"

  190       DependsOnTargets="CheckoutVersionFile" Inputs="@(VSProjects)"

  191       Outputs="%(RootDir)%(Directory)\Properties\AssemblyInfo.cs">

  192 

  193     <!-- For each VS project, create the AssemblyInfo.cs file  -->

  194     <AssemblyInfo CodeLanguage="CS"

  195             OutputFile="%(VSProjects.RootDir)%(Directory)Properties\AssemblyInfo.cs"

  196             ComVisible="false"

  197             CLSCompliant="false"

  198             AssemblyCompany="DougRohm.com"

  199             AssemblyProduct="MSBuild and CruiseControl.NET"

  200             AssemblyCopyright="Copyright © 2005-2006 Douglas Rohm"

  201             AssemblyTitle="%(Title)"

  202             AssemblyDescription="%(Description)"

  203             AssemblyVersion="$(Major).$(Minor).$(Build).$(Revision)"

  204             AssemblyKeyFile="..\..\..\DougRohm.Articles.snk" />

  205   </Target>

  206 

  207   <Target Name="Build" DependsOnTargets="AssemblyInfo">

  208     <CallTarget Targets="ParseTokens" />

  209 

  210     <MSBuild Projects="@(VSProjects)" Condition="'%(Group)' == '$(BuildGroup)'"

  211         Properties="Configuration=$(Configuration)">

  212       <Output TaskParameter="TargetOutputs" ItemName="BuildTargetOutputs"/>

  213     </MSBuild>

  214 

  215     <Copy SourceFiles="@(BuildTargetOutputs)"

  216         DestinationFolder="$(BuildPath)\bin"

  217         Condition="'$(BuildGroup)' == 'Build'"

  218         SkipUnchangedFiles="true" />

  219     <Copy SourceFiles="@(BuildTargetOutputs->'%(RootDir)%(Directory)%(Filename).pdb')"

  220         DestinationFolder="$(BuildPath)\bin"

  221         Condition="'$(BuildGroup)' == 'Build' And '$(Configuration)' == 'Debug'"

  222         SkipUnchangedFiles="true" />

  223     <Copy SourceFiles="@(BuildTargetOutputs->'%(RootDir)%(Directory)%(Filename).xml')"

  224         DestinationFolder="$(BuildPath)\bin"

  225         Condition="'$(BuildGroup)' == 'Build'"

  226         SkipUnchangedFiles="true" />

  227 

  228     <!-- Run NUnit tests and generate report -->

  229     <CallTarget Targets="UnitTest" />

  230 

  231     <!-- Deploy project -->

  232     <CallTarget Targets="DeployBuild" />

  233   </Target>


The first thing that happens is CreateWorkingFolders cleans out the BuildPath, DocumentationPath, and UnitTestPath folders by deleting the folders and then recreating them. Next, the CheckoutVersionFile target checks out the version file (Version.txt). The version file is used by the Version task so that it can reference it to generate the next version for the build. The version file resides in the Solution Items folder in Visual Studio. I then generate the new version by executing the Version task. Notice that I'm using the VersionBuildType and VersionRevisionType properties that I set in the Choose element at the beginning of the build file. Next, the AssemblyInfo target executes and generates the AssemblyInfo.cs file with all of the appropriate assembly attributes and places it in the projects Properties subfolder. The AssemblyInfo target has both Inputs and Outputs parameters. The Inputs parameter is a collection of all the Visual Studio projects that need to be built. The Outputs parameter is the AssemblyInfo.cs file that will replace the existing AssemblyInfo.cs file in the Properties directory.

At this point, tasks in the Build target can execute. The first thing the Build target does is call the ParseTokens target. This target is used to do any last minute modifications to the source files before compilation. In the Web.UI project there is the Default.aspx page. Near the bottom of the page there are two tokens I added for the application version and build date (@VERSION@ and @DATE@). The ParseTokens target will replace these tokens with the values I need. The first thing I do is create an item group named ParseIncludeFiles that contains all the files I want to parse, excluding the files I want to ignore. One thing I need to mention at this point is that I'm creating this item group on the fly, as opposed to creating it with all the other properties and item groups at the beginning of the build file. The reason for this is because all properties and item groups are evaluated before any targets. If I had declared this item group before the target, no output files would be in the source directory to be included in the item group (or excluded in this instance).

After I create the item group of files to parse, I get the current timestamp of the build using the Time task. I then call the FileUpdate task passing the ParseIncludeFiles item group along with the RegEx pattern for @VERSION@ and the replacement text. The replacement text is the $(Major), $(Minor), $(Build), and $(Revision) properties that were calculated in the CheckoutVersionFile target. Next, I call FileUpdate again passing in the ParseIncludeFiles item group with a RegEx pattern for @Date@ and it's replacement text. The replacement text for @DATE@ is the value from the Time task, $(BuildDate).

The last step in the ParseTokens target is a call to the Script task that will execute a custom script that I have declared with the other properties at the beginning of the build file:

   68   <PropertyGroup>

   69     <UpdateWebConfigCode>

   70       <![CDATA[

   71         public static void ScriptMain()

   72         {

   73           XmlDocument wcXml = new XmlDocument();

   74           wcXml.Load(@"Web.UI\Web.config");

   75 

   76           XmlElement root = wcXml.DocumentElement;

   77           XmlNodeList connList = root.SelectNodes("//connectionStrings/add");

   78           XmlElement elem;

   79 

   80           foreach (XmlNode node in connList)

   81           {

   82             elem = (XmlElement)node;

   83 

   84             switch (elem.GetAttribute("name"))

   85             {

   86               case "Northwind":

   87                 elem.SetAttribute("connectionString", "server=stageServer;database=Northwind;UID=web;PWD=pass");

   88                 break;

   89               case "pubs":

   90                 elem.SetAttribute("connectionString", "server=stageServer;database=pubs;UID=web;PWD=pass");

   91                 break;

   92             }

   93           }

   94 

   95           wcXml.Save(@"Web.UI\Web.config");

   96         }

   97       ]]>

   98     </UpdateWebConfigCode>

   99   </PropertyGroup>


I added this task call as an example on request from an email I got from a reader. This task will execute the code in the UpdateWebConfigCode property. The code will replace the connectionStrings in the web.config file so that it can be used on another web server after deployment. When the project is deployed from the dev server to the staging server the connectionStrings will be updated and no manual modification will be needed. Again, this is just an example of how you could modify specific configuration settings with MSBuild.

Now that ParseTokens is complete, the next step in the Build target is to run MSBuild on the projects in the VSProjects item group. There is a condition on the MSBuild task call to only build the project if the item 'Group' is equal to the BuildGroup property, which is set to Build by default. The MSBuild task call also passes the Configuration property and creates an output item group called BuildTargetOutputs. After the MSBuild task call completes building each project, I copy the BuildTargetOutputs to the $(BuildPath)\bin folder. I also copy the .pdb files to the same directory if the Configuration is set to Debug. Lastly, I copy the XML documentation file to the same folder. All of this happens for all the projects in the VSProjects item group.

The next task to run in the Build target is to run the unit tests. I do this by calling the UnitTest target directly. There is a condition on the UnitTest target that checks the BuildType property and will only run if the BuildType is set to 'Dev'. In my build scenario, I only want to run my unit tests when building for the Dev environment. If you want to run your unit tests in other environments simply modify this condition. Here is the target with explanation below:

  270   <Target Name="UnitTest" Condition="'$(BuildType)' == 'Dev'">

  271     <!-- Set BuildGroup to 'NUnit' -->

  272     <CreateProperty Value="NUnit">

  273       <Output TaskParameter="Value" PropertyName="BuildGroup" />

  274     </CreateProperty>

  275 

  276     <!-- Build the VSProjects of the 'NUnit' BuildGroup -->

  277     <MSBuild Projects="@(VSProjects)" Condition="'%(Group)' == '$(BuildGroup)'"

  278         Properties="Configuration=$(Configuration);BuildGroup=NUnit">

  279       <Output TaskParameter="TargetOutputs" ItemName="BuildTargetOutputs"/>

  280     </MSBuild>

  281 

  282     <!-- Create ItemGroup for test assemblies -->

  283     <CreateItem Include="$(UnitTestPath)\%(NUnitContents.SubDirectory)\bin\$(Configuration)\$(ProjectNamespace).Tests.dll">

  284       <Output TaskParameter="Include" ItemName="NUnitFiles" />

  285     </CreateItem>

  286 

  287     <Copy SourceFiles="@(BuildTargetOutputs)"

  288         DestinationFolder="$(UnitTestPath)\%(NUnitContents.SubDirectory)\bin\$(Configuration)"

  289         Condition="'$(BuildGroup)' == 'NUnit'"

  290         SkipUnchangedFiles="true" />

  291 

  292     <!-- Copy test files to the test directory -->

  293     <Copy SourceFiles="@(NUnitContents)" DestinationFolder="$(UnitTestPath)\%(NUnitContents.SubDirectory)" SkipUnchangedFiles="true" />

  294 

  295     <!-- Execute and report the NUnit tests -->

  296     <NUnit Assemblies="@(NUnitFiles)"

  297         WorkingDirectory="$(UnitTestPath)"

  298         OutputXmlFile="%(Filename)$(NUnitPostfix)"

  299         ContinueOnError="true" />

  300 

  301     <!-- Generate NUnit report from NUnit test results -->

  302     <Xslt Inputs="@(NUnitFiles->'$(UnitTestPath)\%(Filename)$(NUnitPostfix)')"

  303         Xsl="@(NUnitReportXslFile)"

  304         Output="$(UnitTestPath)\TestReport.html" />

  305 

  306     <!-- Set BuildGroup back to 'Build' -->

  307     <CreateProperty Value="Build">

  308       <Output TaskParameter="Value" PropertyName="BuildGroup" />

  309     </CreateProperty>

  310   </Target>


The first thing I do is set the BuildGroup property to 'NUnit'. This allows me to only compile the projects in the 'NUnit' group that I declared above in the VSProjects item group. The MSBuild task call also passes the Configuration property and the BuildGroup property and creates an output item group called BuildTargetOutputs. After the MSBuild task call completes, the BuildTargetOutputs is copied to the $(UnitTestPath)\%(NUnitContents.SubDirectory)\bin\$(Configuration) folder. I then create an item group called NUnitFiles that contains the unit test assemblies that will be used by the NUnit task below. I also copy the NUnitContents item group to the $(UnitTestPath)\%(NUnitContents.SubDirectory) folder. This is where the .xsl file will reside for translating the unit test results.

The next step involves calling the NUnit task that will run the unit tests based on the unit test project assembly (the NUnitFiles item group contains the test assemblies). I set the WorkingDirectory for the unit tests and then set the OutputXmlFile to be the name of the project with a suffix of '-results.xml'. The next step is to call the Xslt task that will process the unit test results into an HTML report. The Inputs parameter is set to the unit test results file that was generated by the NUnit task. The Xsl parameter is set to the .xsl file that will translate the results into an HTML report. The Output parameter is set to the name of the report file, TestReport.html. The final step is to set the BuildGroup property back to 'Build'.

With the UnitTest target complete, the Build target then calls the DeployBuild target. The overall job of this target is to archive the compiled application in a zip file, deploy the application to the deployment folder, generate and deploy NDoc documentation, check-in the version file that was used by the AssemblyInfo target and clean up the build server folders. Here is the target:

  313   <Target Name="DeployBuild" Condition="'$(BuildGroup)' == 'Build'">

  314     <!-- Compiled web files -->

  315     <CreateItem Include="$(SourcePath)\Web.UI\**\*.*">

  316       <Output TaskParameter="Include" ItemName="CompiledWebFiles" />

  317     </CreateItem>

  318 

  319     <!-- Copy compiled web project to build folder -->

  320     <Copy SourceFiles="@(CompiledWebFiles)" DestinationFiles="@(CompiledWebFiles->'$(BuildPath)\%(RecursiveDir)%(Filename)%(Extension)')" />

  321 

  322     <!-- Deployment web files -->

  323     <CreateItem Include="$(BuildPath)\**\*.*"

  324                 Exclude="$(BuildPath)\**\*.build;

  325                         $(BuildPath)\**\*.scc;

  326                         $(BuildPath)\**\*.vssscc;

  327                         $(BuildPath)\**\*.vspscc;

  328                         $(BuildPath)\**\*.csproj;

  329                         $(BuildPath)\**\*.vbproj;

  330                         $(BuildPath)\**\*.sln;

  331                         $(BuildPath)\**\*.suo;

  332                         $(BuildPath)\**\*.user;

  333                         $(BuildPath)\**\*.cs;

  334                         $(BuildPath)\**\*.vb;

  335                         $(BuildPath)\**\*.resx;

  336                         $(BuildPath)\**\*._old;

  337                         $(BuildPath)\**\*.old;

  338                         $(BuildPath)\**\*.reg;

  339                         $(BuildPath)\**\*.ndoc;

  340                         $(BuildPath)\**\*.snk;

  341                         $(BuildPath)\**\*.nunit;

  342                         $(BuildPath)\**\*.bat;

  343                         $(BuildPath)\**\*.txt;

  344                         $(BuildPath)\**\*.strings;

  345                         $(BuildPath)\**\*.webinfo;

  346                         $(BuildPath)\**\Web References\**;

  347                         $(BuildPath)\**\_sgbak\**;

  348                         $(BuildPath)\**\obj\**;

  349                         $(BuildPath)\bin\*.xml;

  350                         $(BuildPath)\**\Tests\**;

  351                         $(BuildPath)\**\Class\**;

  352                         $(BuildPath)\**\Docs\**">

  353       <Output TaskParameter="Include" ItemName="DeploymentWebFiles" />

  354     </CreateItem>

  355 

  356     <!-- Archive build -->

  357     <Zip Files="@(DeploymentWebFiles)" ZipFileName="$(BuildArchiveFolder)\$(ProjectNamespace)_$(Major).$(Minor).$(Build).$(Revision).zip" />

  358 

  359     <!-- Check to make sure web site directory exists, create if necessary, and clean contents -->

  360     <RemoveDir Condition="Exists('$(DeploymentFolder)')" Directories="$(DeploymentFolder)" />

  361 

  362     <!-- Copy web site -->

  363     <Copy SourceFiles="@(DeploymentWebFiles)" DestinationFiles="@(DeploymentWebFiles->'$(DeploymentFolder)\%(RecursiveDir)%(Filename)%(Extension)')" />

  364 

  365     <!-- Generate and deploy documentation -->

  366     <!--<CallTarget Targets="Documentation" />-->

  367 

  368     <!-- Checkin BuildNumber.txt -->

  369     <VssCheckin UserName="$(VssUsername)"

  370           Password="$(VssPassword)"

  371           LocalPath="$(SourcePath)\Version.txt"

  372           Recursive="False"

  373           DatabasePath="$(VssDatabasePath)"

  374           Path="$/DougRohm/Articles/MSBuildAndCCNet/Version.txt"

  375           Comment="CC.NET auto-incrementing revision number for build." />

  376 

  377     <!-- Clean the source folders -->

  378     <RemoveDir Directories="@(CleanSource)" />

  379     <Exec Command="del /F /Q $(SourcePath)\*.*" />

  380   </Target>


The first thing I do is create an item group that contains all the files in the web project folder. I label this item group CompiledWebFiles since the project has been compiled. I then copy that item group to the BuildPath folder for packaging and archiving. Before I package the web project files into a zip file and archive the files I create another item group that contains all the files in the BuildPath minus the file types that I declare with the Exclude parameter. This assures me that I will not deploy any of those file types to the deployment folder or include them in the zip file for archiving. I then create the zip file with the Zip task and name the zip file according to the ProjectNamespace and the version number calculated in the CheckoutVersionFile target. The next step is to ensure the deployment folder exists and is clean. I do this with the RemoveDir task. I then deploy the web project to the clean deployment folder with the Copy task.

The next step is to generate the documentation with NDoc. In order for me to get the documentation generation to work with .NET 2.0 I'm using a very early alpha version of NDoc2. The creator of NDoc is currently working on the next version but has no timeframe for when it should be released. He was generous enough to allow me to fiddle with an early alpha. If you would like to get a copy of the alpha version you can email Kevin Downs and request a copy. I can't guarantee that he will give everyone a copy, but it's worth a try. He's been very busy with other things which explains the slow progression of NDoc2. You can also monitor the status of NDoc2 on the mailing list. If you can't get a copy of the alpha version, simply comment out this section so that you can proceed to get the build running, or better yet, post on the email list that you need NDoc2! I'm going to omit adding documentation generation for this article simply because NDoc2 is in such an early phase of development.

I then check-in the version file with the updated version back into VSS. The final task is to clean the source folders.

Verifying the Results

To confirm that the build was successful, you can use either CC.NET's web dashboard or CCTray. I prefer to use CCTray which is a systray application that comes with CC.NET that runs on your development machine and monitors the CC.NET projects. Here is a screenshot of CCTray that I've setup to monitor the projects on the build server:



Here is a screenshot of CC.NET's web dashboard:



I also setup individual web sites for each BuildType (Dev, Stage, QA, and Prod):



The folders for these web sites reside in the Inetpub folder:



These folders coincide with the individual DeploymentFolder property set for each BuildType. For this demo, I'm deploying each different BuildType on the same server. This is obviously not a real deployment scenario. Normally, each different BuildType would be deployed to it's own separate server, with the Production server most likely offsite.

If we performed a build for the Dev BuildType and then viewed the web site, we would get the following output:



Notice how the ParseTokens target replaced the tokens in the Default.aspx page with the appropriate values.

Summary

As you can see from this example, customizing MSBuild for CC.NET produces tremendous power and flexibility. In this example, we have created a fully automated build process that we can use for our application from development all the way to production. I covered the most common operations of a build process but left out some operations such as running FXCop or database integration. Hopefully I have sparked your imagination about using MSBuild and CC.NET to create customized solutions for continuous integration.

You can download the Visual Studio solution and a copy of the CC.NET configuration file here. The solution is not under source control. You will need to mimic the VSS setup I used in this article to run the build script.

blog comments powered by Disqus