Automating Clickonce deployment for .net 6.0 Winforms
Updating legacy apps for the future
Update Jan 26, 2023
The instructions below work the same for .net 6 and mage version 6. The specific version numbers below are left as is but the specific versions are not required.
Where we started
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.
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 "Company Name" -v $Version -AppManifest $manifestName -t $clickOnceAppFile
dotnet mage -Update $clickOnceAppFile -pub "Company Name" -v $Version -AppManifest $manifestName -MinVersion $Version
} else{
dotnet mage -Update $clickOnceAppFile -Install true -pub "Company Name" -v $Version -AppManifest $manifestName
dotnet mage -Update $clickOnceAppFile -pub "Company Name" -v $Version -AppManifest $manifestName -MinVersion $Version
}
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.
Update March 14, 2022
For auto update to work another command needs to be ran to update the Deployment manifest. That change has been made and reflected in the necessary sections.