I’ve done a few WPF projects now and have gained some good experiences with using ClickOnce as a deployment technology. ClickOnce works very well when you’re packaging and deploying through Visual Studio, but it gets very counterintuitive when trying to produce real software products. My main problem with it is that Visual Studio only builds. It’s good at building. You write code and build it. However, real software products that might be built with Visual Studio, are packaged. Packaged applications have help files and other files. Plus, modern applications are modular, meaning that modules are not necessarily statically linked with a product and need to be added to the software package during the build process. So ClickOnce/Visual Studio tend to fail when dealing with packaging software products, which is what I try to do.
To get around this problem, I took a deep look into how Visual Studio published ClickOnce applications, and stole some code from another Microsoft tool that will allow me to package software products using MSBuild and support ClickOnce delivery of my software products as well, all while only requiring a since custom MSBuild task.
To illustrate how I build software products using MSBuild, I’m going to show demonstrations of the build script for my newest product idea, ImaginaryFinance, which I’ll be covering in much more detail in future posts over the next few weeks. When I start software projects, one of the first things that I do is set up the automated build system for the product. As a developer, I want to believe that my ultimate job is to produce software, and you can’t produce software if you’re not able to build the software, so I like to do frequent builds to prove that the software works and can be shown off. To accomplish this goal, I start off and create a complete build system based around Microsoft’s MSBuild tool that comes with the .NET Framework Software Development Kit. This build script can either be run manually from the command line, or can be hooked into a continuous integration tool such as CruiseControl.NET. Note that I don’t use Microsoft Team Build on software development projects. I’m just not in my happy place when it’s playing in my sandbox, but that’s another story.
Since I use Visual Studio to build my software projects, I usually will create a new solution for every project. I then create my build script’s MSBuild file in the same directory as my solution, to make everything work nicely. I don’t want to spend a lot of time configuring the build, especially if I’m just doing a trial build to make sure that everything builds right. I just want to build the software to make sure that it compiles and packages correctly, so I start off by creating my main build target: Build.
ImaginaryFinance
$(ProjectName).sln
$(MSBuildProjectDirectory)\..\Builds
(path to Debugging Tools for Windows)
Debug
Build
Release
Build
GenerateVersionNumbers;
BuildAllConfigurations
I use the MSBuild Community Tasks in my builds in a couple of areas that I’ll demonstrate shortly. First, they generate the version numbers for my builds. Second, they generate the build-specific AssemblyInfo.cs files for my assemblies. Third, the Zip task lets me create .zip archives for storage or redistribution.
The first ItemGroup in the above build script sample shows the BuildConfigurations items. These are used by the build tasks that I’ll show later to build both the Debug and Release configurations of the project. The Debug builds are used for quality assurance and testing, while the Release builds are what might go out to potential end-users or customers.
At the bottom of the sample is my Build target. It’s an empty target that is used to run the two main steps of the build: generating the version number for the build, and building the Debug and Release configurations for the project.
The GenerateVersionNumbers target is used to generate the build, application, and assembly version numbers for the build. I use the Version task that comes with the MSBuild Community Tasks to generate the version number. The version number is persisted in a text file in my project workspace and changes on each build. In .NET, a version number has four components:
- Major version number
- Minor version number
- Build number
- Revision number
The major and minor version numbers are static and are defined by me. In the above example, I’m building the 0.1 version series of the ImaginaryFinance application right now. The build number is the number of days that have passed since the project started. The project start date in this case is February 28, 2009, so my build number will change each day of the project. The revision number is an incremental number that indicates which build of the day is getting built. This revision number keeps increasing throughout the day, but will reset to zero for the first build of the next day.
Once I generate the version number, I actually use the version number components to create two version numbers. The first version number is the build version. The build version is the actual version number for the product being built and will be a full number such as 0.1.7.4 or 1.0.3.5. The assembly version number is the second version number, and basically indicates the release that the assembly belongs to. In this case, both the build number and revision number components of the assembly version will always be zero.
Why do I force the assembly version number components to zero? It’s to accommodate how version numbers are used in loading assemblies. When .NET assemblies are built, references to external components in other assemblies are recorded in the assembly with the dependencies. All .NET assemblies contain references that specify the names of assemblies that they reference, and what version numbers of those assemblies are referenced. At run time, the CLR loader will attempt to find the assembly with the same name and version number and will load it and link it with the rest of the code.
Now if I create a first version of an assembly, and give it a number such as 0.1.7.4, then if I find a bug in the assembly and fix it and release a new assembly with a version of 0.1.7.5, the CLR loader will still try to load the assembly with the 0.1.7.4 version number, because that’s what’s encoded in the dependent assembly. To get around this problem, I’d need to publish a publisher policy file or implement an assembly redirect in the App.config or Web.config files to get the new assemblies to load.
If I instead release both assemblies with a version number of 0.1.0.0, I still know which release the assemblies are for, and I can replace the older assembly with the newer assembly. I just have to ensure that compatibility is maintained between the two assemblies.
But if all of my assemblies have the same version number, how can I tell them apart? .NET assemblies actually have not one version number, but three possible version numbers, two of which are important. The version number that we have been discussing so far is the assembly version number. The assembly version number is best left to be the version number for the actual product release. The other version number is the file version number, which is represented in .NET by the AssemblyFileVersionAttribute class. The file version will be set to the build version number and will be reported when I view the assembly file in Windows Explorer. The assembly version number is used for linking, while the file version number is not used by .NET but is used by Windows to tell the difference between two files.
The third version number is called the informational version. I also set this to the build version number, but it’s not used by anyone.
Now that the version number’s been figured out, it’s time to build both the Debug and Release configurations:
This build target uses the item metadata of the BuildConfigurations item group that I defined above to first build the Debug configuration, and then build the Release configuration of the application. Using this syntax, the above MSBuild task will be executed twice. On the first run, the Configuration property will be set to Debug. On the second run, the Configuration property will be set to Release.
The actual steps for building a single configuration are defined below:
GenerateAssemblyInfo;
BuildSolution;
Test;
PublishPrivateSymbols;
PublishProgramFiles;
PublishClickOnce;
ZipInstaller
The steps for building each configuration are:
- Generate the AssemblyInfo.cs file with the build version and assembly version numbers.
- Use MSBuild to build the projects in the Visual Studio solution file.
- Run the unit tests or acceptance tests that are defined for the project.
- Publish the .pdb files for the assemblies to the private symbol store.
- Publish the .exe and .dll files to the private symbol store.
- Publish the ClickOnce deployment package.
- Create a .zip archive containing the Windows Installer package for users that don’t want to use ClickOnce.
I’ll discuss steps 4, 5, and 7 in later posts. The first step is to generate the AssemblyInfo.cs file:
$(OutputPath)\$(BuildVersion)\$(Configuration)
I lied. I’m not actually generating AssemblyInfo.cs. Instead, I typically keep that file static. Instead, I create a new file named VersionInfo.cs and I put the build-specific attributes into that file. In this case, I’m putting in the name of the build configuration for the AssemblyConfigurationAttribute attribute. I’m also setting the three version numbers that I described earlier in this post. Now that I have the attributes set, I can build the solution:
In each of my project files, I have added a link to a file named VersionInfo.cs which is in the root of my project workspace. I then manually went into each .csproj file and created a new property named VersionInfo. I then replaced the file reference in the .csproj file with $(VersionInfo). This allows me from the automated build to set a new value for the VersionInfo property to the build-generated version. During the build, the build-generated version supersedes the version that I use for development, and my assemblies get built with the correct version number.
Notice that I’m also passing in the ApplicationVersion property and setting it to the build version. Because my Visual Studio solution contains an .exe project intended for use with ClickOnce, the .NET compiler is going to generate the ClickOnce deployment manifest that contains the list of assemblies and their version numbers that will be deployed. By passing in the ApplicationVersion property, I can make sure that the build version number is used by .NET to generate the manifest file.
Once the application is built, it is now time to publish the ClickOnce package:
The .NET Framework defines a target that gets included in every .NET project: PublishOnly. Since I have already built the ClickOnce assemblies in an earlier task, I don’t need to build them again and instead can publish the files to the ClickOnce deployment area.
Once the application has been published to the ClickOnce area, I now need to add the other files that are missing so that they will also be deployed using ClickOnce. To accomplish this, I created the AddFilesToManifest MSBuild task that will add the additional files to the ClickOnce manifest and will re-sign the manifest using the software publisher certificate.
To create the MSBuild task, I took the source code for the Manifest Manager Utility available on CodePlex and rewrote the code for adding files to the package and signing the manifests. Here’s the code:
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Build.Framework;
using Microsoft.Build.Tasks.Deployment.ManifestUtilities;
using Microsoft.Build.Utilities;
///
/// Adds one or more files to a ClickOnce manifest and re-signs the
/// manifest.
///
public class AddFilesToManifest : Task
{
///
/// The object for the application.
///
private ApplicationManifest applicationManifest;
///
/// The path to the ClickOnce package.
///
private string applicationPath;
///
/// The object for the application.
///
private DeployManifest deploymentManifest;
///
/// Gets or sets the manifest file to modify.
///
///
/// The value of this property is the path to the application
/// manifest to be updated.
///
[Required]
public string ApplicationManifestFile
{
get;
set;
}
///
/// Gets or sets the path to the certificate to use to sign the
/// ClickOnce manifest.
///
///
/// The value of this property is the path to the X.509 certificate
/// that is used to sign the manifests.
///
[Required]
public string CertificatePath
{
get;
set;
}
///
/// Gets or sets the password for the certificate used to sign the
/// ClickOnce manifest.
///
///
/// The value of this property is the password for the X.509
/// certificate.
///
public string CertificatePassword
{
get;
set;
}
///
/// Gets or sets the list of files to add to the manifest.
///
///
/// The value of this property is an array of
/// objects containing the list of files to add to the manifest and
/// their relative location in metadata.
///
[Required]
public ITaskItem[] FilesToAdd
{
get;
set;
}
///
/// Gets or sets the URL of the secure timestamp server.
///
///
/// The value of this property is the URL of the secure timestamp
/// server.
///
[SuppressMessage(
"Microsoft.Design",
"CA1056:UriPropertiesShouldNotBeStrings",
Justification = "This property needs to be a string for MSBuild.")]
public string TimestampUrl
{
get;
set;
}
///
/// Adds the files to the manifest and re-signs the manifest.
///
///
/// Returns true if the files were added to the manifest and the
/// manifest was signed, or false if an error occurred.
///
public override bool Execute()
{
try
{
this.LoadDeploymentManifest();
this.LoadAssemblyManifest();
this.AddFilesToApplicationManifest();
this.SignManifests();
}
catch (ArgumentException e)
{
this.Log.LogErrorFromException(e);
return false;
}
catch (FileNotFoundException e)
{
this.Log.LogErrorFromException(e);
return false;
}
return true;
}
///
/// Adds the additional files to the application manifest.
///
private void AddFilesToApplicationManifest()
{
foreach (var file in this.FilesToAdd)
{
var targetFolder = file.GetMetadata("TargetFolder") ??
String.Empty;
var targetPath =
Path.Combine(this.applicationPath, targetFolder);
if (!Directory.Exists(targetPath))
{
Directory.CreateDirectory(targetPath);
}
var destinationFilename = Path.Combine(
targetPath,
Path.GetFileName(file.ItemSpec));
File.Copy(file.ItemSpec, destinationFilename, true);
var identity = AssemblyIdentity.FromFile(destinationFilename);
BaseReference fileReference;
if (null != identity)
{
fileReference =
this.applicationManifest.AssemblyReferences.Add(
destinationFilename);
}
else
{
fileReference =
this.applicationManifest.FileReferences.Add(
destinationFilename);
((FileReference) fileReference).IsDataFile = true;
}
if (!String.IsNullOrEmpty(targetFolder))
{
fileReference.TargetPath = Path.Combine(
targetFolder,
Path.GetFileName(destinationFilename));
}
var group = file.GetMetadata("Group");
if (!String.IsNullOrEmpty(group))
{
fileReference.Group = group;
}
var optional = file.GetMetadata("IsOptional");
if (!String.IsNullOrEmpty(optional) && "true" == optional)
{
fileReference.IsOptional = true;
}
}
}
///
/// Loads the application manifest.
///
///
/// The application manifest file was not found.
///
private void LoadAssemblyManifest()
{
var assemblyManifestPath =
this.deploymentManifest.EntryPoint.TargetPath;
assemblyManifestPath = Path.Combine(
Path.GetDirectoryName(this.deploymentManifest.SourcePath),
assemblyManifestPath);
this.applicationPath = Path.GetDirectoryName(assemblyManifestPath);
var manifest =
ManifestReader.ReadManifest(assemblyManifestPath, false);
this.applicationManifest = manifest as ApplicationManifest;
if (null == this.applicationManifest)
{
throw new FileNotFoundException(
"Unable to open referenced application manifest",
assemblyManifestPath);
}
}
///
/// Loads the deployment manifest for the application.
///
///
/// The deployment manifest is not a valid deployment manifest.
///
private void LoadDeploymentManifest()
{
var manifest = ManifestReader.ReadManifest(
this.ApplicationManifestFile,
false);
this.deploymentManifest = manifest as DeployManifest;
if (null == this.deploymentManifest)
{
throw new ArgumentException("Not a valid deployment manifest");
}
}
///
/// Signs the application and deployment manifest using the specified
/// certificate.
///
///
/// The certificate was not found.
///
private void SignManifests()
{
if (String.IsNullOrEmpty(this.CertificatePath))
{
return;
}
if (!File.Exists(this.CertificatePath))
{
throw new FileNotFoundException(
"Invalid certificate file path",
this.CertificatePath);
}
var certificate = String.IsNullOrEmpty(this.CertificatePassword)
? new X509Certificate2(this.CertificatePath)
: new X509Certificate2(
this.CertificatePath,
this.CertificatePassword);
var timestampUri = String.IsNullOrEmpty(this.TimestampUrl)
? null
: new Uri(this.TimestampUrl);
this.applicationManifest.ResolveFiles();
this.applicationManifest.UpdateFileInfo();
ManifestWriter.WriteManifest(this.applicationManifest);
SecurityUtilities.SignFile(
certificate,
timestampUri,
this.applicationManifest.SourcePath);
ManifestWriter.WriteManifest(this.deploymentManifest);
SecurityUtilities.SignFile(
certificate,
timestampUri,
this.deploymentManifest.SourcePath);
}
}
Using this task, I am now able to completely automate the ClickOnce packaging and deployment process.