Deploying a ClickOnce app via Script
- Posted in:
- build script
ClickOnce seems to be quite maligned on the internet, but I think its a nice simple publishing technology, as long as your application is small and doesn’t need to do anything fancy on installation. It offers automatic updating as well, which is very nice.
Anyway I was getting annoyed that the only way I could deploy a new version of this desktop WPF app was by going into Visual Studio, right-click the Project –> Properties –> Publish. Then I had to go in and do things with the version, and make sure the other settings were correct.
It was all just too many clicks and too much to remember.
Complicating matters is that the app has 3 different build configurations, development, staging and release. Switching to any one of those build configurations changed the ClickOnce publish settings and the API endpoint used by the application. However, sometimes Visual Studio would just forget to update some of the ClickOnce publish settings, which caused me to publish a development application to the staging URL a couple of times (and vice versa). You had to actually reload the project or restart Visual Studio in order to guarantee that it would deploy to the correct location with the correct configuration. Frustrating (and dangerous!).
A little bit more information about the changes that occur as a result of selecting a different build configuration.
The API endpoint is just an app setting, so it uses Slow Cheetah and config transforms.
The publish URL (and supporting ClickOnce publish information) is stored in the csproj file though, so it uses a customised targets file, like this:<Project ToolsVersion="3.5" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup Condition="'$(Configuration)' == 'development-release'"> <PublishUrl>[MAPPED CLOUD DRIVE]\development\</PublishUrl> <InstallUrl>[PUBLICALLY ACCESSIBLE INSTALL URL]/development/</InstallUrl> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'staging-release'"> <PublishUrl>[MAPPED CLOUD DRIVE]\staging\</PublishUrl> <InstallUrl>[PUBLICALLY ACCESSIBLE INSTALL URL]/staging/</InstallUrl> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'production-release'"> <PublishUrl>[MAPPED CLOUD DRIVE]\production\</PublishUrl> <InstallUrl>[PUBLICALLY ACCESSIBLE INSTALL URL]/production/</InstallUrl> </PropertyGroup> </Project>
This customised targets file was included in the csproj file like this:<Import Project="$(ProjectDir)\Customized.targets" />
So, build script time! Mostly to make doing a deployment easier, but hopefully also to deal with that issue of selecting a build configuration and not having the proper settings applied.
Like everything involving software, many Yaks were shaved as part of automating this deployment.
My plan was to create 3 scripts, one to deploy to each environment. Those 3 scripts should use a single script as a base, so I don’t create a maintenance nightmare. This script would have to be parameterised around the target configuration. Sounds simple enough.
Lets look at the finished product first, then we’ll go into each section in detail.
@ECHO OFF SET publish_type=%1 SET install_url=[PUBLICALLY ACCESSIBLE INSTALL URL]/%publish_type%/ SET configuration=%publish_type%-release SET remote_publish_destination=[MAPPED CLOUD DRIVE]\%publish_type%\ SET timestamp_file=publishDate.tmp tools\date.exe +%%Y%%m%%d%%H%%M%%S > %timestamp_file% SET /p timestamp_directory= < %timestamp_file% DEL %timestamp_file% REM %~dp0 is the directory containing the batch file, which is the Solution Directory. SET publish_output=%~dp0publish\%publish_type%\%timestamp_directory%\ SET msbuild_output=%publish_output%msbuild\ tools\NuGet.exe restore [SOLUTION FILE] "C:\Program Files (x86)\MSBuild\12.0\bin\msbuild.exe" [SOLUTION FILE] /t:clean,rebuild,publish /p:Configuration=%configuration%;PublishDir=%msbuild_output%;InstallUrl=%install_url%;IsWebBootstrapper=true;InstallFrom=Web IF ERRORLEVEL 1 ( ECHO Build Failure. Publish terminated. EXIT /B 1 ) REM Add a small (1 second) delay here because sometimes the robocopy fails to delete the files its moving because they are in use (probably by MSBUILD). TIMEOUT /T 1 robocopy %msbuild_output% %remote_publish_destination% /is /E
Not too bad. It fits on one screen, which is a nice. The script itself doesn’t actually do all that much, just leverages MSBUILD and the build configurations that were already present in the solution.
The script lives inside the root directory of my solution, and it does everything relative to the directory that it’s in (so you can freely move it around). Scripts with hardcoded directories are such a pain to use, and there’s not even a good reason to do it. Its just as easy to do a relative script.
Alas, its not perfectly self contained. It is reliant on Visual Studio 2013 being installed, and a couple of other things (which I will mention later). Ah well.
First up, the script sets some variables that it needs to work with. The only parameter supplied to the script is the publish or deployment type (for me that’s development, staging or release). It then uses this value to select the appropriate build configuration (because they are named similarly) and the final publish location (which is a publically accessible URL).
Secondly, the script creates a working directory using the type of publish being performed and the current date and time. I’ve used the “date” command-line tool for this, which I extracted (stole) from a set of Unix tools that were ported to Windows. Its completely self contained (no dependencies, yeah!) so I’ve just included it in the tools directory of my repository. If you’re wondering why it creates a file to put the timestamp into, this was because I had some issues with the timestamp not evaluating correctly when I just put it directly inside a variable. The SET /P line allows you to set a variable using input from the user, and the input that it is supplied with is the contents of the file (using the < operator). More tricksy than I would like, but it gets the job done.
My other option was to write a simple C# command line application to get the formatted timestamp for the directory name myself, but this was a good exercise in exploring batch files. I suppose I could have also used ScriptCS to just do it inline (or Powershell) but that would have taken even more time to learn (I don’t have a lot of Powershell experience). This was the simplest and quickest solution in the end.
NuGet Package Restore
Third, the script restores the packages that the solution uses via NuGet. There are a couple of ways that you can do this inside the actual csproj files in the solution (using the MSBUILD targets and tasks), but I find that it’s just easier to call NuGet.exe directly from the command line, especially if you’re already in build script land. Much more obvious about what’s going on.
Build and Publish
Fourth, we finally get to the meat of the build script, where it farms out the rebuild and publish to MSBUILD. You can set basically any property from the command line when doing a build through MSBUILD, and in this case it sets the selected build configuration, a local directory to dump the output to and some properties related to the eventual location of the deployed application.
The reason it sets the IsWebBootstrapper and InstallFrom properties on the command line is because I’ve specifically set the ClickOnce deployment property values in the project file to be non-functional. This is to prevent people from publishing without using the script, which as mentioned previously, can actually be a risky proposition due to the build configurations.
The build and publish is more complicated than it appears though, and the reason for that is versioning.
Applications deployed through ClickOnce have 2 version related attributes.
The first is the ApplicationVersion, and the second is MinimumRequiredVersion.
ApplicationVersion is the actual version of the application that you are deploying. Strangely enough, this is NOT the same as the version defined in the AssemblyInfo file of the project. This means that you can publish Version 184.108.40.206 of a ClickOnce application and have the actual deployed exe not be that version. In fact, that’s the easiest path to take. It takes significantly more effort to synchronize the two.
I don’t like that.
Having multiple identifiers for the same piece of software is a nightmare waiting to happen. Especially considering that when you actually try to reference the version from inside some piece of C#, you can either use the normal way (checking the version of the executing assembly) or you can check the ClickOnce deployment version.
Anyway, MinimumRequiredVersion is for forcing users of the ClickOnce application to update to a specific version. In this case, the product owner required that the user always be using the latest version (which I agree with), so MinimumRequiredVersion needed to be synchronized with ApplicationVersion.
ClickOnce seems to assume that someone will be manually setting the ApplicationVersion (and maybe also the MinimumRequiredVersion) before a deployment occurs, and isn’t very friendly to automation.
I ended up having to write a customised MSBUILD task. Its nothing fancy (and looking back at it, I’m pretty sure there are many, better, ways to do it, maybe even using the community build tasks) but it gets the job done. You can see the source code of the build task here.
It takes a path to an AssemblyInfo file, reads the AssemblyVersion attribute from it, sets the build and revision versions to appropriate values (build is set to YYDDD i.e. 14295, revision is set to a monotonically increasing number, which is reset to 0 on the first build of each day), writes the version back to the AssemblyInfo file and then outputs the generated version, so that it can be used in future build steps.
I use this custom task in a customised.targets file which is included in the project file for the application (in the same way as the old project customisations were included above).
This is what the targets file looks like.
<?xml version="1.0" encoding="utf-8"?> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <GeneratedAssemblyVersion></GeneratedAssemblyVersion> </PropertyGroup> <UsingTask TaskName="Solavirum.Build.MSBuild.Tasks.ReadUpdateSaveAssemblyInfoVersionTask" AssemblyFile="$(SolutionDir)\tools\Solavirum.Build.MSBuild.Tasks.dll" /> <Target Name="UpdateVersion"> <Message Text="Updating AssemblyVersion in AssemblyInfo." Importance="high" /> <ReadUpdateSaveAssemblyInfoVersionTask AssemblyInfoSourcePath="$(ProjectDir)\Properties\AssemblyInfo.cs"> <Output TaskParameter="GeneratedVersion" PropertyName="GeneratedAssemblyVersion" /> </ReadUpdateSaveAssemblyInfoVersionTask> <Message Text="New AssemblyVersion is $(GeneratedAssemblyVersion)" Importance="high" /> <Message Text="Updating ClickOnce ApplicationVersion and MinimumRequiredVersion using AssemblyVersion" Importance="high" /> <CreateProperty Value="$(GeneratedAssemblyVersion)"> <Output TaskParameter="Value" PropertyName="ApplicationVersion" /> </CreateProperty> <CreateProperty Value="$(GeneratedAssemblyVersion)"> <Output TaskParameter="Value" PropertyName="MinimumRequiredVersion" /> </CreateProperty> <!-- This particular property needs to be set because of reasons. Honestly I'm not sure why, but if you dont set it the MinimumRequiredVersion attribute does not appear correctly inside the deployment manifest, even after setting the apparently correct propery above. --> <CreateProperty Value="$(GeneratedAssemblyVersion)"> <Output TaskParameter="Value" PropertyName="_DeploymentBuiltMinimumRequiredVersion" /> </CreateProperty> </Target> <Target Name="BeforeBuild"> <CallTarget Targets="UpdateVersion" /> </Target> </Project>
Its a little hard to read (XML) and it can be hard to understand if you’re unfamiliar with the way that MSBUILD deals with…things. It has a strange way of taking output from a task and doing something with it, and it took me a long time to wrap my head around it.
From top to bottom, you can see that it creates a new Property called GeneratedAssemblyVersion and then calls into the custom task (which is available in a DLL in the tools directory of the repository). The version returned from the custom task is then used to set the ApplicationVersion and MinimumRequiredVersion properties (and to log some statements about what its doing in the build output). Finally it configures the custom UpdateVersion target to be executed before a build.
Note that it was insufficient to just set the MinimumRequiredVersion, I also had to set the _DeploymentBuiltMinimumRequiredVersion. That took a while to figure out, and I still have no idea exactly why this step is necessary. If you don’t do it though, your MinimumRequiredVersion won’t work the way you expect it to.
Now that we’ve gone on a massive detour around versioning, its time to go back to the build script itself.
The last step in the build script is to actually deploy the contents of the publish directory filled by MSBUILD to the remote URL.
This publish directory typically contains a .application file (which is the deployment manifest), a setup.exe and an ApplicationFiles directory containing another versioned directory with the actual EXE and supporting files inside. Its a nice clean structure, because you can deploy over the top of a previous version and it will keep that old versions files around and only update the .application file (which defines the latest version and some other stuff).
For this application, the deployment URL is an Amazon S3 storage account, parts of which are publically accessible. I use TNTDrive to map a folder in the S3 account to a local drive, which in my case is Y.
With the deployment location available just like a local drive, all I need to do is use robocopy to move the files over.
I’ve got some basic error checking before the copy (just to ensure that it doesn’t try to publish if the build fails) and I included a short delay before the copy because of some issues I was having with the files being locked even though MSBuild had returned.
Future Points of Improvement
I don’t like the fact that someone couldn’t just clone the repository that the build script is inside and run it. That makes me sad. You have to install the TNTDrive client and then map it to the same drive letter as the script expects. A point of improvement for me would be to use some sort of portable tool to copy into an Amazon S3 account. I’m sure the tool exists, I just haven’t had a chance to look for it yet. Ahh pragmatism, sometimes I hate you.
A second point of improvement would be to make the script run the unit and integration tests and not publish if they fail. I actually had a prototype of this working at one point, but it needed more work and I had to pull back from it in order to get the script completed in time.
Lastly, it would be nice if I had a build server for this particular application. Something like TeamCity or AppVeyor. I think this is a great idea, but for this particular application (which I only work on contractually, for a few hours each week) its not really something I have time to invest in. Yet.
ClickOnce is a great technology, but it was quite an adventure getting a script based deployment to work. Documentation in particular is pretty lacklustre, especially around what exactly some of the deployment properties mean (and their behaviour).
Still, the end result solved my initial problems. I no longer have to worry about accidentally publishing an application to the wrong place, and I can just enter one simple command from the command line to publish. I even managed to fix up some versioning issues I had with the whole thing along the way.