How to create and publish a .Net WPF application with Gitlab CI/CD and NSIS

How to create and publish a .Net WPF application with Gitlab CI/CD and NSIS
Image copyright: Marco Griep (CC BY-NC-ND) - Written from: Marco Griep

Wie man mit Gitlab CI/CD und NSIS eine .Net WPF Anwendung erstellt und veröffentlicht

As a rule, since I have been involved with CI/CD, I have only web applications and websites that need to run on a Linux server. I have always kept this simple. The first step was to build the application, then pack it into a Docker image and pull it on the target server from the repository with the latest tag. However, currently I am working on the Windows UI for my 360°Image Viewer Software which will be available exclusively for Windows devices. In my solution there are 2 projects, one for the tray icon, the other for the actual GUI, plus an NSIS script to build the installer. I don’t want to have to do the build process and the installer build always manually and therefore I needed a pipeline. How I implemented this I try to explain in this article understandable.

Here’s what we have in mind

We will build a CI/CD pipeline using Gitlab in this post. This pipeline will build our project, cache it in a “Publish” directory with all the necessary files and then build an actual installer with NSIS. This installer will then be uploaded to a server using SSH so that the setup can also be linked automatically on a website, for example. The pipeline will look like this:

CI/CD Pipeline

Requirements

Unfortunately, the Docker images for .Net don’t do much for us in GitLab, since GitLab’s Docker host doesn’t seem to be able to provide Windows container images. So we need a build server that runs on Windows. I took an old Fujitsu Esprimo Q920 and installed a Windows Server 2019 image on it. For now, the 180 day trial version is enough for me. In the future, I will probably rather use a Windows 10 Home here for cost reasons. That’s perfectly adequate for what we want to do.

Install and register the GitLab Runner

For the GitLab pipeline to be used, at least one GitLab runner must be registered in the project. If you need a pipeline for Linux, the Shared Runner is usually sufficient. However, since GitLab does not bring us a Shared Runner for Windows, we install one ourselves. This can be done either on a VM or on the machine you are sitting at or on a build server. We only need the MSBuild tools and Nuget to build the solution. Download the latest GitLab Runner for Windows from GitLab. Unzip the zip archive and place the .exe file of Gitlab-Runner in the following path: “C:\Gitlab-Runner\gitlab-runner.exe” (I have adjusted the name of the EXE file).

The above link also includes the installation instructions if you prefer to follow the official GitLab documentation. You still need to install the GitLab runner after placing it in the correct directory. To do this, run the following commands with elevated privileges.

cd C:\GitLab-Runner
gitlab-runner.exe install
gitlab-runner.exe start

After that we still need to register the GitLab runner. The runner wants to have a URL from you as well as a secret key. You can find this information in your GitLab repository under: Settings -> CI/CD -> Runners -> Specific Runners.

GitLab Runner Settings

Copy the token and enter the following command in your command line to register your GitLab Runner:

gitlab-runner.exe register

Enter the information you have from your GitLab repository. The runner should then appear under Specific Runners. Disable the Shared Runners so that GitLab does not try to run the build process with them.

**Info: In my case it was necessary to enter a tag to which the GitLab Runner should react. The GitLab Runner should always be used regardless of the tag. I simply took “test” as tag and in the GitLab Runner settings I checked “Specifies if this runner can select jobs without tags “. Your GitLab Runner should now be ready to use.

Allow unknown certificates (optional)

I had the problem that my GitLab Runner could not download the pipeline artifacts because it did not know the certificate. I had downloaded and installed this manually, but it was still blocked. If this is also the case for you, you can create the following variable in GitLab under “Settings -> CI/CD -> Variables “:

Key: GIT_SSL_NO_VERIFY

Value: true

Git SSL Issue

Install NuGet

In order for the pipeline to download the dependencies, we need NuGet installed on our system. I downloaded current NuGet version (5.9.1) from official website and put it here: “C:\Tools\NuGet\NuGet.exe”. I didn’t adjust the path variable because I address directly to the absolute path in my pipeline.

Install MSBuild Tools

To create the release files for your project, you need MSBuild. If you have Visual Studio installed, you already have this installed. Otherwise, you would need to download and install either the Visual Studio version that matches your project or MSBuild. In my case this was Visual Studio 2019 Community. MSBuild is then installed under the following path: “C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\msbuild.exe “. We need to specify this path in our pipeline as well. Adjust the path if necessary.

Aufbau meines Repositories

I have built my repository so that all files that belong to the Windows app are in the same repository. The server frontend and backend are in a different repository and are not described here. The following components belong to my Windows application:

  • Tray Icon Project
  • WPF project
  • ElectronJS tool for 360 degree images preview
  • NSIS Installer
  • License file

Depending on your project setup you will of course need to customize your GitLab CI/CD file. Some points of my application you will not need. e.g. There is the ElectronJS Tool in this pipeline no build goes through, but simply all components (in the directory AdditionalRessources) are packed with in the installer, this point you need of course not to take over. Also the license file is only needed for the installer. The build of the tray project is identical to the build of the WPF project. If your Windows application consists of only one project, you can simply delete the unnecessary pipeline steps.

Repository

All C# project files are located under “360Tray”.

  • “360Tray\360Tray”: The WPF GUI
  • “360Tray\Tray”: The Tray Menu

CI / CD Yaml File

Now we want to build our pipeline. First we define variables for the path to our NuGet and MSBuild.

variables:
  NUGET_PATH: 'C:\Tools\Nuget\nuget.exe'
  MSBUILD_PATH: 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\msbuild.exe'

Then there should be three stages:

stages:
  - build
  - installer
  - backup

The last stage could also be called “deploy”. In the build stage we build our C# project files so that we have the executable program files in the release folder. In the second stage “Installer” all files needed for our application will be copied into a temporary directory “Publish” and our NSIS script will build the setup file. In the last stage “Backup” our application will be copied via SSH to a server. Once as “Setup.exe” and once with the commit ID in the file name. The Setup.exe is always the newest version of our installer. The setup files with the Commit ID in the filename are used for historization so that we can also publish older versions. Alternatively we could start a pipeline as soon as the project is tagged. The tag could then be used as the version name.

1. Build Stage

In this stage the project dependencies are restored with NuGet and the project is compiled with MSBuild.

build_ui:
  stage: build
  only:
    - branches
  script:
    - cd 360Tray
    - '& "$env:NUGET_PATH" restore'
    - '& "$env:MSBUILD_PATH" /p:Configuration=Release /clp:ErrorsOnly'
    - '& "$env:MSBUILD_PATH" 360Tray\360Tray.csproj /p:DeployOnBuild=true /p:Configuration=Release /P:PublishProfile=FolderProfile.pubxml'
  artifacts:
    expire_in: 2 days
    paths:
      - '.\360Tray\360Tray\bin\Release\'

Here you have to adjust your paths. As you can see, which I in the script area first in the subdirectory “360Tray”. There is my solution file. NuGet downloads here all dependencies, which are necessary for the projects in the solution. Then MSBuild command is called with the parameter “Release”. The build release files are stored as artifact in the pipeline and kept for 2 days.

I do the same again for the tray project, here only the paths change. For completeness I show the YAML code here too:

build_tray:
  stage: build
  only:
    - branches
  script:
    - cd 360Tray
    - '& "$env:NUGET_PATH" restore'
    - '& "$env:MSBUILD_PATH" /p:Configuration=Release /clp:ErrorsOnly'
    - '& "$env:MSBUILD_PATH" Tray\Tray.csproj /p:DeployOnBuild=true /p:Configuration=Release /P:PublishProfile=FolderProfile.pubxml'
  artifacts:
    expire_in: 2 days
    paths:
      - '.\360Tray\Tray\bin\Release\'

The two build processes then run in parallel in the pipeline.

2. Create Installer Stage

In the next step I prepare a directory under C:\temp\publish. In this directory I put all the release files. Furthermore I copy the Electron App (which is located under AdditionalRessources) and the license.txt file into the publish directory. Now you only have to call the install script with makensis and the installer will be created.

create_installer:
  stage: installer
  only:
    - master
  script:
    - if (Test-Path C:\temp\publish) { Remove-Item -Recurse -Force C:\temp\publish }
    - New-Item -ItemType directory -Path C:\temp\publish
    - Get-ChildItem .\360Tray\Tray\bin\Release | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - Get-ChildItem .\AdditionalRessources | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - Get-ChildItem .\360Tray\360Tray\bin\Release | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - copy-item "installer.nsi" "C:\temp\publish" -Force
    - copy-item "license.txt" "C:\temp\publish" -Force
    - '& "C:\Program Files (x86)\NSIS\makensis.exe" C:\temp\publish\installer.nsi'
    - copy-item "C:\temp\publish\Setup.exe" ".\Setup.exe" -Force
  artifacts:
    expire_in: 2 days
    paths:
      - '.\Setup.exe'
  dependencies:
    - build_tray
    - build_ui

Under Dependencies I have defined that this step should only be executed if the two previous build steps were also successful. Again, the setup is saved as an artifact in the pipeline for 2 days. In this case, saving as an artifact is not necessary. However, I then always get the latest setup file for the next 2 days and do not have to SSH to the server.

The NSIS Script

If you don’t know NSIS, NSIS is a script language with which you can create an installer including logic. You can easily create a NSIS script yourself with HM-NSIS. In the end only all files needed for the installer are linked here. For space reasons I will not copy the installer script here. If you want to see the script, just write me a message.

Note: In the before the NSIS script is compiled, you could still have the version replaced automatically with string replacement. For example by GitLab tag names. So the setup would always get a new version number.

3. Backup Stage (Deploy)

In the last step we want to upload the generated setup file to our server so that e.g. website visitors can download the current version. Since we are on a Windows system we don’t have rsync or a simple command to copy a file from A to B with SSH. I use the Putty tool “pscp” here. You can easily use this tool if you install Putty on your system.

backup_release:
  stage: backup
  only:
    - master
  script:
    - '& echo y | pscp -P 22 -pw "$SSH_PW" C:\temp\publish\Setup.exe admin@192.168.112.200:/share/Public/Archive/Setup.exe.$CI_COMMIT_SHA'
    - '& echo y | pscp -P 22 -pw "$SSH_PW" C:\temp\publish\Setup.exe admin@192.168.112.200:/share/Public/Archive/Setup.exe'
  dependencies:
    - create_installer

I defined the variable "$SSH_PW “ before under the GitLab settings -> Variables.

The complete .gitlab-ci.yaml file

variables:
  NUGET_PATH: 'C:\Tools\Nuget\nuget.exe'
  MSBUILD_PATH: 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\msbuild.exe'

stages:
  - build
  - installer
  - backup

build_ui:
  stage: build
  only:
    - branches
  script:
    - cd 360Tray
    - '& "$env:NUGET_PATH" restore'
    - '& "$env:MSBUILD_PATH" /p:Configuration=Release /clp:ErrorsOnly'
    - '& "$env:MSBUILD_PATH" 360Tray\360Tray.csproj /p:DeployOnBuild=true /p:Configuration=Release /P:PublishProfile=FolderProfile.pubxml'
  artifacts:
    expire_in: 2 days
    paths:
      - '.\360Tray\360Tray\bin\Release\'

build_tray:
  stage: build
  only:
    - branches
  script:
    - cd 360Tray
    - '& "$env:NUGET_PATH" restore'
    - '& "$env:MSBUILD_PATH" /p:Configuration=Release /clp:ErrorsOnly'
    - '& "$env:MSBUILD_PATH" Tray\Tray.csproj /p:DeployOnBuild=true /p:Configuration=Release /P:PublishProfile=FolderProfile.pubxml'
  artifacts:
    expire_in: 2 days
    paths:
      - '.\360Tray\Tray\bin\Release\'

create_installer:
  stage: installer
  only:
    - master
  script:
    - if (Test-Path C:\temp\publish) { Remove-Item -Recurse -Force C:\temp\publish }
    - New-Item -ItemType directory -Path C:\temp\publish
    - Get-ChildItem .\360Tray\Tray\bin\Release | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - Get-ChildItem .\AdditionalRessources | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - Get-ChildItem .\360Tray\360Tray\bin\Release | Copy-Item -Destination C:\temp\publish -Recurse -filter *.* -Force
    - copy-item "installer.nsi" "C:\temp\publish" -Force
    - copy-item "license.txt" "C:\temp\publish" -Force
    - '& "C:\Program Files (x86)\NSIS\makensis.exe" C:\temp\publish\installer.nsi'
    - copy-item "C:\temp\publish\Setup.exe" ".\Setup.exe" -Force
  artifacts:
    expire_in: 2 days
    paths:
      - '.\Setup.exe'
  dependencies:
    - build_tray
    - build_ui

backup_release:
  stage: backup
  only:
    - master
  script:
    - '& echo y | pscp -P 22 -pw "$SSH_PW" C:\temp\publish\Setup.exe admin@192.168.112.200:/share/Public/Archive/Setup.exe.$CI_COMMIT_SHA'
    - '& echo y | pscp -P 22 -pw "$SSH_PW" C:\temp\publish\Setup.exe admin@192.168.112.200:/share/Public/Archive/Setup.exe'
  dependencies:
    - create_installer

Conclusion

I hope that I could help you a little bit and that you succeed in building your .Net Framework project automatically. The pipeline is not perfect yet. As you may have noticed, the “test” step is missing. With XUnit you could add a stage that runs unit tests and only builds if they are successful. You could also exchange the version number with the tag number before compiling the setup file, so that the version is automatically incremented. If you need this you are welcome to tweak the script.

If I could help you and you could possibly save time by using my tutorial, I would be happy if you would share this post on social media. Social media shares help me in SEO ranking.