Saturday, February 02, 2008

Deploying ASP.NET Web Application Projects With MSBuild 2008 In Less Than 25 Lines

One of the things I've had to do recently is create a deployment script for my ASP.NET Web Application Project.  Not one of those project-less web projects, but an old-school VS 2003 style web project. 

The deployment of the web project needed to be part of a larger set of deployments, consisting of a database and some classes to be used as an API.  Additionally, I needed to deploy two versions, one that is for new installations, and another that is targeted at upgrading the previous version.  The upgrade deployment needed to leave the usual suspects like the web.config, and the themes directories, untouched.

I wanted the whole thing automated, and to occur from a build server, and not the desktop of an engineer.  The process needed to be consistent and ideally a single click would do it.

Because of all these requirements, I couldn't use tools like the "Publish" menu option in the IDE, or the Web Application Deployment Project.  So, I used the next best thing, MSBuild.

For this post, I'm going to focus solely on the upgrade version of the web projects, and stay away from the database and the API deployments.  Database deployments really depend on your versioning scheme.  K. Scott Allen is currently putting together some posts about a database versioning scheme that might be helpful for you, if you don't have one you're happy with right now.

So, I want to do the following with my build script:

  1. Compile the web application and put it's output in a directory called "Releases\[Version]\NewInstall\bin"
  2. Read the project file (csproj in my case) to find all the content files and folders that I need to run the application, and move those to the "Releases\[Version]\NewInstall\" directory
  3. Copy the contents of the "Releases\[Version]\NewInstall" directory to "Releases\[Version]\Upgrade" directory, and exclude all the files that I don't want to overwrite on upgrade (in this case, the web.config, and the themes directory).

To do the first item, I'm just going to use the MSBuild task to call the solution, providing the output directory.  I created properties for the version number and the build directory, knowing that I'll probably want to use them elsewhere.  Also, separated the version number and the build directory, because I'll probably want to use the build number on it's own as well.

<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="3.5">
    <PropertyGroup>
        <VersionNumber>1.0.0</VersionNumber>
        <BuildRoot>..\Releases\$(VersionNumber)\</BuildRoot>
        <NewInstallDir>$(BuildRoot)\NewInstall\</NewInstallDir>
        <UpgradeDir>$(BuildRoot)\Upgrade\</UpgradeDir>
    </PropertyGroup>
    <Target Name="Build">
        <MSBuild Projects="DeploymentProject.sln" Properties="OutputPath=$(NewInstallDir)bin\" />
    </Target>
</Project>

The next thing I need to do is copy over the content files.  I want to make sure I don't deploy any of the source files.  Instead of regex or rolling something of my own to filter out the source files, what I can do is use the information in the the .csproj.

One handy thing you can do with MSBuild is import another project, and in this case, we can import the .csproj.  Once I do that, I have access to all the properties of the imported project, including the list of content files.  The .csproj keeps track of the files and categorizes them into, references, files to be compiled, content files, empty folders, and unknown.

<Import Project="DeploymentProject\DeploymentProject.csproj"/>

With the collection of content files, empty folders and unknown, and can move all of my non-compiled files to the deployment directory.  I'm going to put this into the same target as my compile task.  I'm not a big fan of chaining targets together with dependencies.  I find it confusing.

Here is what gets added right after the MSBuild task:

<Copy SourceFiles="@(Content->'DeploymentProject\%(RelativeDir)%(FileName)%(Extension)')"
      DestinationFiles="@(Content->'$(NewInstallDir)%(RelativeDir)%(FileName)%(Extension)')" />
<Copy SourceFiles="@(None->'DeploymentProject\%(RelativeDir)%(FileName)%(Extension)')"
      DestinationFiles="@(None->'$(NewInstallDir)%(RelativeDir)%(FileName)%(Extension)')" />
<MakeDir Directories="@(Folder->'$(NewInstallDir)%(RelativeDir)')" />

I'm using an MSBuild technique called transforms, which is explained in more in that link.

The next thing I need to do is copy the output to the upgrade directory.  We can do this much easier now that you can create items on the fly with MSBuild 2008.  The example speaks to this new feature:

<CreateItem Include="$(NewInstallDir)**" Exclude="**\App_Themes\**;**\Web.config">
    <Output ItemName="UpgradeFiles" TaskParameter="Include" />
</CreateItem>
<Copy SourceFiles="@(UpgradeFiles)"
      DestinationFiles="@(UpgradeFiles->'$(UpgradeDir)%(RecursiveDir)%(FileName)%(Extension)')" />
<MakeDir Directories="@(Folder->'$(UpgradeDir)%(RelativeDir)')" />

The last MakeDir task in there gets all the empty folders from the project directory.

Here's what it looks like put together:

  1 <?xml version="1.0" encoding="utf-8"?>
  2 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="3.5">
  3     <Import Project="DeploymentProject\DeploymentProject.csproj"/>
  4     <PropertyGroup>
  5         <VersionNumber>1.0.0</VersionNumber>
  6         <BuildRoot>Releases\$(VersionNumber)\</BuildRoot>
  7         <NewInstallDir>$(BuildRoot)NewInstall\</NewInstallDir>
  8         <UpgradeDir>$(BuildRoot)Upgrade\</UpgradeDir>
  9     </PropertyGroup>
10     <Target Name="Build">
11         <MSBuild Projects="DeploymentProject.sln" Properties="OutputPath=..\$(NewInstallDir)bin\" />
12         <Copy SourceFiles="@(Content->'DeploymentProject\%(RelativeDir)%(FileName)%(Extension)')"
13               DestinationFiles="@(Content->'$(NewInstallDir)%(RelativeDir)%(FileName)%(Extension)')" />
14         <Copy SourceFiles="@(None->'DeploymentProject\%(RelativeDir)%(FileName)%(Extension)')"
15               DestinationFiles="@(None->'$(NewInstallDir)%(RelativeDir)%(FileName)%(Extension)')" />
16         <MakeDir Directories="@(Folder->'$(NewInstallDir)%(RelativeDir)')" />
17         <CreateItem Include="$(NewInstallDir)**" Exclude="**\App_Themes\**;**\Web.config">
18             <Output ItemName="UpgradeFiles" TaskParameter="Include" />
19         </CreateItem>
20         <Copy SourceFiles="@(UpgradeFiles)"
21               DestinationFiles="@(UpgradeFiles->'$(UpgradeDir)%(RecursiveDir)%(FileName)%(Extension)')" />
22         <MakeDir Directories="@(Folder->'$(UpgradeDir)%(RelativeDir)')" />
23     </Target>
24 </Project>