The chicken can dance

Rubber Chicken Paradise

Making the chicken dance one line of code at a time

Jay Oursler

8 minutes read

With .NET Framework on the way out and .NET 5 being released, my company decided to go through and update some of our applications to use .NET 5.

The application was on .NET Framework 4.5.1 when I started a while ago. About a year ago I upgraded it to .NET Framework 4.6.1 to gain access to .NET Standard 2.0 libraries and share code between a new .NET Core Web API hosted on AWS Elastic Container Service with Fargate.

Updating to .NET 5

The actual updates to .NET 5 for the WinForms app was actually pretty straight forward. (Previously written about). Now that we are finally pushing things out I needed to update the ClickOnce deployment mechanism. Unfortunately, after diving into trying to figure out how to use the Publish functionality I came across this GitHub issue where the maintainers confirmed my fears, ClickOnce is not supported through the Visual Studio Publish functionality for SDK Style projects. Thankfully using Mage through the command line is still possible.

Starting to script things out

The first step in the process is to generate the dotnet publish or MSBuild command line build. Since this is a legacy app, there are still some Office Interop COM connections left over so the dotnet publish was out (.NET Core MSBuild does not support COM objects, which is understandable since the specific COM objects are windows only).

After getting the build scripted out and published to a folder, the next step was using dotnet-mage (Nuget.org version 5.0.0 for .NET 5) to build the ClickOnce deployment manifest.

Getting things set up

The first step is to install the dotnet-mage command line tool since its not included in Visual Studio 2019. Since its a dotnet CLI command line tool its as simple as running dotnet tool install --global Microsoft.DotNet.Mage --version 5.0.0 from PowerShell.

Once that is installed everything is ready to go for the packaging.

Adding the Launcher

With .NET 5 there is a small wrinkle that we need to add a Launcher for .NET 5 apps. To add the Launcher we just need to run dotnet mage -al myapp.exe -td files. The -al switch tells dotnet mage which EXE file the launcher should point to and -td is the target directory where the launcher should be created (that the exe file lives in). After running the command you will see a Launcher.exe added to the directory.

Building the Application Manifest

ClickOnce works by building a manifest file that contains a hash of every file to be deployed. The manifest is also versioned so when a user runs the application, updates can be handled automatically. To build the manifest you run:

dotnet mage -new Application -t files\MyApp.manifest -fd files -v 1.0.0.1

  • -new Application is used to generate a new application manifest. In my deploy script, I am building and copying the files to version specific folders, similar to the legacy ClickOnce through Visual Studio Publish.
  • -t files\MyApp.manifest is the manifest file name and location. This should be placed in the same folder as your application EXE.
  • -fd files specifies which folder should have its files added to the manifest. This is recursive, so any subfolder will also get added. Make sure your publish process does not include any files or secrets that should not be deployed to a users computer.
  • -v 1.0.0.1 to set the semantic version of the application manifest

Building the Deployment Manifest

The final piece that makes ClickOnce work is the Deployment Manifest. This xml file contains the latest version number and hash and if a user must install the latest version. I use the following folder structure for an app

ApplicationRoot
  | deploymentManifest.application
  | Application Files
    | app_1_0_0_0
    | app_1_0_1_0
    | app_1_0_1_1
    | app_1_0_2_0

The Application Manifest is only created once then for each version released the deployment manifest is updated with the latest version number and hash.

  • Creating the application manifest

    • dotnet mage -new Deployment -Install true -pub "My Publisher" -v 1.0.0.1 -AppManifest files\MyApp.manifest -t MyApp.application
  • Updating the application manifest

    • dotnet mage -update MyApp.Application -v 1.0.0.2 -AppManifest files\MyApp.manifest

Supporting Multiple deployed versions

One of the major limitations with ClickOnce is you cant deploy multiple versions, such as a Dev and Prod version, very easily. ClickOnce depends on the Assembly Name to determine if the application has been installed. However since we are scripting all this out, changing the assembly name through MSBuild is actually relatively easy (Thanks StackOverflow.

The first step is to add a conditional <AssemblyName /> to our project file (.vbproj or ‘.csproj’) so we can pass in the Assembly Name. We need this since specifying the Assembly Name through the MSBuild -p:AssemblyName will set the assembly of all the projects built which is not what we want.

    <AssemblyName Condition=" '$(ThisProjectNameOverrideAssemblyName)' == '' " >FallBackAssemblyName</AssemblyName>
    <AssemblyName Condition=" '$(ThisProjectNameOverrideAssemblyName)' != '' " >$(ThisProjectNameOverrideAssemblyName)</AssemblyName>

The next part to making this work is to pass /p:ThisProjectNameOverrideAssemblyName=SomeOtherName to MSBuild through the command line.

Wrapping everything in PowerShell

The final step to all this automation was creating a PowerShell script to semi automate everything. While its not as powerful as something like Jenkins or TeamCity or one of the cloud based build automation tools, for a small company with only a couple of apps, the overhead of a full CICD platform makes the PowerShell script the way to go.

GitHub gist

The script Parameters

These are all the things that change (or can change) build to build. There are some things below that are still hard coded (like the MSBuild Path below) but that is a compromise I am willing to make. Some of the above parameters are also set with sane defaults for the project I am working on.

The most important ones are $Version to set the semantic version number and $packageName which control the ClickOnce deployment.

$confituration, $winformsProjLocation, and $buildOutputDir are passed to MSBuild properties to control whether we build the Prod or Dev version (Based on Build Configurations like Debug, Release, or other custom build profiles) and enable us to reuse our script for multiple WinForms apps if we need to. I have 2 wrapper scripts that pass a defined set of parameters in for everything except version number so deploying Dev or Prod is as simple as running .\DeployDev.ps1 1.0.0.1.

$packageOutDir sets our staging ground to prepare the deployment files and $networkDeployPath is the root UNC path for the application folder structure.

param(
    [Parameter()]
    [string]
    $Version,
    [Parameter()]
    [string]
    $packageName,
    [Parameter()]
    [string]
    $confituration,
    [Parameter()]
    [string]
    $networkDeployPath,
    [Parameter()]
    [string]
    $winformsProjLocation,
    [Parameter()]
    [string]
    $packageOutDir
)

MSBuild

While normally I prefer dotnet build and dotnet publish, because of the COM objects we need to use MSBuild directly.

The first thing we do is set our MSBuild Path. This may change and could be added as a parameter, but since it will only change on me when I install VS 2022 sometime in the future, I left it hard coded.

Next we set up our build output directory for publishing, make sure we don’t have anything left from past builds, run Clean with MSBuild, run our build, and finally clean up any files we don’t want in the final deployment.

If you are using dotnet publish just swap out the MSBuild for the dotnet SDK commands.




$msbuildPath = "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin"
$buildOutputDir = "$packageOutDir\bin"

if((test-path $buildOutputDir)){
    remove-item -recurse $buildOutputDir
}

&"$msbuildPath\MSBuild.exe" /t:Clean $winformsProjLocation

&"$msbuildPath\MSBuild.exe" -p:Configuration=$confituration -p:OutDir=$buildOutputDir /p:Version=$Version /p:ThisProjectNameOverrideAssemblyName=$packageName $winformsProjLocation

remove-item -recurse "$buildOutputDir\ref"

Preping for the wizards

Now lets package up our files for deployment.

Since I like to use PackageName_1_0_0_0 when deploying our files, lets calculate our folder paths and create a new folder to hold the deployment files. Since ClickOnce works on relative paths we set up all our folders how they will be under the application deployment root folder.

$appFileFolder="$($packageName)_$($version.replace(".", "_"))"

$applicationFileSubFolder="Application Files"


$appFileDir = "$packageOutDir\$applicationFileSubFolder\$appFileFolder"

if(!(test-path $appFileDir)){
    New-Item -Path $appFileDir -ItemType Directory
}

copy-item -recurse "$buildOutputDir\*" $appFileDir

Visiting Gandalf

Now we finally get to our mage. We add the Launcher, build the Application Manifest, and the either create or update the Deployment Manifest. This could all happen after copying the files up to a network share but since I have been working from home and the VPN can sometimes be slow, this all happens locally.


dotnet mage -al "$packageName.exe" -td $appFileDir

$manifestName = "$appFileDir\$packageName.manifest"

dotnet mage -new Application -t $manifestName -fd $appFileDir -v $Version

$clickOnceAppFile = "$packageOutDir\$packageName.application"

if(!(test-path $clickOnceAppFile)){

    dotnet mage -new Deployment -Install true -pub "Sunset Transportation" -v $Version -AppManifest $manifestName -t $clickOnceAppFile
} else{
    dotnet mage -Update $clickOnceAppFile -Install true -pub "Sunset Transportation" -v $Version -AppManifest $manifestName
}

Release the Users

The last 2 commands of the PowerShell script copy the files out. The first copies the application files into a version specific subfolder and the second copies the MyApp.application deployment manifest.



copy-item -recurse "$appFileDir" "$networkDeployPath\$applicationFileSubFolder\$appFileFolder"
copy-item $clickOnceAppFile $networkDeployPath

Cleaning Up

If, like me, you are updating an app, once the users install the new version it would be a good idea to have them uninstall the old version through Add or Remove Programs.

Overall, I am starting to like ClickOnce more than I did. Especially now that I have scripted out the build and can deploy multiple versions based on the Assembly Name.

Recent posts

See more

Categories

About

An ADHD programmer writing about the random stuff they run into. Check the about page for more.