Using TeamCity to Publish ClickOnce Packages to a Network Share

Recently at my job I had to figure out how to publish a Windows app with ClickOnce deployments from a centralized build server running JetBrains TeamCity. This might be kind of an esoteric use for TeamCity, but I wanted to get my process down in my blog to perhaps save some future generations some time and Google searching.

Here's a list of ingredients:

  1. A fresh TeamCity server running on Windows.
  2. Visual Studio 2010 installed on the build server (with the MSBuild 4.0 bits).
  3. The MSBuild Extension pack.
  4. The MSBuild Community Tasks.

I'm going to assume you already know how to set up your project in TeamCity and check out the sources from the version control software of your choosing.

The first build step I have is a little orthogonal to the ultimate deployment process, but I will include it because ultimately I wanted the Windows app and the ClickOnce deployment to all share the same version number that can be tied back to the TeamCity build. One thing to keep in mind is to call MSBuild in TeamCity with command line parameters of: /p:Configuration=Debug (if you are doing a debug build). This way I can have the compilation configuration type stamped in the binary's metadata.
Here is a sample MSBuild file called SetVersion.proj that I use:

<Project DefaultTargets="SetVersion" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Version>$(BUILD_NUMBER)</Version>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\ExtensionPack\4.0\MSBuild.ExtensionPack.tasks"/>
<Target Name="SetVersion">
<ItemGroup>
<AssemblyInfoFilesFirst Include="$(MSBuildProjectDirectory)\FIRST_PROJECT\Properties\AssemblyInfo.cs"/>
<AssemblyInfoFilesSecond Include="$(MSBuildProjectDirectory)\SECOND_PROJECT\Properties\AssemblyInfo.cs"/>
</ItemGroup>
<AssemblyInfo AssemblyInfoFiles="@(AssemblyInfoFilesFirst)"
AssemblyVersion="$(Version)"
AssemblyFileVersion="$(Version)"
AssemblyConfiguration="$(Configuration)"
AssemblyCopyright="© 2011 YOUR COMPANY. All rights reserved."
AssemblyProduct="First Project Name - $(Configuration)"/>
<AssemblyInfo AssemblyInfoFiles="@(AssemblyInfoFilesSecond)"
AssemblyVersion="$(Version)"
AssemblyFileVersion="$(Version)"
AssemblyConfiguration="$(Configuration)"
AssemblyCopyright="© 2011 YOUR COMPANY. All rights reserved."
AssemblyProduct="Second Project - $(Configuration)"/>
</Target>
</Project>

Note the use of the Extension Pack's AssemblyInfo task which enabled me to set values in the AssemblyInfo.cs files prior to compilation.

Next, I needed to clean and build the sources and call the Publish target in the Winforms project. To do this, I used another MSBuild build step that targets a file called Publish.proj, listed below:

<Project DefaultTargets="DoPublish" ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\MSBuildCommunityTasks\MSBuild.Community.Tasks.Targets"/>
<PropertyGroup>
<Version>$(BUILD_NUMBER)</Version>
<ClickOnceBuildDirectory>$(MSBuildProjectDirectory)\PROJECT1\bin\$(Configuration)\app.publish</ClickOnceBuildDirectory>
<ClickOnceInstallDirectory>$(MSBuildProjectDirectory)\Publish</ClickOnceInstallDirectory>
<ClickOnceHtmFileLocation>$(MSBuildProjectDirectory)\Build\publish.htm</ClickOnceHtmFileLocation>
<ClickOnceFinalLocation>$(env_PublishUrl)</ClickOnceFinalLocation>
</PropertyGroup>
<Target Name="DoPublish">
<RemoveDir Directories="$(ClickOnceInstallDirectory)" ContinueOnError="true" />
<MSBuild Projects="MY SOLUTION FILE.sln" Targets="Clean;Build" Properties="ApplicationVersion=$(Version);Configuration=$(Configuration)"/>
<MSBuild Projects="PROJECT1\CLICKONCE PROJECT.csproj" Targets="Publish" Properties="ApplicationVersion=$(Version);Configuration=$(Configuration);InstallUrl=$(ClickOnceFinalLocation);PublishUrl=$(ClickOnceFinalLocation)" />
<MakeDir Directories="$(ClickOnceInstallDirectory)"/>
<Copy SourceFiles="$(ClickOnceHtmFileLocation)" DestinationFiles="$(ClickOnceInstallDirectory)\publish.htm"/>
<Exec Command="xcopy /E $(ClickOnceBuildDirectory) $(ClickOnceInstallDirectory)" />
<FileUpdate Files="$(ClickOnceInstallDirectory)\publish.htm"
IgnoreCase="true"
Multiline="true"
Singleline="false"
Regex="{VERSION}"
ReplacementText="$(Version)" />
</Target>
</Project>

In order to get this to work, I needed to save a copy of the publish.htm file because this is not generated as part of the Publish target. In fact, it can only be generated when clicking Publish within the Visual Studio IDE. This is not acceptable for an automated build: bad Microsoft! No cookie for you! During the build, I needed to edit the publish.htm file so that I could tamper with it's version number. I stored a copy of this file in my source tree in a Build folder. Note also the $(env_PublishUrl) variable. This is coming from the build properties in TeamCity. The value is the full UNC location to the ultimate resting place of the ClickOnce deployment. MSBuild needs this because when it generates the ClickOnce manifest, it uses the fully qualified location (FTP/HTTP or file) so that all clients can refer to that location for future for updates. Basically, MSBuild file does the following actions in order:

  1. Remove the place where it is going to assemble the ClickOnce package (in case an prior artifacts exist).
  2. Do a Clean and Build on the entire solution to compile the the source code into binaries.
  3. Call the Publish target within the WinForms project and set the InstallUrl and PublishUrl locations to be the final package location.
  4. Create the local folder where it's going to assemble the package.
  5. Copy the publish.htm file from the source archive to the package location.
  6. Use xcopy to copy the output of the Publish target that ends up in the bin\Debug\app.publish folder.
  7. Use the FileUpdate task to put our version number in the right spots in the publish.htm file.

BIG HAIRY CAVEAT: To make this work, I had to remove the following PropertyGroup properties from the WinForm's .csproj file:

  • MinimumRequiredVersion
  • PublishUrl
  • ApplicationRevision
  • ApplicationVersion

Finally, I needed to move the resultant build package out onto a network share for consumption by clients. My build machine was not a member of the AD domain, so I had to manually map and delete network drives. Fortunately, the Command Line build step's Custom Script mode handled this wonderfully. Think of it as a way to write a batch file on the fly using environment variables set up during the build.

NET USE P: "%env.PublishShare%" /USER:domain\username **password**
ROBOCOPY "%system.teamcity.build.workingDir%\Publish" "P:%env.PublishFolder%" /MIR
NET USE P: /DELETE

Note how I referenced the build's working directory to find the published package, and two different Environment Variables that were set as part of the build configuration. This allowed me to target different network shares and folder structures without changing the script or any other build artifacts.

This wasn't a difficult solution to arrive at, but it did give me the opportunity to learn a lot about MSBuild and both the Extension Pack and the Community Tasks add-on packages. After trying to accomplish a similar task with Cruise Control.net and a WCF webservice project, and giving up; I have become a huge fan of TeamCity and it's ease of use. It only took three or four hours to get a brand new Windows Server 2008 Standard x86 (a requirement for WCF packages with COM dependencies) virtual machine stood up with TeamCity installed and working. That includes all the Windows Updates!

Happy Automated Building!