Continuous Learning and Sharing of Team Foundation Server and Application Lifecycle Management RSS 2.0
# Thursday, March 04, 2010

This is Part 4 of my Deployments with TFS series. It has been awhile since I started putting this information together but I haven’t found time to finish this.   A question about building ClickOnce deployments with Team Build 2008 on the MSDN forums was just what I needed to get this posted.

What is ClickOnce?

ClickOnce is a deployment technology that enables you to create self-updating windows based applications that can be installed and run with minimal user interaction.  For a complete overview of ClickOnce, please visit this the ClickOnce Deployment Overview article on MSDN.

Our Requirements

Our requirements contained a couple items that made our deployment more complicated.  The first one is one a configuration management issue that I strongly pushed.  Basically there were two environments, Test and Production.  The ClickOnce was to be built and deployed to the Test server, tested and accepted, and finally pushed to production.  The problem is that Test used different config files than Production.  The thing we didn't want to do is to have to rebuild the application to update the config files, recreate the manifest, and publish to the production.  This had the potential to introduce something different than was tested.Fortunately our Production Release build script was able to copy in the new config files and recreate the manifest and push to production.  The helped ensure the same version of the assemblies tested.  Of course nothing is perfect, we could have still had problems in our config files. :)

The next issue is that we wanted to run Test and Production at the same time to compare if there were problems.  ClickOnce thought these were the same applications so we were unable to run them both.  The solution for this was to use a different key for each environment.  In Test we used a test certificate key that we created, in Production, we used our issued certificate key.  With this, the assemblies could be the same or different versions and ClickOnce treated them as two separate applications.

The Solution

The solution consisted of 4 build scripts

  1. Build and Stage to Test – This builds the application, creates the ClickOnce manifest, and then publishes it to a folder share, ready to be deployed to Test (but not yet).
  2. Deploy to Test – This script copies the ClickOnce application to the web server so the application is available to the testers.  This script also deployed a WCF Windows service.
  3. Stage to Production – This script copied the production config file into the manifest folder, recreated the manifest (with production certificate), and copied the ClickOnce application to a staging folder on a production (so it could be deployed at a later time).
  4. Deploy to Production – This script copies the ClickOnce application from the production staging folder to the production web server.

The “staging” builds could be combined with the “deployment” builds but I believe by separating these it offers the most flexibility.  I will now highlight some of the unique things I had to do to get this all to work.  Unfortunately it wasn’t as simple as using the MSBUILD task.  I think this would work for a demo or simple scenario but not a real world scenario.  There wasn’t any way to change any of the settings.  This offers more options.

Build and Stage To Test

The first part is the properties.  Here I set all of the ClickOnce properties that will be used later on in the build.

<PropertyGroup>
  <TF>&quot;$(TeamBuildRefPath)\..\tf.exe&quot;</TF>
  <PublishUrl>\\myserver\deployments\coolapp\current\ClickOnceStage\</PublishUrl>
  <ClickOnceAppName>MyCompany.CoolApp</ClickOnceAppName>
  <ClickOnceExeFile>MyCompany.CoolApp.exe</ClickOnceExeFile>
  <ClickOnceProduct>Cool Application</ClickOnceProduct>
  <Company>My Company</Company>
  <ClickOnceDescription></ClickOnceDescription>
  <ClickOnceUrl>http://testserver.mycompany.com/</ClickOnceUrl>
  <SigningCert>$(SolutionRoot)\MyCompany.CoolApp\CoolApp_1_TemporaryKey.pfx</SigningCert>
  <SigningCertPassword>1234</SigningCertPassword>
</PropertyGroup>

The first trick is to edit project file and update the publish url.   This allows the build to edit it but not check out the file and need to check it in.  Do this before the build by overriding the BeforeCompile target.

<Target Name ="BeforeCompile">
  <Message Text="Making csproj file writable"/>
  <Exec Command="attrib -R &quot;$(SolutionRoot)\MyCompany.CoolApp\MyCompany.CoolApp.csproj&quot;"/>

  <Message Text="Replacing PublishUrl"/>
  <File.RegEx
    Path="$(SolutionRoot)\MyCompany.CoolApp\MyCompany.CoolApp.csproj"
    RegularExpression="&lt;PublishUrl&gt;(.*?)&lt;/PublishUrl&gt;"
    NewValue="&lt;PublishUrl&gt;$(PublishUrl)&lt;/PublishUrl&gt;"
     />
</Target>

Take the publish.htm file from a manual ClickOnce publish change the version to a tag that can be replaced by the updated version number and check it in to the solution.  After the compile, use the modified version of the publish.htm file copy it to the staging location and then replace it with the version.  MaxVersion is the variable the represents the new version.  I like to keep the assembly version the same as the ClickOnce version.

<Target Name="AfterCompile" Condition="'$(IsDesktopBuild)'!='true'">
  <!-- Copy modified publish htm file to staging publish location -->
  <Copy SourceFiles="$(SolutionRoot)\publish.htm" DestinationFolder="$(PublishUrl)" />

  <ItemGroup>
    <WebPage Include="$(PublishUrl)\publish.htm" />
  </ItemGroup>

  <RegEx
    Condition="Exists(@(WebPage))"
    Path="@(WebPage)"
    RegularExpression="#VERSION#"
    NewValue="$(MaxVersion)"
    Force="true"/>
</Target>

Next, I used a couple of custom tasks I created.  The first was to get the framework version.  I couldn’t figure out a way to do this.  Basically this just returns the .net framework path so I can call Mage.exe.  The second one takes the name of the app and the version to create the manifest folder.  Then it does the heavy lifting to create the ClickOnce application.  We have to do each step to make this work.  There is one thing you will probably noticed is that the setup.exe is renamed to CoolAppSetup.exe.  This was done because there was a policy that users couldn’t run setup.exe. I left out the copying of the files to the staging location before running all of this below.  I had to copy the files individually because this build script also built the WCF service.  You will need to add the appropriate process to copy these.

  1. Generate the application manifest
  2. Sign the application manifest
  3. Rename the source files to .deploy
  4. Generate the deployment manifest
  5. Sign the application manifest (one more time)
  6. Create the bootstrapper

<Target Name="AfterEndToEndIteration">
  <GetFrameworkPath>
    <Output TaskParameter="FrameworkPath" PropertyName="FrameworkPath" />
  </GetFrameworkPath>
  <CreateManifestName ExecutableName="MyCompany.CoolApp" ExecutableVersion="$(MaxVersion)">
    <Output TaskParameter="ManifestName" PropertyName="ManifestName" />
  </CreateManifestName>
  <PropertyGroup>
    <ClickOnceApplicationUrl>$(ClickOnceUrl)$(ClickOnceAppName).application</ClickOnceApplicationUrl>
    <PublishDir>$(PublishUrl)</PublishDir>
    <AppPublishDir>$(PublishDir)Application Files\$(ManifestName)</AppPublishDir>
    <SdkPath>$(FrameworkPath)\</SdkPath>
    <VersionNumber>$(MaxVersion)</VersionNumber>
  </PropertyGroup>

  <Message Text="FrameworkPath = $(FrameworkPath)" />
  <BuildStep
    TeamFoundationServerUrl="$(TeamFoundationServerUrl)"
    BuildUri="$(BuildUri)"
    Message="Building $(ClickOnceAppName) ClickOnce version: $(VersionNumber)">
    <Output TaskParameter="Id" PropertyName="StepId" />
  </BuildStep>

  <!--
************************************************
Generate application manifest
************************************************
-->
  <Exec
  Command="mage.exe -New Application -TrustLevel FullTrust -ToFile &quot;$(AppPublishDir)\$(ClickOnceExeFile).manifest&quot; -Name &quot;$(ClickOnceAppName)&quot; -Version &quot;$(VersionNumber)&quot; -FromDirectory &quot;$(AppPublishDir)"
  WorkingDirectory="$(SdkPath)"/>

  <!--
************************************************
Sign application manifest
************************************************
-->
  <!--<Exec Condition="'$(SigningCertPassword)'==''"
    Command="mage.exe -Sign &quot;$(AppPublishDir)\$(ClickOnceExeFile).manifest&quot; -CertFile &quot;$(SigningCert)&quot;"
    WorkingDirectory="$(SdkPath)"  />-->

  <Exec Condition="'$(SigningCertPassword)'!=''"
      Command="mage.exe -Sign &quot;$(AppPublishDir)\$(ClickOnceExeFile).manifest&quot; -CertFile &quot;$(SigningCert)&quot; -Password &quot;$(SigningCertPassword)&quot;"
      WorkingDirectory="$(SdkPath)"/>

  <!--
************************************************
Rename source files to .deploy
************************************************
-->
  <ItemGroup>
    <SourceFilesToRename Include="$(AppPublishDir)\**\*.*"
                         Exclude="$(AppPublishDir)\*.manifest;$(AppPublishDir)\*.htm"/>
    <SourceFilesToDelete Include="$(AppPublishDir)\**\*.*"
                         Exclude="$(AppPublishDir)\*.application;$(AppPublishDir)\*.manifest;$(AppPublishDir)\*.htm"/>
  </ItemGroup>

  <Copy
      SourceFiles="@(SourceFilesToRename)"
      DestinationFiles="@(SourceFilesToRename->'$(AppPublishDir)\%(RecursiveDir)%(Filename)%(Extension).deploy')"
      />

  <Delete Files="@(SourceFilesToDelete)"/>

  <!--
************************************************
Generate deployment manifest
************************************************
-->
  <CreateItem Include="$(AppPublishDir)\$(ClickOnceExeFile).manifest" AdditionalMetadata="TargetPath=Application Files\$(ManifestName)\$(ClickOnceExeFile).manifest">
    <Output TaskParameter="Include" ItemName="ApplicationManifest"/>
  </CreateItem>

  <Message Text="@(ApplicationManifest)" />

  <GenerateDeploymentManifest
    MapFileExtensions="true"
    AssemblyName="$(ClickOnceAppName).application"
    AssemblyVersion="$(VersionNumber)"
    Description="$(ClickOnceDescription)"
    Product="$(ClickOnceProduct)"
    Publisher="$(Company)"
    SupportUrl="$(SupportUrl)"
    EntryPoint="@(ApplicationManifest)"
    Install="false"
    UpdateEnabled="true"
    UpdateInterval="7"
    UpdateMode="Foreground"
    OutputManifest="$(PublishDir)\$(ClickOnceAppName).application"/>

  <!--
************************************************
Sign application manifest
************************************************
-->
  <!--<Exec Condition="'$(SigningCertPassword)'==''"
      Command="mage.exe -Sign &quot;$(PublishDir)\$(ClickOnceAppName).application&quot; -CertFile &quot;$(SigningCert)&quot;"
      WorkingDirectory="$(SdkPath)"/>-->
  <Exec Condition="'$(SigningCertPassword)'!=''"
      Command="mage.exe -Sign &quot;$(PublishDir)\$(ClickOnceAppName).application&quot; -CertFile &quot;$(SigningCert)&quot; -Password &quot;$(SigningCertPassword)&quot;"
      WorkingDirectory="$(SdkPath)"/>

  <!--
************************************************
Generate Bootstrapper
************************************************
-->
  <ItemGroup>
    <BootstrapperFile Include="Microsoft.Net.Framework.3.5">
      <ProductName>Microsoft .NET Framework 3.5</ProductName>
    </BootstrapperFile>
    <BootstrapperFile Include="Microsoft.Windows.Installer.3.1">
      <ProductName>Windows Installer 3.1</ProductName>
    </BootstrapperFile>
  </ItemGroup>

  <GenerateBootstrapper
    ApplicationFile="$(ClickOnceAppName).application"
    ApplicationName="$(ClickOnceAppName)"
    ApplicationUrl="$(ClickOnceUrl)"
    BootstrapperItems="@(BootstrapperFile)"
    Culture="en"
    FallbackCulture="en-US"
    CopyComponents="true"
    Validate="false"
    OutputPath="$(PublishDir)"/>

  <Copy SourceFiles="$(PublishDir)\Setup.exe" DestinationFiles="$(PublishDir)\CoolAppSetup.exe" />
  <Delete Files="$(PublishDir)\Setup.exe" />

  <BuildStep
    TeamFoundationServerUrl="$(TeamFoundationServerUrl)"
    BuildUri="$(BuildUri)"
    Id="$(StepId)"
    Status="Succeeded"/>

  <OnError ExecuteTargets="MarkBuildStepAsFailed" />
</Target>

<!--
************************************************
Mark the buildstep as failed
************************************************
-->
<Target Name="MarkBuildStepAsFailed">
  <BuildStep
    TeamFoundationServerUrl="$(TeamFoundationServerUrl)"
    BuildUri="$(BuildUri)"
    Id="$(StepId)"
    Status="Failed"/>
</Target>

The Deploy Builds are simply a Copy task to copy the files to the web server.  Do this by creating a share to the same location so you can use a UNC.  The production stage build is basically all of the above again but with the production certificate key and copy the new config file(s) to the production stage location.

Looking back on this it seems complex but this is what the publish wizard is doing behind the scenes and offers complete customizing to fit your needs.  I sliced, diced, and renamed items in the scripts above.  I tried to make sure everything is correct.  If there is a typo or something missing, please let me know and will update it.

Enjoy!

Mike

Thursday, March 04, 2010 11:20:00 AM (Central Standard Time, UTC-06:00)  #    Comments [3] -
ClickOnce | Team Build | Team Foundation Server

Visual Studio ALM MVP
Microsoft Visual Studio ALM MVP
Archive
<November 2014>
SunMonTueWedThuFriSat
2627282930311
2345678
9101112131415
16171819202122
23242526272829
30123456
Blogroll
About the author/Disclaimer

Disclaimer
The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.

© Copyright 2014
Mike Douglas
Sign In
Statistics
Total Posts: 101
This Year: 7
This Month: 0
This Week: 0
Comments: 87
All Content © 2014, Mike Douglas
DasBlog theme 'Business' created by Christoph De Baene (delarou)