Search
Recent Tweets

Entries in .net (31)

Tuesday
May082012

Build Automation Part 2: Building and Packaging

Series Index

Build Automation Part 1: Overview and Pre-build Tasks
Build Automation Part 2: Building and Packaging
Build Automation Part 3: App Deployment Script
Build Automation Part 4: Database and Report Deployments

In the last post in this series I discussed an overview of some build and deployment automation goals and covered some pre-build activities. In this post I cover compiling the code and packaging the files to be deployed.

Building the Code

In the Compile target the main solution file is built along with a few misc. projects that are not part of the main solution file. Refer back to part 1 for the definition of some of the items used such as $(CompileDependsOn), @(SolutionFile), etc.

One item that is not obvious here is @(ServiceAppBuild); basically there is another WCF service application that runs locally on the end user's machine in parallel with the client app. This application has its own build script and the client app being built passes in a ClientOutDir property that tells the service application build script where to copy the service app client files to after they are built. Really I wanted this piece to be handled by an internal NuGet package; unfortunately I had some issues setting up an internal NuGet server and I ran out of time.
<Target Name="Compile" DependsOnTargets="$(CompileDependsOn)">
    <Message Text="Starting compile of @(SolutionFile)"/>

    <ItemGroup>
      <SharedLibPath Include="..\MyApp.Shared\_Lib"/>
    </ItemGroup>

    <Message Text="Building service app projects; client output will be copied to %(SharedLibPath.FullPath)"/>

    <!-- First we need to copy the client files of this dependency app into our client app -->
    <!-- Really we need to change this so the dependency is nuget based but that'll have to be done later -->
    <MSBuild Projects="@(ServiceAppBuild)" Targets="Rebuild"
             Properties="Configuration=$(Configuration);ClientOutDir=%(SharedLibPath.FullPath)"/>

    <MSBuild Projects="@(SolutionFile)" Targets="Rebuild"
             Properties="Configuration=$(Configuration)"/>
    <Message Text="Compile complete of @(SolutionFile)"/>

    <Message Text="Compiling misc other related such as Service Controller"/>
    <MSBuild Projects="@(MiscOtherToBuild)" Targets="Rebuild"
             Properties="Configuration=$(Configuration)"/>

    <Message Text="All compilation is done"/>
</Target>

Breaking Up the Build Script

To keep the main MSBuild script from getting too lengthy I split it as follows:
  • MyApp.build - Main driver script that is always the entry point. Contains pre-build and build targets and wrapper targets to call into other build scripts.
  • Package.build - Handles taking all the compiled output of the main build script and copying the appropriate content to a deployment folder and packaging that up in a compressed archive.
  • Shared.build - Common tasks and properties that both the main build script and the package script need.
The main build script imports the others:
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0"
         DefaultTargets="Compile">

  <Import Project="Shared.build"/>
  <Import Project="Package.build"/>
  <!-- ... -->
</Project>

Setting Up the Packaging Build Script

Imports, Property and Item Groups

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
  <Import Project="Shared.build"/>
  <Import Project="..\MyApp.Client\MyApp.Client.vbproj"/>

  <PropertyGroup>
    <DeployPackageFolder>..\build\artifacts\deployPackage\</DeployPackageFolder>
    <RemovePackageFilesAfterZip Condition=" '$(RemovePackageFilesAfterZip)' == '' ">true</RemovePackageFilesAfterZip>
    <PowerShellAssembly>$(MSBuildExtensionsPath)\ExtensionPack\4.0\MSBuild.ExtensionPack.TaskFactory.PowerShell.dll</PowerShellAssembly>
    <TargetServer/>
  </PropertyGroup>
  
  <ItemGroup>
    <DeployPackageFolderItem Include="$(DeployPackageFolder)"/>
    <ClientProjectItem Include="..\MyApp.Client\MyApp.Client.vbproj" />
  </ItemGroup>
  
  <!-- ... -->
</Project>

Packaging Initialization

This target creates an error if the build number is not defined and then removes and recreates a deployment package folder where the deployable content will be placed.
<Target Name="Init">
    <Error Condition="'$(BUILD_NUMBER)' == ''" Text="Build number is required" />

    <MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" 
		Path="@(DeployPackageFolderItem)"
        Condition="Exists(%(DeployPackageFolderItem.FullPath))" />
    <RemoveDir Directories="@(DeployPackageFolderItem)"/>
    <MakeDir Directories="@(DeployPackageFolderItem)"/>
</Target>

Publishing the ClickOnce Client Project

This target creates the ClickOnce manifests of the client project and is the equivalent of Build-->Publish in Visual Studio. Effectively the client project gets built twice, once as a part of the main solution, and again when packaged in Publish mode. Refer back to part 1 for the CreateLicenseFiles dependency.

I will speak to some of the details following the target definition:
<Target Name="BuildClickOncePublishFiles" DependsOnTargets="Init;CreateLicenseFiles">
    <Error Condition=" '$(TargetServer)' == '' "
    Text="'/p:TargetServer:server-name' is required to generate ClickOnce publish files"/>

    <GetClickOnceNextVersion TargetServer="$(TargetServer)" BuildNumber="$(BUILD_NUMBER)">
      <Output TaskParameter="ReturnValue" PropertyName="ClickOnceAppVersion"/>
    </GetClickOnceNextVersion>

    <GetFrameworkSdkPath>
      <Output TaskParameter="Path" PropertyName="SdkPath" />
    </GetFrameworkSdkPath>
    <Message Text="SdkPath: $(SdkPath)" />

    <!-- Other properties: Platform, PublishUrl, InstallUrl, Platform (i.e. x86)-->
    <MSBuild Projects="@(ClientProjectItem)"
    Targets="Publish" 
    Properties="Configuration=$(Configuration); 
BootstrapperComponentsLocation=Relative; 
GenerateBootstrapperSdkPath=$(SdkPath)Bootstrapper; 
PublishDir=$(DeployPackageFolder)ClickOnce\; 
ApplicationVersion=$(ClickOnceAppVersion); 
UpdateInterval=1; 
UpdateIntervalUnits=Hours; 
UpdatePeriodically=true">
      <Output ItemName="OutputFiles" TaskParameter="TargetOutputs"/>
    </MSBuild>

    <CreateItem Include="$(DeployPackageFolder)ClickOnce\">
      <Output TaskParameter="Include" ItemName="ClickOnceDeployPath" />
    </CreateItem>

    <Error Condition="!Exists(%(ClickOnceDeployPath.FullPath))"
    Text="Expected ClickOnce folder $(ClickOnceDeployPath) to exist. 
	Either a partial target was run and not a full 
	build, build output was not at expected location, and/or build output copy failed." />
</Target>

Getting the Next ClickOnce Version Number

The target first raises an error if there was not a TargetServer value passed the build script; this value is used in the next step to determine the next ClickOnce revision number. The build info file created in part 1 gets deployed out to the target server along with the rest of the app. The below task reaches out to the target server, looks for the build info file, reads in the current ClickOnce revision number (or uses 0 if the file was not found), increments that value, writes the local file back out, and returns the full publish version for the new build.
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="GetClickOnceNextVersion" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <TargetServer Required="true" ParameterType="System.String" />
      <BuildNumber Required="true" ParameterType="System.String" />
      <ReturnValue Output="true"/>
    </ParameterGroup>
    <Task>
      <![CDATA[
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Getting ClickOnce next publish version")
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "TargetServer is $targetServer")  
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "BuildNumber is $buildNumber")    
  $buildInfoFile = "\\$targetServer\SomeHiddenShare$\MyApp\BuildInfo.csv"
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Looking for $buildInfoFile")
    
  $appRev = -1
  if (Test-Path $buildInfoFile)
  {
      $obj = import-csv $buildInfoFile
      $appRev = [int32]$obj.ClickOnceRevision
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Found remote build info file; ClickOnceRevision is $appRev")
  }
  $nextRev = $appRev + 1
    
  #Major, Minor, Build, Revision
  $buildVer = new-object System.Version($buildNumber)
  $clickOnceVer = new-object System.Version($buildVer.Major, $buildVer.Minor, $buildVer.Build, $nextRev)
  
  # need to update local build info file with new publish version
  $localBuildInfoFile = "$(BuildArtifacts)BuildInfo.csv"
  
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Local build info file is $localBuildInfoFile")
  if (!(Test-Path $localBuildInfoFile))
  {
    throw "Failed to find expected build info file $localBuildInfoFile"
  }
  
  $obj = import-csv $localBuildInfoFile
  $obj.ClickOnceRevision = $nextRev
  $obj.ClickOncePublishVersion = $clickOnceVer
  $obj | export-csv $localBuildInfoFile -notypeinformation  
  
  $returnValue = $clickOnceVer.ToString()
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Next ClickOnce publish version is $returnValue")    
      ]]>
    </Task>
</UsingTask>

ClickOnce App Identity and Multiple Environments

The ClickOnce publish version is a curious, brittle little dependency. At one point I thought that there would be no problem with using the same publish version across different environments. After deploying the app to a test environment however, ClickOnce generated an error that the app was already installed; the fact that it was installed from another server (dev vs test) made no difference.

As a result, for the first deployment to each target environment, I started the ClickOnceRevision value at a different starting number such that the likelihood of a collision would be rare. Additionally, since the full ClickOnce publish version here is based on the Major, Minor, and Build number of the app version (which typically changes with each push), a conflict would only happen to begin with when deploying the same app version to another server with the same ClickOnce revision number. Furthermore, only so many versions are kept in the ClickOnce cache and generally only developers or business experts would likely run into this problem.

You can use Mage or MageUI to change the name of the deployment manifest to avoid this issue. Some mention changing the app's assembly and product names for each environment but to me that causes as many problems as it solves and with our 8 environments it is not ideal. Still if your ClickOnce app is made available from the Start menu (ours is online only) this could be more of a need.

Updating the ClickOnce Web Page

We previously modified the default ClickOnce web page that Visual Studio generates to change the .net FX bootstrapping and to disable the Run button for a while after users clicked it so they would not get multiple instances of the app launched accidentally if they double-clicked it or otherwise hit it again because the app didn't appear to launch quickly enough.

This target collects various pieces of build information and calls another target to update placeholders in the webpage to reflect the current app version, file version, publish version and built on date.
<Target Name="CreateClickOnceWebPage">    
    <ItemGroup>
      <ClickOnceDefaultFile Include="default.htm" />
    </ItemGroup>    

    <Copy SourceFiles="@(ClickOnceDefaultFile)" 
		DestinationFolder="$(DeployPackageFolder)ClickOnce\" />

    <GetFileVersion>
      <Output TaskParameter="ReturnValue" PropertyName="FileVersion"/>
    </GetFileVersion>

    <GetClickOncePublishVersion>
      <Output TaskParameter="ReturnValue" PropertyName="ClickOnceVersion"/>
    </GetClickOncePublishVersion>

    <GetBuiltOnTime>
      <Output TaskParameter="ReturnValue" PropertyName="BuiltOnTime"/>
    </GetBuiltOnTime>

    <Message 
    Text="Updating ClickOnce web page $(DeployPackageFolder)ClickOnce\default.htm with ClickOnce Version $(ClickOnceVersion), File Version $(FileVersion)"/>

    <UpdateClickOncePage
      WebPageFilename="%(DeployPackageFolderItem.FullPath)ClickOnce\default.htm"
      ClickOnceVersion="$(ClickOnceVersion)"
      FileVersion="$(FileVersion)"
      AppVersion="$(BUILD_NUMBER)"
      BuiltOn="$(BuiltOnTime)"
      />
</Target>
The tasks that retrieve the build properties could be consolidated but currently look like:
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="GetFileVersion" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <ReturnValue Output="true"/>
    </ParameterGroup>
    <Task>
      <![CDATA[
            $obj = import-csv $(BuildArtifacts)BuildInfo.csv
            $returnValue = $obj.FileVersion
      ]]>
    </Task>
  </UsingTask>

  <UsingTask TaskFactory="PowershellTaskFactory" TaskName="GetClickOncePublishVersion" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <ReturnValue Output="true"/>
    </ParameterGroup>
    <Task>
      <![CDATA[
            $obj = import-csv $(BuildArtifacts)BuildInfo.csv
            $returnValue = $obj.ClickOncePublishVersion
      ]]>
    </Task>
  </UsingTask>

  <UsingTask TaskFactory="PowershellTaskFactory" TaskName="GetBuiltOnTime" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <ReturnValue Output="true"/>
    </ParameterGroup>
    <Task>
      <![CDATA[
            $obj = import-csv $(BuildArtifacts)BuildInfo.csv
            $returnValue = $obj.BuiltOn
      ]]>
    </Task>
</UsingTask>
The UpdateClickOncePage task takes those values in as parameters and replaces special placeholders with them.
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="UpdateClickOncePage" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <WebPageFilename Required="true" ParameterType="System.String" />
      <ClickOnceVersion Required="true" ParameterType="System.String" />
      <FileVersion Required="true" ParameterType="System.String" />
      <AppVersion Required="true" ParameterType="System.String" />
      <BuiltOn Required="true" ParameterType="System.String" />
    </ParameterGroup>
    <Task>
      <![CDATA[
      Set-ItemProperty $WebPageFilename -name IsReadOnly -value $false
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "WebPageFilename is $WebPageFilename")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "ClickOnceVersion is $ClickOnceVersion")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "FileVersion is $FileVersion")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "AppVersion is $AppVersion")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "BuiltOn is $BuiltOn")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Reading WebPage content")
      $page = Get-ChildItem $WebPageFilename      
      $content = [string]::join([environment]::newline, (get-content $page))
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Replacing version tokens with version numbers")
      $content = $content.Replace("<!--+(ClickOnceVersion)-->", $ClickOnceVersion)
      $content = $content.Replace("<!--+(FileVersion)-->", $FileVersion)
      $content = $content.Replace("<!--+(AppVersion)-->", $AppVersion)
      $content = $content.Replace("<!--+(BuiltOn)-->", $BuiltOn)
      Set-Content $WebpageFilename ($content)
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Web Page Content modified")
      ]]>
    </Task>
</UsingTask>



Staging the Files For Deployment

The ClickOnce files have already been staged into a $(DeployPackageFolder)ClickOnce\ folder and now the same is needed for Satellite files, Service files, and deployment scripts.

Setup

<Target Name="PackageDeployment" 
DependsOnTargets="BuildClickOncePublishFiles;CreateClickOnceWebPage">
	<CreateProperty Value="bin\x86\$(Configuration)\">
	  <Output TaskParameter="Value" PropertyName="BinOutput"/>
	</CreateProperty>
	<Message Text="Bin output path is $(BinOutput)"/>

	<!-- ... -->
</Target>

Staging Service Files

<CreateItem Include="..\MyApp.Service\$(BinOutput)*.*">
  <Output TaskParameter="Include" ItemName="ServiceSourceFiles" />
</CreateItem>
<CreateItem Include="%(DeployPackageFolderItem.FullPath)Services\">
  <Output TaskParameter="Include" ItemName="ServiceDestFolder" />
</CreateItem>

<Message Text="Copying service source files to @(ServiceDestFolder)"/>

<Copy
		SourceFiles="@(ServiceSourceFiles)"
		DestinationFolder="@(ServiceDestFolder)"
		SkipUnchangedFiles="true"
	/>

<Error Condition="!Exists(%(ServiceDestFolder.FullPath))"
Text="Expected services folder %(ServiceDestFolder.FullPath) to exist. Either a partial target was run and not a full 
build, build output was not at expected location, and/or build output copy failed." />

Staging Satellite Files

This app has various satellite module assemblies that get loaded dynamically off a network share. Other assemblies and files such as config files may not get loaded directly off the network but may be copied to client machines from the network. The intent behind most of these files is allowing for certain updates to files without having to rollout a new application build.

This section includes and excludes specific files to copy just those files intended for satellite distribution.
<ItemGroup>
  <SatelliteSourceFiles Include="..\**\MyApp.*.dll; ..\**\MyApp.*.pdb; ..\**\ThirdParty.*.dll; 
	..\**\ThirdParty.*.pdb; ..\**\*.xslt; ..\**\*.css"
   Exclude="..\**\*Shared*; ..\**\*Oracle*; ..\**\*Database*; ..\**\*Business*; ..\**\obj\; 
	..\**\*MyApp.Client*; ..\**\*MyApp.Console*; ..\**\*MyApp.Service*" />
  <SatelliteSourceFiles Include="..\ThirdParty.Library\ThirdParty.dll"/>
  <SatelliteSourceFiles Include="..\MyApp.Client\MyApp.Help.chm"/>
</ItemGroup>
<CreateItem Include="%(DeployPackageFolderItem.FullPath)Satellite\">
  <Output TaskParameter="Include" ItemName="SatelliteDestFolder" />
</CreateItem>

<Message Text="Copying satellite source files to @(SatelliteDestFolder)"/>
<Copy
		SourceFiles="@(SatelliteSourceFiles)"
		DestinationFolder="@(SatelliteDestFolder)"
		SkipUnchangedFiles="true"
	/>

<Error Condition="!Exists(%(SatelliteDestFolder.FullPath))" 
Text="Expected satellite folder %(SatelliteDestFolder.FullPath) to exist. 
Either a partial target was run and not a full build, build output was not 
at expected location, and/or build output copy failed." />

<CreateItem Include="$(DeployPackageFolder)Satellite\ServiceApp\">
  <Output TaskParameter="Include" ItemName="ServiceAppDeployPath" />
</CreateItem>

<MakeDir Directories="@(ServiceAppDeployPath)"/>

<Message Text="Staging Service app files to %(ServiceAppDeployPath.FullPath)"/>
<StageServiceApp DestStagingDir="%(ServiceAppDeployPath.FullPath)" />

<Error Condition="!Exists(%(ServiceAppDeployPath.FullPath))"
Text="Expected satellite ServiceApp folder %(ServiceAppDeployPath.FullPath) to exist. 
Either a partial target was run and not a full build, build output was not at expected 
location, and/or build output copy failed." />
The prior target called this StageServiceApp task which invokes a PowerShell script of a dependent service app to copy the appropriate build output of that app to the Satellite folder of this ClickOnce app.
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="StageServiceApp" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <DestStagingDir Required="true" ParameterType="System.String" />
    </ParameterGroup>
    <Task>
      <![CDATA[
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Staging ServiceApp to $DestStagingDir")
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Loading ServerStageServiceApp.ps1")
      . ..\..\..\..\Common\SomeServiceApp\Main\Code\SomeServiceApp\ServerStageServiceApp.ps1
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Performing staging of service app")
      ServerStage $DestStagingDir
      $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Service app staging complete")
      ]]>
    </Task>
</UsingTask>

Staging Deployment Scripts

Finally some PowerShell deployment scripts are copied into the deployment package folder along with the build info file and a compression assembly used to backup the existing installation.
<Message Text="Copying deployment scripts into deploy folder"/>
<ItemGroup>
  <BuildItemsToCopy Include="$(BuildFolder)Deploy*.*"/>
  <!-- might consider removing Ionic later since we are using 7zip; Ionic was faster at network zip ops though -->
  <BuildItemsToCopy Include="$(BuildFolder)Ionic.Zip.dll"/>
  <BuildItemsToCopy Include="$(BuildArtifacts)BuildInfo.csv"/>
</ItemGroup>
<Copy SourceFiles="@(BuildItemsToCopy)" DestinationFolder="@(DeployPackageFolderItem)"/>

<!-- we've copied into deploy folder so we can remove this one -->
<Delete Files="$(BuildArtifacts)BuildInfo.csv"/>

Compressing the Deployment Package Folder

At this point all the files that need to be deployed (or that perform the deployment) reside inside a root deployment package folder. This section of PackageDeployment target first creates a normal zip file of that folder using 7-zip, creates a self-extracting executable from that, and finally deletes the original zip file and deployment package folder as everything that is needed is in the SFX package.

I am not going to go into details here about how the compression is done as I covered that in this post and I like to follow the DRY principle for my blog too :).
<ItemGroup>
  <FilesToZip Include="$(DeployPackageFolderItem)**\*" 
  Exclude="..\**\*.build; ..\**\*.licx"/>
</ItemGroup>

<Message Text="Creating zip archive of contents of @(DeployPackageFolderItem)"/>

<!-- Switched to 7-zip cmdline since extension pack zip can't create self-extracting exectuable (sfx)
	 DotNetZip (Ionic) can create sfx but it was hanging zipping up some build artifacts for some reason
<MSBuild.ExtensionPack.Compression.Zip TaskAction="Create" CompressPath="@(DeployPackageFolderItem)"
  ZipFileName="$(BuildArtifacts)MyApp.deploy.zip" RemoveRoot="@(DeployPackageFolderItem)"/>
-->
<Exec Command="$(BuildFolder)Zip-Install-Create.bat "%(BuildArtifactsItem.FullPath)"" />

<!-- file is in zip and can be removed now -->
<Delete Files="$(DeployPackageFolder)BuildInfo.csv"/>

<!-- now that contents are zipped, delete package folder -->
<MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" 
Path="@(DeployPackageFolderItem)" Force="true" RetryCount="5" 
Condition="Exists(%(DeployPackageFolderItem.FullPath)) AND $(RemovePackageFilesAfterZip) == true" />

<RemoveDir Directories="@(DeployPackageFolderItem)" 
Condition="$(RemovePackageFilesAfterZip)"/>

Calling the Build Script Now

Back in the main MyApp.build script, there is a convenience target defined to compile everything and then call the package deployment target.
<Target Name="BuildAndPackage" DependsOnTargets="Compile;PackageDeployment"/>
So now back in PowerShell a more complete call to the build script might look like one of the below:
msbuild MyApp.build /t:BuildAndPackage /p:BUILD_NUMBER=3.3.0.0 `
	/p:TargetServer=app-server-dev
	
msbuild MyApp.build /t:BuildAndPackage /p:BUILD_NUMBER=3.3.0.0 `
	/p:TargetServer=app-server-test /p:Configuration=Release 
At this point the EXE can be run to extract the contents and launch the PowerShell script to deploy the app. Because custom parameters cannot really be passed to the EXE on through to the deployment script, additional build script changes are required to indicate whether this is an automated CI build and deployment or one being run in a user interactive mode. Alternatively such an indicator parameter could be used to not create the SFX for a CI build but just the zip file, or to not delete the zip file and leave both. I'll leave such decisions as an exercise for the reader :).

What's Next?

I may post some details on the deployment scripts and/or Team City in the future. Happy building and deploying!
Saturday
May052012

Build Automation Part 1: Overview and Pre-build Tasks

Series Index

Build Automation Part 1: Overview and Pre-build Tasks
Build Automation Part 2: Building and Packaging
Build Automation Part 3: App Deployment Script
Build Automation Part 4: Database and Report Deployments

A recent major application release at work required various changes to our build and deployment process. Our continuous integration process was created probably 5+ years ago when Cruise Control .Net was king. It has served us well but I did not want to invest in it anymore, especially with alternatives like Jenkins and Team City out there. CI aside I wanted to reevaluate the build and deployment process as a whole and overhaul as much of it as possible to help ease the burden.

In this post I will discuss an overview of this initiative and address build script details in terms of pre-build activities. If time, interest, and priorities permit, I will create follow-up posts on topics such as build and packaging details, deployment scripts, and the setup and configuration of Team City.

Goals of the Build and Deployment Overhaul

  • Build script - Producing a more complete build script that could be run both outside and inside a CI process to build and package a deployment.
  • CI system change - Replacing Cruise Control with Team City.
  • Further automation - Automating some features that were previously manual such as ClickOnce certificate installation.
  • Deployment packaging - Compressing the app files to be deployed into a self-extracting archive that runs a deployment script after extraction.
  • Deployment script rewrite - Replacing an old deployment batch file with a more capable PowerShell script.
  • Remote deployment - The original process required performing the deployment locally on the target server but the desire was to be able to deploy from any given location to any given location remotely.
  • Existing deployment backup - Compressing the previous installation before overwriting it in the event of issues.
  • Dead-simple production deployments - While our CI process automated deployments to some environments, we have not been in a position to do this for production due to auditing, policy, security, and other reasons. The existing deployment script required passing in some parameters and some knowledge of how things were setup. With handing over prod deployments to new parties, there was a need to simplify things as much as possible to reduce the knowledge requirement and the chance of human error.
  • Artifact deployment enhancements - Additional automation around deploying database and report changes.

What to Do in a Build Script vs. a CI Tool?

The ultimate goal here is automating the building, packaging and deployment of an application and CI is the preferred way to accomplish that. However I am of the opinion that this automation shouldn't be hopelessly dependent on CI to work. There may be times when a build and deployment may need to be done from a developer's machine. Additionally the development, testing, and debugging an automated build is more easily done outside of a CI process.

On one end of the extreme, the build steps and tools of a CI system like Team City could be used with no build script at all. Separate steps can be configured in a web interface of the CI tool to retrieve source code, compile a solution or project, install packages via NuGet, run unit tests and much more. In this scenario the CI build configuration effectively is the build script.

On the other end of the extreme, every single action could be done in a build script and the CI system could just call that build script.

The CI-centric approach has the advantages of a simple wizard-like setup, leveraging the power of a CI system, and avoiding doing a lot of potentially tedious work in what might be a large build script otherwise. A complete build script has the advantages of precise control and independence from a CI tool but comes at the cost of more work and maintenance and likely means the CI tool is being under-utilized.

As with most things I think nirvana lies somewhere in the middle. Here is what I settled on:
  • Source Control - Done from a CI tool. When building locally I usually already have the latest source code. From a CI build, tools like Team City are pretty smart about efficient VCS operations in terms of caching, settings reuse, dependent builds, VCS build triggers, labeling, supported platforms etc.
  • Building - pre-build activities (NuGet etc.), code compilation, and packaging the deployable files are done from the build script. These are the core pieces that may need to be done both inside and outside of a CI tool.
  • Unit tests - Done from a CI tool. I may not always want to execute unit tests when building the app locally but from a CI build I always want some level of tests run. Decent CI tools support most popular unit testing frameworks really well.
  • Code analysis and other - Most other tasks such as code analysis or build notifications are left to a CI system to handle as they are well-suited for these tasks.

Choosing Tools

Build Tool

At the risk of turning you away, I ended up going with MSBuild, partially because I was somewhat familiar with it, there was some MSBuild content invoked from Cruise Control that was salvageable, and it has a solid base of support. I think MSBuild is a decent tool capable of far more than the simple compilation tasks many are familiar with from project and solution files. Mostly I am just not fond of XML-based build tools; I would much rather use a scripting language.

Had I rediscovered PSake sooner I probably would have chosen it instead. This Ayende post on Psake is a bit older but he discusses Psake a bit and compares it to some other options such as NAnt, Rake, and Bake and I think many of the points are still valid.

Some of the MSBuild pain would be offset by many of the great MSBuild task collections out there such as the MSBuild Extensions Pack. Additionally PowerShell script can be embedded directly inline into the an MSBuild script (through a PowerShell task) or MSBuild can execute an external PowerShell script file to offset some of the XML heaviness.

Deployment

For the deployment I chose PowerShell to extract the build archive that MSBuild creates, backup the existing installation, stop services, disconnect server sessions, copy files, install and start services, and more. The idea was to run this deployment script directly from a self-extracting executable without someone having to launch PowerShell, dot source the file, invoke functions, etc.

Initial Prerequisites

For this app's build (.net Winforms ClickOnce smart client) the following needs to be installed:
  • Windows SDK or Visual Studio depending on whether the build is on a dev box or CI server
  • .NET FX 4.0 for MSBuild
  • MSBuild Extension Pack is used for several custom tasks. Used the x86 v4.0.4.0 installer.
  • TFS Explorer - on the build server for TFS source control access
  • Software component dependencies of the app being built. For this app this included items such as:
    • Oracle Data Access Components (ODAC)
    • Infragistics NetAdvantage Controls
    • Office Outlook PIAs
    • Exchange Web Services Managed API (EWS)

Where to Store Build Script Files

I went with the convention of creating a build folder under each application branch; in our case with TFS source control usually that is at least Dev, Main, and Production. Often the build scripts and configuration will be nearly identical between branches but there may be some configuration or other differences. As changes to the build scripts are made in the Dev branch and verified they will later be merged to Main and Production over the iteration lifecycle.

The app's code can be referenced easily in relative fashion by going up one level. Any temporary output of the build script will be created in an artifacts subfolder of the build folder.

All the contents of the build folder are stored in source control, minus the output in the artifacts subfolder. During a CI build, all of the build script items will be pulled from source control along with the rest of the code and resources for the application.

Cleanup

The first task will be cleaning up any output from previous builds and ensuring the build is started with a clean slate. First some properties and item groups are defined at the top of the build script to reference various files and folders that will be cleaned up.
<PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
	<NuGetPackagesFolder>..\packages\</NuGetPackagesFolder>
	<Branch>Dev</Branch>
</PropertyGroup>

<ItemGroup>
	<BuildRoot Include="..\build\"/>
	<BuildArtifactsItem Include="..\build\artifacts\"/>
	<SolutionFile Include="..\MyApp.sln"/>
	<NuGetPackagesFolderItem Include="$(NuGetPackagesFolder)"/>
	<LicenseFiles Include="..\**\licenses.licx" Exclude="..\build\licenses.licx"/>
</ItemGroup>
	
<ItemGroup Label="MiscOtherBuild">
	<!-- Removed next 2 from main app solution as rarely used/changed or has dependency issues -->
	<MiscOtherToBuild Include="../MyApp.ServiceController/MyApp.ServiceController.csproj"/>
	<MiscOtherToBuild Include="../ThirdParty.Library/ThirdParty.Library.vbproj"/>
	<ServiceAppBuild Include="..\..\..\..\Common\SomeServiceApp\$(Branch)\Code\build\SomeServiceApp.build"/>
</ItemGroup>
The clean target invokes the clean target in the main app solution, related project files outside of the main solution, and a dependent service application to cleanup compiled output. It also removes a build artifacts folder that will get created with the results of the build process. Finally it deletes license.licx files and the NuGet packages folder to ensure a fresh pull.
<Target Name="Clean">
	<Message Text="Cleaning up"/>

	<!-- do a clean on solution first since it has the most output, leave custom tasks at end -->
	<MSBuild Projects="@(SolutionFile)" Targets="Clean" 
		Properties="Configuration=$(Configuration);"/>

	<Message Text="Removing existing content in @(BuildArtifactsItem)"/>
	<!-- Subdirectories could exist which can lead to error "directory is not empty" 
	removing top level dir normally -->
	<MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" 
		Path="@(BuildArtifactsItem)"
		Condition="Exists(%(BuildArtifactsItem.FullPath))" />
	<Message Text="Removing @(BuildArtifactsItem) folder"/>
	<RemoveDir Directories="@(BuildArtifactsItem)"/>

	<Message Text="Removing NuGet packages folder @(NuGetPackagesFolder)"/>
	<MSBuild.ExtensionPack.FileSystem.Folder TaskAction="RemoveContent" 
		Path="@(NuGetPackagesFolderItem)" Force="true" 
		Condition="Exists(%(NuGetPackagesFolderItem.FullPath))" RetryCount="5" />
	<RemoveDir Directories="@(NuGetPackagesFolderItem)" />

	<!-- File could be readonly; we could add condition of not readonly. Continue on error -->
	<Message Text="Removing any existing licenses.licx files"/>
	<Delete Files="@(LicenseFiles)" ContinueOnError="true"/>

	<MSBuild Projects="@(MiscOtherToBuild)" Targets="Clean" 
		Properties="Configuration=$(Configuration);"/>
	<MSBuild Projects="@(ServiceAppBuild)" Targets="Clean" 
		Properties="Configuration=$(Configuration);"/>

	<Message Text="Clean complete"/>
</Target>

The Path to Compilation

A Compile target is started with a DependsOnTargets value of a $(CompileDependsOn) property to be defined that will list all the targets that must be executed successfully before compilation:
 <Target Name="Compile" DependsOnTargets="$(CompileDependsOn)">
	<!-- ... -->
 </Target>
This property is defined as follows at the top of the build script.
<PropertyGroup>
	<CompileDependsOn>
	Init;CreateLicenseFiles;NuGetPackageRestore;InstallClickOnceCert;UpdateAssemblyInfo
	</CompileDependsOn>
</PropertyGroup>

Initialization

<Target Name="Init" DependsOnTargets="Clean">
	<Message Text="Making build artifacts dir of @(BuildArtifactsItem)"/>
	<MakeDir Directories="@(BuildArtifactsItem)"/>

	<Message Text="Build number is $(BUILD_NUMBER)"/>
	<CreateBuildInfoFile/>
</Target>
The Init target first creates a build artifacts folder where output of the build will be placed.

It then invokes a custom, inline PowerShell task to create a "build information file" that will store metadata such as the app version, file version, built on time, ClickOnce Publish Version etc. This file was created because originally a target was being called from multiple locations to get the file version and the file version was based in part on the date/time which could change during the course of the build. Later it also was used to store and increment the ClickOnce revision number.

Later the build script will read in this file into an object, update various properties and write it back out.
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="CreateBuildInfoFile" 
	AssemblyFile="$(PowerShellAssembly)">
<Task>
  <![CDATA[
  $log.LogMessage([Microsoft.Build.Framework.MessageImportance]"High", "Creating build info file for later use")
  $obj = new-object PSObject
  $obj | add-member -membertype NoteProperty -name "AppVersion" -value "$(BUILD_NUMBER)"
  $obj | add-member -membertype NoteProperty -name "FileVersion" -value ([System.DateTime]::Now.ToString("yyyy.MM.dd.HHmm"))
  $obj | add-member -membertype NoteProperty -name "BuiltOn" -value ([System.DateTime]::Now.ToString("G"))
  $obj | add-member -membertype NoteProperty -name "ClickOnceRevision" -value 0
  $obj | add-member -membertype NoteProperty -name "ClickOncePublishVersion" -value ""
  $obj | export-csv $(BuildArtifacts)BuildInfo.csv -notypeinformation
  ]]>
</Task>
</UsingTask>
This task is part of the MSBuild Extension Pack and the $(PowerShellAssembly) value is defined as:
<PropertyGroup>
    <PowerShellAssembly>$(MSBuildExtensionsPath)\ExtensionPack\4.0\MSBuild.ExtensionPack.TaskFactory.PowerShell.dll</PowerShellAssembly>
</PropertyGroup>

The Build Number and Calling the Build Script

In the Initialization target you may have noticed $(BUILD_NUMBER) referenced in a couple of places. During a CI build, Team City was used and it sets this property which can be referenced in the build script. When building outside of a CI process, this property value will need to be set when msbuild is invoked.

I prefer to invoke MSBuild build scripts from PowerShell. In my PowerShell $profile I add the .net framework path to the PATH environment variable so I do not have to specify the location of MSBuild each time.
$env:path = $env:Path += ";C:\Windows\Microsoft.NET\Framework\v4.0.30319"
Usually I will invoke an "Open PowerShell here" action from the build script's folder in Windows Explorer and then execute the build script with something like:
msbuild MyApp.build /p:BUILD_NUMBER=3.3.0.0
 
Depending on what steps need to be performed or tested the invocation will vary a bit:
msbuild MyApp.build /t:Compile /p:BUILD_NUMBER=3.3.1.0 /p:Configuration=Release
msbuild MyApp.build /t:Clean

Creating License Files

Component vendors such as Infragistics require license.licx files for design time use of controls. We do not store these license files in source control for a variety of reasons but the build will generate exceptions if these files do not exist. This task copies a zero-length license file to the property directories to prevent exceptions during compilation.

This target first gets a reference to all AssemblyInfo files that reside in the same directories where the license files need to be. The actual directory name varies between Properties and My Project depending upon the language. The directory of each file is used as the destination to copy the empty license file to.

Initially I tried creating an empty license file using the File.WriteLines method of the MSBuild extension pack. That did not work though they since addressed it but I never tried the changes.
<Target Name="CreateLicenseFiles">
	<CreateItem Include="..\**\AssemblyInfo.*">
	  <Output TaskParameter="Include" ItemName="PropertyDirList" />
	</CreateItem>

	<Message Text="Checking/creating licenses.licx files"/>
	<Message Text="%(PropertyDirList.RootDir)%(PropertyDirList.Directory)" />

	<Copy
			SourceFiles="licenses.licx"
			DestinationFolder="%(PropertyDirList.RootDir)%(PropertyDirList.Directory)"
			SkipUnchangedFiles="true"
			ContinueOnError="true"
		/>
</Target>

NuGet Package Restore

Initially I thought that with NuGet Package Restore enabled in the app's Visual Studio solution that when the code was compiled by MSBuild that any missing NuGet packages would be automatically downloaded and installed. As it turned out it was not all rainbows and unicorns as that only worked in the context of Visual Studio. It was simple enough however to enumerate all packages.config files and run the NuGet command line to install the packages in each.
<PropertyGroup>
	<NuGetExe Condition="$(NuGetExe) == ''">..\.nuget\nuget.exe</NuGetExe>
    <NuGetPackagesFolder>..\packages\</NuGetPackagesFolder>
</PropertyGroup>

<Target Name="NuGetPackageRestore">
<ItemGroup>
  <NuGetPackageConfigs Include="..\**\packages.config" />
</ItemGroup>

<Message Text="Restoring NuGet package(s). Nuget path is $(NuGetExe)" />
<Exec Command="$(NuGetExe) install %(NuGetPackageConfigs.Identity) -o $(NuGetPackagesFolder)" />
</Target>

ClickOnce Certificate

The required certificate for a ClickOnce-distributed Windows app has been a pain in the past. First we tend to forget what the password is after a while and end up trying several common variations. For another the cert expires after a year by default so we end up generating another one. Then on the build server the build would break and someone had to logon as the build account, manually import the new certificate, enter the password, fire off MSBuild and verify the fix. I wanted to get away from all that as much as possible by having the build script install the certificate.

To install the certificate the password used to create it will be needed so a property is defined for that. It may not be required but for good measure the script first removes the certificate if it exists before installing it. On a build server this might be desirable once the app has finished compiling.

To remove any existing certificate it needs to be uniquely identified which is done through its thumbprint. On a machine where the cert is already installed the cert can be found through browsing IE=>Tools=>Internet Options=>Content=>Certificates=>Personal; see http://msdn.microsoft.com/en-us/library/ms734695.aspx for more details. Issued to/by and Issue Date (Not Before) are the key fields to look at.

Another way to search for the cert is with PowerShell. For example if you know the thumbprint starts with E595 you could run:
cd cert:
dir -recurse | where {$_.Thumbprint -like '*e595*'} | Format-List -property *
 
Once the right cert is identified the thumbprint can be copied into a property in the build script. I do not recommend copying from IE however as you may pick up special unicode characters that are hard to see and will prevent a match in finding the cert. Also all spaces would need to be removed and letters would need to be uppercased. It is easier to copy the thumbprint data from PowerShell where the format is already correct.
<PropertyGroup Label="ClickOnceProps">
	<ClickOnceCertThumb>E5958D48FE10D7340311E8C03BB22940DABA2DA3</ClickOnceCertThumb>
	<ClickOnceCertFile>..\MyApp.Client\MyApp.Client_TemporaryKey.pfx</ClickOnceCertFile>
	<ClickOnceCertPass>S0mePassw0rd!</ClickOnceCertPass>
</PropertyGroup>
The Certificate task in the MSBuild Extension Pack is used to remove a certificate via supplying the thumbprint:
<Target Name="RemoveClickOnceCert">
	<!-- Continue on error in case it doesn't exist, will just be a warning -->
	<Message Text="Removing any existing ClickOnce certificate"/>
	<MSBuild.ExtensionPack.Security.Certificate TaskAction="Remove" 
		Thumbprint="$(ClickOnceCertThumb)" ContinueOnError="true"/>
</Target>
Likewise the Certificate task is used to install the certificate via supplying the filename and password. The thumbprint and subject properties are output for verification purposes.
<Target Name="InstallClickOnceCert" DependsOnTargets="RemoveClickOnceCert">
	<Message Text="Installing Certificate file @(ClickOnceCertFile)"/>
	<MSBuild.ExtensionPack.Security.Certificate TaskAction="Add" 
	FileName="$(ClickOnceCertFile)" CertPassword="$(ClickOnceCertPass)">
	  <Output TaskParameter="Thumbprint" PropertyName="TPrint"/>
	  <Output TaskParameter="SubjectDName" PropertyName="SName"/>
	</MSBuild.ExtensionPack.Security.Certificate>
</Target>
Instead of using the Certificate task in the MSBuild extension pack, using PowerShell directly to install the certificate is another option. For example, see this script on poshcode or this post by James Kehr.

Updating AssemblyInfo

Prior to starting the compilation various info in the AssemblyInfo files should be set including:
  • Assembly Version
  • File Version
  • Copyright
  • Company Name
There are a few different ways to do this including TeamCity's AssemblyInfo Patcher, the AssemblyInfo task of the MSBuild Community Tasks, MSBuild Extension Pack's AssemblyInfo class, regular expressions and replacements with PowerShell or MSBuild tasks, etc. For me the patcher was out since it was CI-only and the other tasks had usage requirements I did not like. I chose the regular expression route though RegEx solutions do tend to be brittle.

First the AssemblyInfo target requires that BUILD_NUMBER is set, otherwise it is skipped. Next a couple of RegEx patterns are defined. There are two for AssemblyFileVersion as I found some formatted as AssemblyFileVersion( "0.0.0.0" ) and the rest as AssemblyFileVersion("0.0.0.0") due to differing developer formatting settings (again, this approach is more brittle). It probably would have been trivial to combine the two regular expressions into one but my RegEx skills are rusty and I was feeling lazy at the time.
<Target Name="UpdateAssemblyInfo" Condition="'$(BUILD_NUMBER)' != ''">
    <ItemGroup>
      <AssemblyInfoFiles Include="../**/Properties/**/AssemblyInfo.cs;" />
      <AssemblyInfoFiles Include="../**/My Project/**/AssemblyInfo.vb;" />
      <ClientAssemblyInfo Include="../MyApp.Client/My Project/AssemblyInfo.vb"/>
    </ItemGroup>

    <PropertyGroup>
      <AssemblyVersionPattern>AssemblyVersion\(".*"\)</AssemblyVersionPattern>
      <!-- 2nd ver handles spaces for those that have formatting like AssemblyFileVersion(  ". Should combine regexs -->
      <AssemblyFileVersionPattern>AssemblyFileVersion\(".*"\)</AssemblyFileVersionPattern>
      <AssemblyFileVersionPattern2>AssemblyFileVersion\( +"\d+\.\d+\.\d+\.\d+" \)</AssemblyFileVersionPattern2>
    </PropertyGroup>

	<!-- ... --->
</Target>
Next a target is called to get the file version:
<GetFileVersion>
	<Output TaskParameter="ReturnValue" PropertyName="FileVersion"/>
</GetFileVersion>
Which is retrieved elsewhere using the BuildInfo file written to earlier:
<UsingTask TaskFactory="PowershellTaskFactory" TaskName="GetFileVersion" AssemblyFile="$(PowerShellAssembly)">
    <ParameterGroup>
      <ReturnValue Output="true"/>
    </ParameterGroup>
    <Task>
      <![CDATA[
            $obj = import-csv $(BuildArtifacts)BuildInfo.csv
            $returnValue = $obj.FileVersion
      ]]>
    </Task>
</UsingTask>
Next the current year is retrieved into a property which will be used momentarily to update the Assembly copyright so it has the current year.
<MSBuild.ExtensionPack.Framework.DateAndTime TaskAction="Get" Format="yyyy">
      <Output TaskParameter="Result" PropertyName="Year"/>
</MSBuild.ExtensionPack.Framework.DateAndTime>
Next the same file version is stamped on all assemblies and the assembly version is only set for the entry client assembly. For a refresher on differences between assembly versions vs. file versions see a post such as this.
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="$(AssemblyFileVersionPattern)"
	Replacement="AssemblyFileVersion("$(FileVersion)")"
	Files="@(AssemblyInfoFiles)" />
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="$(AssemblyFileVersionPattern2)"
	Replacement="AssemblyFileVersion("$(FileVersion)")"
	Files="@(AssemblyInfoFiles)"/>

<!-- special handling for client app project -->
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="$(AssemblyVersionPattern)"
	Replacement="AssemblyVersion("$(BUILD_NUMBER)")"
	Files="@(ClientAssemblyInfo)"/>
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="$(AssemblyFileVersionPattern)"
	Replacement="AssemblyFileVersion("$(FileVersion)")"
	Files="@(ClientAssemblyInfo)"/>										   
Finally "while we're in there" company name and copyright are set:
	
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="AssemblyCopyright\(".*"\)"
	Replacement="AssemblyCopyright("Copyright © Initech $(Year)")"
	Files="@(AssemblyInfoFiles)"/>
<MSBuild.ExtensionPack.FileSystem.File TaskAction="Replace" 
	RegexPattern="AssemblyCompany\(".*"\)"
	Replacement="AssemblyCompany("Initech")"
	Files="@(AssemblyInfoFiles)"/>	

What's next?

That's it for the pre-build activities. I may follow this up with posts on build and packaging, deployment scripts, and Team City setup and configuration.
Saturday
Apr212012

ASP.NET MVC 4 NUnit template

We are using ASP.NET MVC 4 beta on a new project as it should be out of beta before we go live. Even if not, they do have a Go Live license option. Each time I start using a new version of ASP.NET MVC and check the "Create a unit test project" checkbox, I always let out a groan when I remember that MSTest is my only initial option. The combobox might as well be empty as far as I'm concerned.

There are some Visual Studio extensions and custom templates that others have created to address this for past versions of ASP.NET MVC. I did not immediately find anything for ASP.NET MVC 4 beta in my searching so I decided to take a stab at creating a template for NUnit. I considered doing one for xUnit.net as well but I've gone back to NUnit mostly. I like xUnit better in some respects but the out of the box tooling support is better for NUnit and over the years NUnit seems to have addressed some of the shortcomings that xUnit was addressing to begin with.

I ended up basing my solution on this old ASP.NET MVC 1 NUnit template. There were a number of changes required to the various files including the VS templates, registry files, project files, installation batch file etc. I will not go into those gory details as I have neither the time or inclination and you probably do not care anyway :).

So here it is if it is useful to anyone else:
NUnit ASP.NET MVC4.zip





Details and Disclaimers

  • NuGet - Rather than reference NUnit directly I bundled the latest NUnit NuGet package with the templates (NUnit.2.6.0.12054.nupkg). You might want to check for updates to that.
  • Visual Studio and OS Versions - I adjusted all the reg files and the install script to attempt to handle the different Visual Studio versions and 64 vs 32 bit differences. However I only tested this with Visual Studio 2010 Premium on Windows 7 x64.
  • .NET Framework Version - I tested this against .NET FX 4. I have not tried against 4.5 beta yet.
  • Languages - I'm a C# guy but for completeness sake I converted the VB template too but did not test it as much. It compiled anyway :).
  • Beta bits - I would guess the templates would work when ASP.NET MVC 4 RTM's but who knows.
  • Sample test class(es) - I did not spend much energy adjusting the sample NUnit test class that is created by default (HomeControllerTests) and I removed AccountControllerTests for various reasons. Usually I end up deleting or drastically changing these samples anyway.