Writing lots of lovely code is nice but at some point the application your working on is going to need to be deployed somewhere. If you're in a large organisation chances are the final build of your application will need to go through a few stages as it strolls confidently towards production. This means your need a process to deploy the same application to multiple environments e.g. DEV, SIT, UAT, PROD but you don't want to have to compile and build and package the application at every stage.
This tutorial walks through the creation of a generic msbuild script that can be used to take a set of .NET assemblies and create a full Clickonce deployment for those assemblies merging in the appropriate environment settings along the way. Coupled with a good automated build process this provides a config driven mechanism for deploying any set of .NET binaries to any environment within your organisation.
Structure
I want to have a folder structure containing the necessary configuration files for each environment. The name of the folder will be key to identifying that environment and the folder itself will contain the app.config file the environment aswell as an msbuild target file containing the environment specific properties needed by the packaging process.
Example Folder Structure:
\scripts\package.proj
\envs\dev\app.config
\envs\dev\properties.target
\envs\sit\app.config
\envs\sit\\properties.target
Package Script
So now to the script itself (package.proj). The script will need to take in two arguments: the path to the binaries we want to package and what environment we want to deploy to. You don't need to add in a PropertyGroup for the properties we're going to pass in but I like adding this at the top of the script as it makes it easier to keep track of what you've named them.
Someone calling the script will pass in the Environment property to the script so we want to use this to work our way back to the properties file and app.config file for that environment. The properties.target file for each environment will look like this:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ApplicationName>MyApp</ApplicationName>
<MainExecutableName>MyApp.MyRegion</MainExecutableName>
<TeamName>MyDivision.MyTeam</TeamName>
<MagePath>"C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin\NETFX 4.0 Tools\mage.exe"</MagePath>
<Certificate>C:\temp\MyApp_TemporaryKey.pfx</Certificate>
<ApplicationName>MyApp</ApplicationName>
<DeployToUrl>http://MyWebServer/MyAppFolder/</DeployToUrl>
<SupportUrl>http://MyWiki/MyApp/support.htm</SupportUrl>
<Description>MyApp Dev Deployment</Description>
<PackageVersion>1.0.0.1</PackageVersion>
</PropertyGroup>
</Project>
If this is the config for our DEV package then we'll use these settings to create a clickonce package for the development environment using the version number, deployment URL, Name etc. as set in the file. We add the following line to the package.proj script to import in the properties for the specified environment:
<Import Project="..\envs\$(Environment)\properties.target" />
The key part of the packaging is identifying the assemblies and other files that are to be included as part of the build so that they can be listed in the application manifest we will be generating. We use the ItemGroup element to get the lists of assemblies and other files that we want to include:
<ItemGroup>
<EntryPoint Include="$(PathToBinaries)\$(MainExecutableName).exe" />
<Dependency Include="$(PathToBinaries)\*.dll">
<AssemblyType>Managed</AssemblyType>
<DependencyType>Install</DependencyType>
</Dependency>
<IconFile Include="$(PathToBinaries)\$(ApplicationName).ico"/>
<ConfigFile Include="$(PathToBinaries)\$(MainExecutableName).exe.config">
<TargetPath>$(MainExecutableName).exe.config</TargetPath>
</ConfigFile>
</ItemGroup>
Although the name of the executable file will probably be the same for all environments the version number of the main executable will not so rather than add this to the properties file and have to update it all the time, I've written an msbuild task using inline C# to pull out the version number from the executable file and use this in the build process:
<UsingTask
TaskName="GetAssemblyVersion"
TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll" >
<ParameterGroup>
<FilePath ParameterType="System.String" Required="true" />
<VersionNumber ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System.Reflection"/>
<Code Type="Fragment" Language="cs">
<![CDATA[
this.VersionNumber = AssemblyName.GetAssemblyName(this.FilePath).Version.ToString();
]]>
</Code>
</Task>
</UsingTask>
This task will dig out the version number of the assembly and then return the version as a string in an output parameter. To use this in the script we call the task as follows:
<GetAssemblyVersion FilePath="$(PathToBinaries)\$(MainExecutableName).exe">
<Output PropertyName="AssemblyVersion" TaskParameter="VersionNumber"/>
</GetAssemblyVersion>
This gives us a property that we can then use later in the script. Next we want to copy across the app.config for our package again using the environment parameter to determine the path of the app.config file to use.
<Copy SourceFiles="..\envs\$(Environment)\app.config" DestinationFiles="$(PathToBinaries)\$(MainExecutableName).exe.config" />
And now to the meat of the script. Generating the manifests. The GenerateApplicationManifest task is used, funnily enough, to generate the application manifest. The properties and parameters we talked about earlier are used to fill out the attributes of this task. The AssemblyName is really the name of the package rather than the actual name of the main executable and making sure this is unique for each environment is important in ensuring the different packages and can installed and run on the one machine. If the application identity is the same for different packages you'll get conflicts when you try to run them on the same PC.
<GenerateApplicationManifest
AssemblyName="$(ApplicationName).$(Environment)"
AssemblyVersion="$(AssemblyVersion)"
ConfigFile="@(ConfigFile)"
Dependencies="@(Dependency)"
Description="$(Description)"
EntryPoint="@(EntryPoint)"
IconFile="@(IconFile)"
InputManifest="@(BaseManifest)"
OutputManifest="$(PathToBinaries)\$(MainExecutableName).exe.manifest">
<Output
ItemName="ApplicationManifest"
TaskParameter="OutputManifest"/>
</GenerateApplicationManifest>
Mage is then used to sign this manifest using a signing certificate. This gives us some security that once a package is created you cannot change the contents of the package without corrupting it and rendering the package invalid unless you sign it again.
<Exec Command="$(MagePath) -Sign @(ApplicationManifest) -cf $(Certificate)"/>
A deployment manifest is created to facilitate the clickonce install and again, just like the application manifest, the assembly name should be unique across deployment manifests on different environments to ensure there's no conflict at install time.
<GenerateDeploymentManifest
AssemblyName="$(ApplicationName).$(Environment)"
AssemblyVersion="$(PackageVersion)"
DeploymentUrl="$(DeployToUrl)$(MainExecutableName).application"
Description="$(Description)"
EntryPoint="@(ApplicationManifest)"
Install="true"
TargetFrameworkMoniker=".NETFramework,Version=v4.0"
OutputManifest="$(PathToBinaries)\$(MainExecutableName).application"
Product="$(ApplicationName).$(Environment)"
Publisher="$(TeamName)"
SupportUrl="$(SupportUrl)"
UpdateEnabled="true"
UpdateMode="Foreground">
<Output
ItemName="DeployManifest"
TaskParameter="OutputManifest"/>
</GenerateDeploymentManifest>
One the deployment manifest is created we add a few tweaks to ensure it forces the install of the latest package using the minimum version switch (-mv) and we set what the start menu for the application will be using the -Publisher switch.
<Exec Command="$(MagePath) -u @(DeployManifest) -Publisher $(TeamName).$(Environment) -v $(PackageVersion) -mv $(PackageVersion)" />
We then sign the deployment manifest and that is the package complete.
<Exec Command="$(MagePath) -Sign @(DeployManifest) -cf $(Certificate)"/>
The package is tightly-coupled to the deployment URL specified in the property file so you will only be able to install it from that location. The final step then is to transfer the contents of the PathToBinaries folder to the location you specified in the DeployToUrl property. The clickonce package is then installed by accessing the deployment manifest from this location in your IE webbrowser (There is a firefox plugin that allows the clickonce apps to work for that browser too). For the example above you would install the application from here:
http://MyWebServer/MyAppFolder/MyApp.MyRegion.application
Full Script:
<Project ToolsVersion="4.0" DefaultTargets="Package" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!--
How to call it: msbuild PackageBinaries.proj /:pEnvironment=dev;PathToBinaries=C:\temp
-->
<!-- These are the command line parameters required to run the package -->
<!-- This import statement brings in the environment specific properties required for the packaging -->
<Import Project="..\envs\$(Environment)\properties.target" />
<!--
This ItemGroup element builds up the sets of files that are listed in the application manifest
for the deployment. Note that an iconfile is expected in the PathtoBinaries location called
ApplicationName.ico where ApplicationName is a property in the property.target file.
-->
<ItemGroup>
<EntryPoint Include="$(PathToBinaries)\$(MainExecutableName).exe" />
<Dependency Include="$(PathToBinaries)\*.dll">
<AssemblyType>Managed</AssemblyType>
<DependencyType>Install</DependencyType>
</Dependency>
<IconFile Include="$(PathToBinaries)\$(ApplicationName).ico"/>
<ConfigFile Include="$(PathToBinaries)\$(MainExecutableName).exe.config">
<TargetPath>$(MainExecutableName).exe.config</TargetPath>
</ConfigFile>
</ItemGroup>
<Target Name="Package">
<Message Text="******************************************" />
<Message Text="** Target Environment:$(Environment)" />
<Message Text="** Binaries Location: $(PathToBinaries)" />
<Message Text="******************************************" />
<!-- Get the version of the Applications executable -->
<GetAssemblyVersion FilePath="$(PathToBinaries)\$(MainExecutableName).exe">
<Output PropertyName="AssemblyVersion" TaskParameter="VersionNumber"/>
</GetAssemblyVersion>
<Message Text="$(AssemblyVersion)" />
<!-- Copy across the app.config file for this environment -->
<Message Text="Getting App.Config File: ..\envs\$(Environment)\app.config" />
<Copy SourceFiles="..\envs\$(Environment)\app.config" DestinationFiles="$(PathToBinaries)\$(MainExecutableName).exe.config" />
<!--
An application manifest is now generated called ApplicationName.Environment
e.g. MyApp.Dev. It's important to have a unique application identity for each
environment so that you can run dev/sit/uat and prod versions of the same app
on the same machine without conflicts.
-->
<Message Text="Creating Application Manifest: $(PathToBinaries)\$(MainExecutableName).exe.manifest" />
<GenerateApplicationManifest
AssemblyName="$(ApplicationName).$(Environment)"
AssemblyVersion="$(AssemblyVersion)"
ConfigFile="@(ConfigFile)"
Dependencies="@(Dependency)"
Description="$(Description)"
EntryPoint="@(EntryPoint)"
IconFile="@(IconFile)"
InputManifest="@(BaseManifest)"
OutputManifest="$(PathToBinaries)\$(MainExecutableName).exe.manifest">
<Output
ItemName="ApplicationManifest"
TaskParameter="OutputManifest"/>
</GenerateApplicationManifest>
<Message Text="Signing Application Manifest:@(ApplicationManifest)" />
<!--
The application manifest is signed at this point with the certificate specified
in the imported properties file
-->
<Exec Command="$(MagePath) -Sign @(ApplicationManifest) -cf $(Certificate)"/>
<!--
A Deployment manifest is now generated called ApplicationName.Environment
e.g. MyApp.Dev. Again it's important to have a unique application identity for each
environment so that you can run dev/sit/uat and prod versions of the same app
on the same machine without conflicts.
-->
<Message Text="Creating Deployment Manifest: $(PathToBinaries)\$(MainExecutableName).application" />
<GenerateDeploymentManifest
AssemblyName="$(ApplicationName).$(Environment)"
AssemblyVersion="$(PackageVersion)"
DeploymentUrl="$(DeployToUrl)$(MainExecutableName).application"
Description="$(Description)"
EntryPoint="@(ApplicationManifest)"
Install="true"
TargetFrameworkMoniker=".NETFramework,Version=v4.0"
OutputManifest="$(PathToBinaries)\$(MainExecutableName).application"
Product="$(ApplicationName).$(Environment)"
Publisher="$(TeamName)"
SupportUrl="$(SupportUrl)"
UpdateEnabled="true"
UpdateMode="Foreground">
<Output
ItemName="DeployManifest"
TaskParameter="OutputManifest"/>
</GenerateDeploymentManifest>
<Message Text="Setting Package Version: $(PackageVersion)" />
<!--
We add versions to the package. Adding a matching minimum version forces
the users machine to take the update whenever we build a new package.
Touching the deployment manifest resets the Publisher property of the
deployment manifest so we need to add this again so that the start menu
folder the app is installed under is something meaningful.
-->
<Exec Command="$(MagePath) -u @(DeployManifest) -Publisher $(TeamName).$(Environment) -v $(PackageVersion) -mv $(PackageVersion)" />
<Message Text="Signing Deployment Manifest:@(DeployManifest)" />
<Exec Command="$(MagePath) -Sign @(DeployManifest) -cf $(Certificate)"/>
</Target>
<!--
The task below uses inline C# to get the Assembly version of the executable file.
-->
<UsingTask
TaskName="GetAssemblyVersion"
TaskFactory="CodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll" >
<ParameterGroup>
<FilePath ParameterType="System.String" Required="true" />
<VersionNumber ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System.Reflection"/>
<Code Type="Fragment" Language="cs">
<![CDATA[
this.VersionNumber = AssemblyName.GetAssemblyName(this.FilePath).Version.ToString();
]]>
</Code>
</Task>
</UsingTask>
</Project>