Using Azure CI for cross-platform Linux and Windows Qt application builds

Developing cross-platform applications brings some extra challenges. Generally your development machine runs an operating system of your choice, but you need to assure that your application runs on other platforms as well. In the end the only way to do this is to actually test if everything is OK. This hassle can be simplified using CI.

While not being a Microsoft fan I ended up using a git repository and Azure DevOps for my latest project because of reasons. Turns out it’s actually quite usable.

Azure pipelines

Azure CI is configured by a “azure-pipelines.yaml” file. It’s syntax is similar to other CIs. In general it’s used as a starting point for automatic builds, with the more detailed process described in separate files.

    - master
    - refs/tags/*
- job: Windows
    vmImage: 'vs2017-win2016'
  - template: ci/windows.yml

- job: Linux
    vmImage: 'ubuntu-latest'
  - template: ci/linux.yml

The trigger section describes when the CI should run. In this example the build gets triggered on every change in master and on each new tag. Somewhat surprisingly if you push several commits at once the build gets triggered only on the latest commit of the batch.

The jobs section describes what should be done once the CI runs. There are two jobs described – one for Windows and second for Linux. If you use the Microsoft-hosted agents (build servers), you can choose one of their supplied virtual machines. Steps of each job are described in a separate file. It’s convenient to put these files in a separate directory.

Linux build

Building Qt applications on Linux is rather straightforward if you (can) use the Qt version from distro repositories.

    - checkout: self
    - script: |
        sudo apt install qt5-default qtdeclarative5-dev qtquickcontrols2-5-dev
      displayName: 'Install requirements'

    - script: |
      displayName: 'Build'
  1. Git checkout
  2. Install build requirements
  3. Run qmake
  4. Make

Easy. And even this simple script makes sure the program builds, which can save you a lot of trouble in the future.


The script for Windows is a bit more complicated for two reasons. First, building Qt applications on Windows takes more work and second, it also deploys the application and creates a setup binary on every new tag.

  - checkout: self
    submodules: true
  - task: UsePythonVersion@0
      versionSpec: '3.x'
  - script: |
      cd $(Build.SourcesDirectory)
      python -m pip install aqtinstall
    displayName: 'Install aqtinstall'
  # 1
  - script: |
      cd $(Build.SourcesDirectory)
      python -m aqt install --outputdir $(Build.BinariesDirectory)\\Qt 5.14.1 windows desktop win32_msvc2017 -m qtcore qtgui qtxml qtdeclarative qtquickcontrols2
      echo "Installing Qt to $(Build.SourcesDirectory)"
    displayName: 'Install Qt 5.14.1'
  # 2
  - script: |
      cd $(Build.SourcesDirectory)
      call "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Enterprise\\VC\\Auxiliary\\Build\\vcvars32.bat"
    displayName: 'Run qmake'
  # 3
  - script: |
      cd $(Build.SourcesDirectory)
      call "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Enterprise\\VC\\Auxiliary\\Build\\vcvars32.bat"
      nmake release
    displayName: 'Build!'

Since Windows still lacks repositories even in the year 1970+50, the script has to install Qt in some other way. There are several preinstalled tools in the virtual machine at our disposal depending on the chosen OS image.

A simple way to install Qt is to use “Another Qt Installer” (Aqt). This tool can be installed using preinstalled Python. Then, invoke aqt and use it to install the Qt toolkit itself. It’s necessary to setup the environment properly by using vcvars32.bat. During this whole process be careful to choose the matching compiler/environment (32/64bit).

This process so far gets triggered on every change in the repository master. Now, wouldn’t it be nice if the CI created a setup.exe on every tag? This can be described using conditions, in this case:

condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/')

Qt applications for Windows are deployed using the windeployqt.bat script. This script takes the release binary and copies all necessary files it needs to run to it’s directory.

  # 4
  - script: |
      cd $(Build.SourcesDirectory)
      call "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Enterprise\\VC\\Auxiliary\\Build\\vcvars32.bat"
      echo "Creating deploy directory"
      mkdir app\\deploy
      copy app\\release\\HL3.exe app\\deploy\\HL3.exe
      dir $(Build.BinariesDirectory)\\Qt\\5.14.1\\msvc2017\\bin
      echo "Running windeployqt"
      $(Build.BinariesDirectory)\\Qt\\5.14.1\\msvc2017\\bin\\windeployqt.exe --qmldir app\\src\\qml app\\deploy\\HL3.exe
    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/')
    displayName: 'Deploy'
  # 5
  - script: |
      cd $(Build.SourcesDirectory)
      set HL3DeployDir=$(Build.SourcesDirectory)\\app\\deploy
      for /f "usebackq tokens=*" %%a in (`git describe --exact-match --tags $(Build.SourceVersion)`) do set HL3Version=%%a
      iscc /Qp ci\\innosetup_script.iss"
      echo "Listing ci directory"
      dir ci
      echo "Listing ci/Output directory"
      dir "ci\\Output"
    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/')
    displayName: 'Run Innosetup'
  - task: PublishBuildArtifacts@1
      pathToPublish: $(Build.SourcesDirectory)\\ci\\Output\\HL3Setup.exe
      artifactName: Windows_release
    condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/')
    displayName: 'Publish Release Installer'

The installer itself is created using InnoSetup. It would be possible to use the Qt installer framework, but InnoSetup has nice extra functions like being able to choose the installer language manually.

InnoSetup needs a script of it’s own to work. This script can be modified from a provided template quite easily. The added magic here is that you can pass parameters to the script using environment variables.

For example to pass the build directory to InnoSetup, in the in the CI script, call:

set HL3DeployDir=$(Build.SourcesDirectory)\\app\\deploy

And then get this variable from InnoSetup using:

#define MyAppDeployDir GetEnv("HL3DeployDir")

Passing the application version from git tag requires a bit more magic:

for /f "usebackq tokens=*" %%a in (`git describe --exact-match --tags $(Build.SourceVersion)`) do set HL3Version=%%a

As the last step the script takes the resulting exe and copies it to Azure itself from the ephemeral virtual build machine. There you can download it later. What do you do with it next depends on the specific use case. It’s possible to use Azure functions to push it it a FTP server, or go the more sophisticated way and use continuous delivery using the Azure release pipelines.

Even though the whole process is not that complicated, it definitely takes some effort to describe it step by step so that it can work automatically. The worst part is that the only way to try that your pipeline actually works as expected is to push your commits and try it. This is a rather time demanding process. Some say it’s better to use only a skleleton yaml file and describe the build/deploy process in a script that can be more easily tested locally. This approach could save some development time.

Setting up CI is not as easy as flipping a switch somewhere but it’s worth it in the end. You get the assurance that your application always at least builds and spares you a lot of hassle with releases.

2 thoughts on “Using Azure CI for cross-platform Linux and Windows Qt application builds

  1. Thanks for your blog post. do you know how can I use aqtinstall to install qt but with qt commercial license? this is apparently not documented and I am a commercial license owner and I need to use it

Leave a Reply

Your email address will not be published. Required fields are marked *