Azure DevOps CI/CD Pipeline for ClickOnce Applications (.NET Framework)

Author: Ramesh Kanjinghat

ClickOnce provides a convenient way to publish and distribute Windows applications without requiring users to manually download and install application updates.

When deploying ClickOnce applications, Visual Studio supports three deployment options:

  • Install from the Web or a Network Share: Users access a published installation page and install the application on their machines.
  • Install from a CD: Users install the application from a physical distribution medium.
  • Start the Application from the Web or a Network Share: Users launch the application directly without installing it locally in the traditional sense.

In this article, I’ll demonstrate how to automate ClickOnce deployment using Azure DevOps YAML pipelines. We’ll use a simple Visual Studio solution containing a Windows Forms project to walk through the implementation.

The pipeline will build and publish the application, sign it using a digital certificate, execute database migrations when required, and deploy the published application to a network share.

About the code

For this example, we’ll use a .NET Framework solution named Dhrutara.ClickOnce, which contains a single Windows Forms project named Dhrutara.WindowsForms.

You can find the complete example solution in the GitHub repository:

View the Dhrutara.ClickOnce source code.

Requirements

The objective is to automate the following tasks:

  1. Publish a .NET Framework 4.x Windows Forms application using ClickOnce so that users can install it from the Web or a network share.

    I’ve tested this approach with .NET Framework 4.x. It might work with earlier versions, but I haven’t tested those.

  2. Digitally sign the ClickOnce application using a code-signing certificate. See ClickOnce and Authenticode.

  3. Run database migrations as part of the deployment process.

  4. Trigger a build validation pipeline when pull requests are created against the ci and master branches.

  5. Trigger the deployment pipeline when changes are merged into the ci or master branches.

To keep the implementation reusable, we’ll create a deployment pipeline template and use it with different configurations for CI and Production environments.

Assumptions and prerequisites

Before implementing the pipelines, make sure the following prerequisites are in place:

  • Your source code is hosted in an Azure DevOps Git repository.
  • You’re using self-hosted Windows build agents. See Azure Pipelines agents.
  • The Windows application is configured for ClickOnce publishing and digital signing.
  • A network share is available to host the published ClickOnce application.
  • The account running the deployment agent has the necessary permissions to access the certificate and write to the network share.

Preparing the deployment environment

Before creating the YAML files, we need to configure a few things on the deployment agent.

Step 1: Make the code-signing certificate available to the deployment agent

When publishing a digitally signed ClickOnce application, MSBuild needs access to the code-signing certificate.

For security reasons, you shouldn’t store the certificate directly in your source code repository. Instead, install the certificate on the server where the deployment agent runs.

You have two options:

  • Install the certificate in the certificate store for the specific user account running the pipeline agent.
  • Install the certificate in the machine-level certificate store.

Installing the certificate in the user-specific certificate store is generally preferable because it limits access to the account that needs it.

Make sure the certificate is installed in the appropriate certificate store and that the deployment agent can access it during the publishing process.

Step 2: Configure the network share

Create a network share where the ClickOnce application will be published.

Make sure the account running the deployment agent has write permissions on the share. The pipeline uses this location to copy the published application files.

Step 3: Prepare database migrations

For this example, I’ll assume the database already exists and that the deployment pipeline only needs to apply incremental database changes.

I’ll use DbUp to execute database migrations.

DbUp provides several ways to manage and execute database upgrade scripts. Refer to its documentation for details on configuring it for your application.

Creating the Azure DevOps YAML pipelines

Let’s add the pipeline definitions to our solution.

I’ve created a solution folder named Pipelines and added three YAML files:

  1. DeployClickOnce.yml – Reusable deployment template.
  2. CIPipeline.yml – CI environment deployment pipeline.
  3. MasterPipeline.yml – Production deployment pipeline.

1. DeployClickOnce.yml

This is the reusable deployment template.

It contains the steps required to restore NuGet packages, build and publish the Windows Forms project as a ClickOnce application, generate the Publish.htm installation page, copy the published files to the network share, and optionally execute database migrations.

Using a template allows us to reuse the same deployment logic across multiple environments while passing environment-specific configuration through parameters.

parameters:
- name: buildConfiguration
- name: publishUrl
- name: environmentName
- name: deployRootPath
- name: solutionName
- name: projectName
- name: dbMigrations
  type: object
  default:
  - name: runDbMigrations
    default: false
  - name: dbConnectionString
    default: ''
  - name: executableProjectName
    default: ''
# The defaults allow callers to omit dbMigrations
# when database migrations are not required.

stages:
- stage: 'DeployClickOnce'
  displayName: 'Deploy ClickOnce'
  jobs:
  - deployment: 'DeploayClickOnce'
    displayName: 'Deploy ClickOnce'
    variables:
      solutionFolder: '$(Build.SourcesDirectory)\${{parameters.solutionName}}'
    # Check https://docs.microsoft.com/en-us/azure/devops/pipelines/process/environments?view=azure-devops
    # to learn more about Azure DevOps environments.
    environment:
      name: '${{parameters.environmentName}}'
      resourceType: 'virtualMachine'
      tags: 'Services'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self

          # Restore NuGet packages.
          - task: NuGetCommand@2
            displayName: 'Restore Packages'
            inputs:
              command: restore
              restoreSolution: '**/*.sln'
              feedsToUse: config
              nugetConfigPath: '$(solutionFolder)\nuget.config'

          # Publish the Windows Forms project as a ClickOnce application.
          - task: PowerShell@2
            displayName: 'Build and publish'
            inputs:
              targetType: 'inline'
              script: |
                Set-Location '$(solutionFolder)\${{parameters.projectName}}'
                & 'C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe' /target:publish /p:Configuration=${{parameters.buildConfiguration}} /p:PublishUrl='${{parameters.publishUrl}}'

          # Generate Publish.htm, the installation page accessed by end users.
          # Visual Studio generates this file automatically when publishing
          # through the IDE. Here, we generate it as part of the pipeline.
          - task: PowerShell@2
            displayName: 'Generate Publish.htm'
            inputs:
              targetType: 'inline'
              script: |
                [xml]$projectFile = Get-Content '$(solutionFolder)\$${{parameters.projectName}}\${{parameters.projectName}}.vbproj'
                [string]$version = $projectFile.Project.PropertyGroup.MinimumRequiredVersion
                $publishHtm = Get-Content '$(solutionFolder)\publish.htm' -Raw
                $updatePublishHtm = $publishHtm -replace '{VERSION}', $version.Trim()
                $updatePublishHtm | Set-Content -Path '$(solutionFolder)\${{parameters.projectName}}\bin\${{parameters.buildConfiguration}}\app.publish\publish.htm'

          # Copy the published application to the network share.
          - task: CopyFiles@2
            displayName: 'Copy files'
            inputs:
              sourceFolder: '$(solutionFolder)\${{parameters.projectName}}\bin\${{parameters.buildConfiguration}}\app.publish'
              contents: '**'
              targetFolder: '${{parameters.deployRootPath}}'
              cleanTargetFolder: false
              overWrite: true

          # Execute database migrations when enabled.
          - ${{ if eq(parameters.dbMigrations.runDbMigrations, true) }}:
          - task: PowerShell@2
            displayName: 'Run database migrations'
            inputs:
              targetType: 'inline'
              script: |
                $command = '${{parameters.solutionFolder}}\${{parameters.executableProjectName}}\bin\${{parameters.buildConfiguration}}\${{parameters.executableProjectName}}.exe ${{parameters.dbConnectionString}}'
                & $command

2. CIPipeline.yml

This pipeline deploys the application to the CI environment whenever changes are pushed to the ci branch.

It references the reusable DeployClickOnce.yml template and passes the configuration specific to the CI environment.

name: $(BuildDefinitionName)-PR.$(Build.SourceBranch)$(Rev:.r)

# Trigger the pipeline when changes are pushed to the ci branch.
trigger:
- ci

stages:

# Reuse the deployment template with CI environment configuration.
- template: DeployClickOnce.yml
  parameters:
    buildConfiguration: 'Debug'
    publishUrl: 'The URL the end users use to install the ClickOnce application'
    environmentName: 'CI'
    deployRootPath: 'The network share where the Windows Forms project will be deployed as ClickOnce'
    dbMigrations:
      runDbMigrations: true
      dbConnectionString: 'your database connection string here'
      executableProjectName: 'Dhrutara.ClickOnce.DBMigrations'

3. MasterPipeline.yml

This pipeline deploys the application to Production whenever changes are pushed to the master branch.

Like the CI pipeline, it uses the reusable deployment template, but passes the Production-specific configuration.

name: $(BuildDefinitionName)-PR.$(Build.SourceBranch)$(Rev:.r)

# Trigger the pipeline when changes are pushed to the master branch.
trigger:
- master

stages:

- template: DeployClickOnce.yml
  parameters:
    buildConfiguration: 'Release'
    publishUrl: 'The URL the end users use to install the ClickOnce application'
    environmentName: 'Production'
    deployRootPath: 'The network share where the Windows Forms project will be deployed as ClickOnce'
    dbMigrations:
      runDbMigrations: true
      dbConnectionString: 'your database connection string here'
      executableProjectName: 'Dhrutara.ClickOnce.DBMigrations'

Setting up the pipelines in Azure DevOps

Now that we’ve created the YAML files, the next step is to create the pipelines in Azure DevOps.

Follow the Azure DevOps pipeline creation documentation to create a pipeline for each branch.

Configure the pipelines as follows:

  • CI pipeline: Point it to Pipelines/CIPipeline.yml and configure it to use the ci branch.
  • Production pipeline: Point it to Pipelines/MasterPipeline.yml and configure it to use the master branch.

Once configured, pushing changes to the respective branches will trigger the deployment pipelines.

What if I don’t have database migrations?

Database migrations are optional.

If your application doesn’t require database migrations, you can omit the dbMigrations parameter from the template invocation.

For example, remove the following section from CIPipeline.yml or MasterPipeline.yml:

dbMigrations:
  runDbMigrations: true
  dbConnectionString: 'your database connection string here'
  executableProjectName: 'Dhrutara.ClickOnce.DBMigrations'

The deployment template provides defaults for these parameters, allowing you to reuse it without configuring database migrations.

A few things to keep in mind

  • If you copy and paste the YAML examples, you might encounter formatting or indentation issues. Visual Studio Code and Visual Studio provide YAML syntax highlighting and validation.
  • You may need to adjust the pipeline parameters, project paths, MSBuild location, and deployment configuration to match your solution and infrastructure.
  • The examples use Visual Studio 2019 Build Tools. If you’re using a different Visual Studio version, update the MSBuild executable path accordingly.

That’s it! You now have a reusable Azure DevOps YAML pipeline for publishing and deploying a .NET Framework ClickOnce application, with optional database migrations and separate configurations for CI and Production environments.