Qt Application Builds on GitHub Actions
Build Qt 6 apps on GitHub Actions with a three-platform matrix, a cached Qt install and ccache per leg, and warp- runner sizes priced per minute.
Last verified:
A Qt application builds on GitHub Actions by installing a pinned Qt version on each target platform, caching that install alongside a compiler cache, and running the deployment tool that belongs to the platform. On WarpBuild that is a three-leg matrix on warp-ubuntu-latest-x64-16x, warp-windows-latest-x64-16x, and warp-macos-26-arm64-6x, each billed per minute.
This page covers the Qt install and cache strategy per platform with the module selection, a matrix workflow with the packaging step for each leg, sizing for compile-heavy Qt builds with the rate for every label applied, and the list-price arithmetic against GitHub-hosted runners of the same shape.
Overview
A desktop release matrix runs on one account with one billing model. Moving an existing Qt workflow across is a change to the runs-on line in each leg plus the cache steps.
A Qt build spends its minutes in five places. The Qt install downloads and unpacks before anything compiles. Then moc, uic, and rcc generate C++ from headers carrying Q_OBJECT, from .ui files, and from resource files. Then the compiler works through the generated sources plus your own translation units. Then the linker pulls in the Qt libraries. Then the deployment tool walks the binary, resolves the Qt libraries and plugins it needs, and writes an installable artifact.
The middle three phases behave like any C++ build, and the C++ builds page covers that half in depth. The first and last phases are what make Qt its own shape: the install is large and repeats every run without a cache, and the deployment tool is different on every platform.
That is also why the matrix has three legs rather than one. Qt ships prebuilt binaries per platform and per compiler, identified by an arch string, so the Linux leg pulls linux_gcc_64, the Windows leg pulls win64_msvc2022_64, and the macOS leg pulls clang_64. The default warp-windows-latest-x64-* labels carry Visual Studio 2022, which is the toolchain the msvc2022_64 binaries expect. The transitional warp-windows-2025-vs2026-x64-* labels carry Visual Studio 2026 instead, so pick the label that matches the Qt arch string rather than the newest one available.
Qt install and cache strategy per platform
| Platform | Runner label | Qt arch string | Qt install cache | Compiler cache | Extra packages the job installs |
|---|---|---|---|---|---|
| Linux | warp-ubuntu-latest-x64-16x | linux_gcc_64 | install-qt-action with cache: true | WarpBuilds/cache over CCACHE_DIR | libgl1-mesa-dev, libxkbcommon-dev, libxcb-cursor0, ninja-build, ccache |
| Windows | warp-windows-latest-x64-16x | win64_msvc2022_64 | install-qt-action with cache: true | actions/cache over the ccache directory | ninja and ccache through the preinstalled package manager |
| macOS | warp-macos-26-arm64-6x | clang_64 | install-qt-action with cache: true | WarpBuilds/cache over CCACHE_DIR | ninja and ccache through Homebrew |
Two constraints set that layout. The caching documentation states that WarpBuild Cache is not supported on Windows runners, so the Windows leg falls back to actions/cache. The same documentation explains that a cache entry is versioned by a hash of the paths and the compression tool, so an entry written on a macOS runner cannot restore on a Linux runner regardless of the key. Both facts point the same way: one cache step and one key per leg.
Module selection is the other half of the strategy. The base install carries Qt Base, and everything else downloads as a named module: qtcharts, qtmultimedia, qtquick3d, qtshadertools, qtserialport, qtimageformats, and qt5compat for the classes that moved out of the default set in Qt 6. qtwebengine is the outlier, since it carries a Chromium build and needs qtwebchannel and qtpositioning alongside it, and it dominates both the download and the disk footprint of the install. Ask for the modules the project actually links against and no more, and put that module list in the cache key. An install restored under a key that predates a module addition looks healthy until find_package(Qt6 COMPONENTS ...) fails partway through the configure step.
The runner images carry the same tooling as GitHub-hosted runners, which covers the MSVC toolchain on Windows and the Xcode command line tools on macOS. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, with a dedicated macOS 27 image to follow once that image is released, so set DEVELOPER_DIR explicitly if the build depends on a specific SDK. The full catalog is in the cloud runner documentation.
Configuration
The workflow below builds, tests, and packages a Qt 6 application on all three platforms from one matrix. Each leg installs Qt with the same module list, restores a compiler cache with the action its platform supports, configures CMake with Ninja, runs the test suite headless, and produces the platform's artifact.
name: qt-release
on:
push:
branches: [main]
pull_request:
env:
QT_VERSION: 6.8.2
QT_MODULES: qtcharts qtmultimedia qtshadertools qtimageformats
CCACHE_MAXSIZE: 2G
jobs:
build:
name: build-${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
runner: warp-ubuntu-latest-x64-16x
host: linux
arch: linux_gcc_64
- platform: windows
runner: warp-windows-latest-x64-16x
host: windows
arch: win64_msvc2022_64
- platform: macos
runner: warp-macos-26-arm64-6x
host: mac
arch: clang_64
steps:
- uses: actions/checkout@v4
- name: Install Linux build and runtime dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build ccache libgl1-mesa-dev \
libxkbcommon-dev libxcb-cursor0 libvulkan-dev
- name: Install macOS build dependencies
if: matrix.platform == 'macos'
run: brew install ninja ccache
- name: Install Windows build dependencies
if: matrix.platform == 'windows'
run: choco install ninja ccache --no-progress -y
- uses: ilammy/msvc-dev-cmd@v1
if: matrix.platform == 'windows'
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: ${{ matrix.host }}
target: desktop
arch: ${{ matrix.arch }}
modules: ${{ env.QT_MODULES }}
dir: ${{ github.workspace }}/qt
cache: true
cache-key-prefix: qt-${{ matrix.platform }}-${{ env.QT_VERSION }}
- name: Restore compiler cache (Linux and macOS)
if: matrix.platform != 'windows'
id: ccache-unix
uses: WarpBuilds/cache/restore@v1
with:
path: ${{ github.workspace }}/.ccache
key: ${{ matrix.platform }}-ccache-${{ github.sha }}
restore-keys: |
${{ matrix.platform }}-ccache-
- name: Restore compiler cache (Windows)
if: matrix.platform == 'windows'
uses: actions/cache@v4
with:
path: ${{ github.workspace }}\.ccache
key: windows-ccache-${{ github.sha }}
restore-keys: |
windows-ccache-
- name: Configure
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
run: >
cmake -S . -B build -G Ninja
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_PREFIX_PATH=$QT_ROOT_DIR
-DCMAKE_C_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
-DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded
- name: Build
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
run: cmake --build build --parallel
- name: Test
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build --output-on-failure --parallel 4
- name: Package AppImage
if: matrix.platform == 'linux'
run: |
curl -sSLO https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
curl -sSLO https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
chmod +x linuxdeploy*.AppImage
./linuxdeploy-x86_64.AppImage --appdir AppDir \
--executable build/myapp --desktop-file packaging/myapp.desktop \
--icon-file packaging/myapp.png --plugin qt --output appimage
- name: Package Windows directory
if: matrix.platform == 'windows'
run: |
cmake --install build --prefix dist
& "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations dist\myapp.exe
- name: Package macOS bundle
if: matrix.platform == 'macos'
run: |
cmake --install build --prefix dist
"$QT_ROOT_DIR/bin/macdeployqt" dist/myapp.app -dmg
- name: Save compiler cache (Linux and macOS)
if: matrix.platform != 'windows' && github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: ${{ github.workspace }}/.ccache
key: ${{ steps.ccache-unix.outputs.cache-primary-key }}
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-package
if-no-files-found: error
path: |
*.AppImage
dist/**Five details in that file carry weight.
QT_ROOT_DIR is what ties the install to CMake. The install action exports it, and passing it as CMAKE_PREFIX_PATH is what lets find_package(Qt6 ...) locate the install placed under ${{ github.workspace }}/qt. The same variable gives the packaging steps the path to windeployqt and macdeployqt, which live in the Qt bin directory rather than on PATH.
CMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded is what makes ccache work on the Windows leg. MSVC writes debug information into a shared PDB by default, which a compiler cache cannot reproduce from a hash, so cache hits stay at zero until the build switches to embedded debug info. The flag is ignored on the other two legs, which is why it can sit in the shared configure command.
fail-fast: false keeps one platform from cancelling the others. A Windows packaging failure otherwise kills a macOS leg that has already paid for its compile.
The save step runs only on main. Pull request branches miss the exact key, fall back to the ${{ matrix.platform }}-ccache- prefix, and restore the newest main-branch entry. That keeps one authoritative cache per platform instead of one per run. Entries expire after 7 days without use.
QT_QPA_PLATFORM=offscreen is what lets widget and Quick tests run without a display server. Without it a QApplication constructor aborts on a headless runner, and the failure reads as a plugin error rather than a missing display.
Third-party dependencies deserve their own cache step. A Qt application that pulls libraries through vcpkg or Conan should persist those trees keyed on the manifest lockfile, which the vcpkg and Conan caching guide covers in full.
Sizing
Catalog rows and per-minute rates for the labels in the matrix, plus the sizes above and below them, from the cloud runner catalog and the pricing page:
| Runner label | OS | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 |
| warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 |
| warp-macos-26-arm64-6x | macOS 26 | 6 | 22 GB | 120GB SSD | $0.08 |
| warp-macos-26-arm64-12x | macOS 26 | 12 | 44 GB | 270GB SSD | $0.16 |
Compilation is the phase that scales with cores, so size the Linux and Windows legs for it. Every Linux and Windows size carries 4 GB of memory per vCPU, which means cmake --build build --parallel with the default job count budgets about 4 GB per compile process. That covers ordinary Qt translation units and the generated moc sources comfortably, and it covers template-dense headers adequately.
The matrix above runs both legs at 16 vCPU because a compile-heavy Qt tree, meaning one with generated moc sources across a thousand-plus translation units or a QML-heavy Quick application with qtquick3d, keeps that many cores fed on a cold cache. A smaller application should start at 8 vCPU and measure before paying for 16. With a few hundred translation units and a warm compiler cache, 16 cores rarely stay busy, because a warm ccache turns most of the compile into hash lookups and the run becomes dominated by the link and the deployment walk. Move up when the CPU chart shows every core busy through compilation rather than in a short burst.
The macOS leg is sized differently. warp-macos-26-arm64-6x carries 22 GB across 6 vCPU, so a parallel build there budgets closer to 3.7 GB per process. That is enough for most Qt sources, and it is worth capping --parallel to 4 or 5 on a codebase with heavy template instantiation to keep headroom for the linker. Disk is the other macOS constraint: 120GB has to hold the Qt install, the build tree, the compiler cache, the installed prefix, and the dmg staging copy. warp-macos-26-arm64-12x carries 270GB when that gets tight.
Two refinements apply to any leg. Cap link parallelism separately with CMake job pools, -DCMAKE_JOB_POOLS=link=2 -DCMAKE_JOB_POOL_LINK=link, because linking a Qt binary with full debug information can consume several gigabytes per process while compilation never does. And keep light jobs off the large sizes: a clang-format check or a translation lint belongs on warp-ubuntu-latest-x64-2x at $0.004 per minute.
List prices against GitHub-hosted runners of the same shape
GitHub publishes per-minute list prices on the Actions minute multipliers reference and runner shapes on the GitHub-hosted runners reference. Both were checked on 2026-08-13.
warp-windows-latest-x64-16x (16 vCPU, 64 GB) costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner (16 vCPU, 64 GB): 22 percent lower list price. The 6 vCPU macOS labels, including warp-macos-26-arm64-6x, cost $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, with one more vCPU and 8 GB more memory. GitHub list prices checked on 2026-08-13.
Worked monthly model
Take a Qt desktop team whose release matrix runs 150 times a month across pull requests and tagged releases, at 14 minutes on the Linux leg, 18 minutes on the Windows leg, and 16 minutes on the macOS leg, with warm caches everywhere.
| Leg | Monthly minutes | WarpBuild rate | WarpBuild cost |
|---|---|---|---|
| Linux, 16 vCPU | 2,100 | $0.032 | $67.20 |
| Windows, 16 vCPU | 2,700 | $0.064 | $172.80 |
| macOS, 6 vCPU | 2,400 | $0.08 | $192.00 |
| Total | 7,200 | $432.00 |
On the two legs where GitHub publishes a same-shape list price, the same minutes come to $221.40 on the 16-core Windows larger runner and $244.80 on the largest GitHub-hosted macOS ARM64 runner, which is $466.20 against the $364.80 those two legs cost above: a difference of $101.40 a month at list prices. Cache metering adds a little on top, since storage is $0.20 per GB-month and each write or restore is $0.0001, so a 9 GB Qt and ccache footprint across the Linux and macOS legs with 4,000 operations a month comes to $2.20.
Full rates for every size and platform are on the pricing page.
Bottlenecks
The Qt install repeating every run. Runners are ephemeral virtual machines, so without the install cache every job downloads and unpacks Qt again before a single source file compiles, on all three legs. The fix is cache: true on the install action with a key that names the version, host, arch, and module list. Trim the module list at the same time, since qtwebengine alone can outweigh everything else in the install.
Cold compiler cache and moc churn. A touched header with Q_OBJECT re-runs moc and invalidates every translation unit that includes it, which on a widget-heavy codebase is most of them. A persisted CCACHE_DIR absorbs the repeat work. Two things quietly cost you the warm cache: a compiler upgrade, since ccache keys on the compiler, and flag drift between jobs, since a -O2 object never satisfies an -O3 request. Pin the toolchain and keep flags identical across jobs that share a key. The ccache guide covers the tuning in detail.
The deployment walk. windeployqt, macdeployqt, and the linuxdeploy Qt plugin each scan the binary, resolve every Qt library and plugin it references, and copy them into the artifact. That work is disk-bound and largely single threaded, so extra cores do nothing for it. Narrowing the deployed set helps instead: skip translations you do not ship, and exclude QML import directories the application never loads.
Headless GUI tests. Tests that construct a QApplication need a platform plugin. QT_QPA_PLATFORM=offscreen covers most widget and Quick test suites on Linux and works on the other legs too. Suites that require a real windowing system need xvfb-run on the Linux leg, and that is worth confirming before assuming a test failure is a code failure. The libxcb-cursor0 package belongs in the Linux install step for the same reason: the xcb platform plugin fails to load without it on recent Qt versions.
Windows without WarpBuild Cache. That leg falls back to actions/cache, which has its own size limits, so keep the cached paths narrow: the ccache directory and the Qt install rather than the whole workspace. The Windows runner page covers the label set and the Visual Studio pairings.
CI observability separates a CPU-bound compile from a deployment step stuck on disk, and the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is the shortest path to inspecting a half-written app bundle or reading ccache -s in place.
Proof
Public OSS repositories running warp- labels are citable evidence, and the check is to open the workflow file and read the runs-on line next to the dependency install. The bitcoin/bitcoin GitHub Actions workflow runs its per-commit build on warp-ubuntu-latest-x64-8x and installs qt6-base-dev, qt6-tools-dev, and qt6-l10n-tools alongside ccache, mold, cmake, and ninja-build in that job, which is the Qt 6 toolchain for the project's desktop GUI. The same file routes the rest of its Linux matrix across warp- labels sized per job: the Windows cross build on warp-ubuntu-latest-x64-4x, the sanitizer matrix on warp-ubuntu-latest-x64-8x with the ASan job pinned to warp-ubuntu-2404-x64-8x, the fuzz and MSan jobs on warp-ubuntu-latest-x64-16x, and the lint job on warp-ubuntu-latest-x64-2x (checked on 2026-08-13). The workflow file is open to read, cache keys and size choices included.
Every cost number on this page carries its source and a checked-on date, so the arithmetic is yours to re-derive. Rates for every size are on the pricing page.
Related reading: C++ builds on GitHub Actions for the compile and link half in depth, using ccache in GitHub Actions for compiler cache tuning, and vcpkg and Conan caching for the dependency tree a Qt application usually carries alongside Qt itself.
FAQ
How should a GitHub Actions workflow install and cache Qt?
Install a pinned Qt version per platform with install-qt-action and let it cache the install directory, then cache the compiler cache separately. Put the Qt version, host, arch string, and the full module list in the cache key, because an install restored under a stale key is missing the modules you added and find_package fails after the configure step.
What runner size should a Qt build start on?
Start at warp-ubuntu-latest-x64-8x at $0.016 per minute for the Linux leg, warp-windows-latest-x64-8x at $0.032 per minute for Windows, and warp-macos-26-arm64-6x at $0.08 per minute for macOS. Move the Linux and Windows legs to the 16 vCPU sizes when the CPU chart in WarpBuild's CI observability shows every core saturated through compilation.
Does a Qt release need one runner per platform?
Yes. Qt binaries are built per platform and per compiler, so the arch string differs on each leg, and the deployment tools are per platform as well: linuxdeploy for an AppImage, windeployqt for a Windows directory, and macdeployqt for a signed app bundle or dmg.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.