Continuous Integration for QGIS Projects
Set up CI for QGIS plugins and automation — containerised runners, headless test execution with Xvfb, matrix testing across QGIS versions, linting, and…
A QGIS plugin that passes every manual test today can break silently the moment a user upgrades QGIS. A renamed enum, a removed QgsMapLayerRegistry shim, a signal signature change in Qt — none of these surface until someone loads your plugin against a version you never tried. The only durable defence is automated tests that run on every push, against every QGIS release you claim to support. This page, part of the Headless Automation, CI/CD & Testing for PyQGIS guide, shows how to build that pipeline: a containerised runner, headless test execution, a QGIS version matrix, linting, and an automated release step that ships a tested artefact to the plugin repository.
The hard part of QGIS CI is not the test logic — it is getting QGIS to initialise at all on a machine with no screen, no window manager, and a QGIS build that must exactly match what your users run. Get the runner environment right and the rest is ordinary continuous integration.
Prerequisites Checklist
Before wiring up a pipeline, confirm you have the following in place:
- A containerised QGIS image (or a reproducible apt install): the official
qgis/qgisDocker image ships a complete QGIS + Qt + Python stack tagged per release. Prefer it over installing QGIS on the raw runner, which pins you to whatever version the runner image happens to carry. pytest-qgisinstalled in the runner: this plugin bootstraps a realQgsApplicationonce per test session and exposes aqgis_appandqgis_ifacefixture, so your tests exercise genuine PyQGIS objects. See Testing PyQGIS with pytest-qgis for the fixtures and assertions.- A plugin or automation repository with a test suite: at least one
test_*.pymodule that importsqgis.core. If you have no tests yet, start with Writing Your First pytest-qgis Test before adding CI. - A headless display strategy: either
QT_QPA_PLATFORM=offscreen(simplest) or an Xvfb virtual display (needed when code paths call into the real X rendering stack). Both are covered below and in Running Headless QGIS with Xvfb in Docker. - QGIS 3.28+ as the floor: target the current LTR lines. Pin exact image tags rather than
latestso a build never silently changes QGIS versions underneath you.
CI is a runtime harness, not a substitute for a well-structured plugin. It rewards code that separates pure logic from iface-dependent UI, because pure logic tests run faster and flake less. The Mocking the QGIS iface Object in Unit Tests companion page covers that separation.
How Headless QGIS CI Works
When Python executes import qgis.core, it loads the QGIS C++ libraries, which in turn load Qt. Qt refuses to start without a platform plugin — an abstraction over the windowing system. On a developer laptop that plugin is xcb (X11) or wayland, backed by a real display. A CI runner has neither, so the naive import aborts with qt.qpa.plugin: could not load the Qt platform plugin "xcb" and the job dies before a single test runs.
There are two ways to satisfy Qt without a monitor:
QT_QPA_PLATFORM=offscreen— tells Qt to use its built-in headless platform plugin, which renders to an off-screen buffer. This is the lightest option and works for the large majority of PyQGIS tests. Set it as an environment variable before the process starts.- Xvfb (X Virtual Framebuffer) — a real X server that draws into memory instead of a screen. Some code paths (certain print-layout exports, OpenGL-backed rendering, a few third-party Qt widgets) misbehave under the offscreen plugin and need a genuine X server. You start it with
xvfb-runor launchXvfb :99and exportDISPLAY=:99.
The runner base matters just as much as the display. Building CI on the official qgis/qgis Docker image means every job gets the exact QGIS, Qt, GDAL, and Python versions baked into that tag. Pin the tag — qgis/qgis:release-3_28, qgis/qgis:release-3_34, qgis/qgis:latest — and a build is reproducible months later. This is the foundation for matrix testing: run the identical suite across several pinned tags in parallel and you learn immediately which QGIS releases your code supports. Caching the Python dependency layer (pip wheels, or the whole image) keeps those parallel jobs fast; without it, each matrix leg re-downloads a multi-hundred-megabyte image on every push.
The diagram below shows the stages a commit flows through, with the test stage fanning out across the version matrix.
Step-by-Step Implementation
The following builds a complete pipeline in GitHub Actions. The mechanics — a job running inside the qgis/qgis container, a matrix over version tags, a separate lint job — carry over to GitLab CI, Azure Pipelines, or any runner that can execute a Docker image.
Step 1 — Make the Test Suite Runnable Headless
Before touching CI, guarantee the suite runs with no display. The cleanest place to force the offscreen platform is a conftest.py at the repository root, so it applies whether tests run locally or in CI.
"""conftest.py — force headless Qt before QGIS is imported anywhere."""
import os
def pytest_configure(config: "object") -> None:
"""
Force the Qt offscreen platform plugin before any test imports qgis.core.
Runs during pytest startup, ahead of collection, so QgsApplication never
attempts to reach a display server on a headless CI runner.
"""
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# Silence Qt's font-config warnings that otherwise clutter CI logs
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.fonts=false")
With pytest-qgis installed, this is enough for pytest to bootstrap a real QgsApplication in the container. Verify locally first:
QT_QPA_PLATFORM=offscreen pytest -q
If that passes on your machine, it will pass in the container.
Step 2 — Add a Lint Job
Linting is fast, needs no QGIS, and catches style and obvious-error regressions before the expensive test matrix runs. Keep it as a separate job so a formatting slip fails in seconds rather than after a full test cycle.
# .github/workflows/ci.yml (lint job)
name: CI
on:
push:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install lint tooling
run: pip install flake8 black
- name: Check formatting
run: black --check .
- name: Lint
run: flake8 . --max-line-length=100
Step 3 — Run the Test Matrix Inside the QGIS Container
The test job runs inside the qgis/qgis image via the container: key, so the runner already has QGIS, Qt, and the PyQGIS bindings. The matrix.qgis values are the image tags; each becomes a parallel job. fail-fast: false ensures one failing version does not cancel the others — you want the full picture of which releases pass.
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
qgis:
- release-3_28 # oldest supported LTR
- release-3_34 # current LTR
- latest # newest stable
container:
image: qgis/qgis:${{ matrix.qgis }}
env:
QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
# The container already ships QGIS + PyQGIS; add only the test tooling.
run: pip3 install --break-system-packages pytest pytest-qgis
- name: Run the suite
run: xvfb-run -a pytest -v --junitxml=report-${{ matrix.qgis }}.xml
- name: Publish test report
if: always()
uses: actions/upload-artifact@v4
with:
name: pytest-${{ matrix.qgis }}
path: report-${{ matrix.qgis }}.xml
Two details earn their place. xvfb-run -a pytest wraps the run in an ephemeral X server — belt-and-braces on top of the offscreen plugin, so any code path that insists on a real display still works. The --break-system-packages flag is needed because recent QGIS images ship a PEP 668 “externally managed” Python; without it pip3 install refuses to run.
Step 4 — Package and Release on a Tag
The release job runs only for tag pushes. It compiles the Qt resource file, zips the plugin into the layout the repository expects, and uploads it. Real credentials never live in the workflow — they come from encrypted repository secrets injected as environment variables.
release:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
container:
image: qgis/qgis:release-3_34
steps:
- uses: actions/checkout@v4
- name: Compile resources and package
run: |
pip3 install --break-system-packages pyqt5ac
# compile resources.qrc -> resources.py if the plugin uses one
if [ -f resources.qrc ]; then pyrcc5 -o resources.py resources.qrc; fi
PLUGIN=my_qgis_plugin
mkdir -p dist/$PLUGIN
rsync -a --exclude='.git' --exclude='tests' --exclude='dist' ./ dist/$PLUGIN/
(cd dist && zip -r $PLUGIN.zip $PLUGIN)
- name: Upload to the QGIS Plugin Repository
env:
OSGEO_USER: ${{ secrets.OSGEO_USER }}
OSGEO_PASSWORD: ${{ secrets.OSGEO_PASSWORD }}
run: pip3 install --break-system-packages qgis-plugin-ci && qgis-plugin-ci release ${GITHUB_REF_NAME} --osgeo-username "$OSGEO_USER" --osgeo-password "$OSGEO_PASSWORD"
The overall shape — lint gate, matrixed tests, tag-gated release — is the reusable skeleton. Everything else is your plugin’s specifics.
Advanced Patterns
GitHub Actions Specifics for QGIS
Running the whole job inside container: is the pattern that makes QGIS CI tractable on GitHub Actions: the checkout, dependency install, and test all execute in the QGIS image’s filesystem, so there is no mismatch between the QGIS that tests import and the one the runner provides. Beyond that, cache the pip layer with actions/cache keyed on your requirements.txt hash, gate the test job on needs: lint so cheap checks fail first, and always upload the JUnit XML with if: always() so you can inspect failures even when the job is red. The dedicated Running QGIS Plugin Tests in GitHub Actions companion page walks through the full workflow file, secret configuration, and status-badge setup line by line.
Matrix Testing Across LTR and Latest
The version matrix is the single highest-value part of a QGIS pipeline. QGIS ships two long-term releases in overlapping support windows plus a rolling latest, and the PyQGIS API changes between them — enums get renamed, methods are deprecated then removed, and default behaviours shift. Declaring matrix.qgis with both current LTR tags and latest runs your suite against each in parallel, so an API removal in the newest release shows up as a red matrix leg on the pull request that introduced the incompatibility, not as a bug report weeks later. Keep the oldest LTR in the matrix for as long as you advertise support for it, and add the next LTR tag the day it is released. Use Qgis.QGIS_VERSION_INT guards in code that must branch on version, and let the matrix prove both branches work.
Automated Packaging and Release to the Plugin Repository
Once tests are green across the matrix, the release step should be fully hands-off. Gate it on a version tag so only deliberate releases publish, compile any Qt resource files, and assemble the exact directory-inside-a-zip layout the repository ingests. Tools such as qgis-plugin-ci read your metadata.txt, build the archive, and push it to the OSGeo plugin server in one command. Store the OSGeo credentials as encrypted secrets and inject them only into the release job — never the test jobs, which run on every push including from forks. The full submission workflow, metadata.txt requirements, and approval process are covered in Publishing a Plugin to the QGIS Plugin Repository under the packaging and distributing QGIS plugins guide.
Pitfalls and Debugging
-
Flaky headless rendering: tests that pass locally but fail intermittently in CI usually touch the render stack — map canvas snapshots, layout exports, symbol rendering. The offscreen plugin and a real display can produce subtly different pixels. Root cause is comparing exact rasters. Fix by asserting on geometry, feature counts, or file existence rather than pixel-perfect image diffs; if you must compare images, allow a tolerance and wrap the job in
xvfb-runfor a genuine X server. -
Missing Xvfb or platform plugin: the error
could not load the Qt platform plugin "xcb"orcould not connect to displaymeans Qt found no surface. Root cause is a job that neither setsQT_QPA_PLATFORM=offscreennor runs underxvfb-run. Fix by setting the env var at job level and installingxvfbif you rely onxvfb-run(theqgis/qgisimage includes it; slimmer bases may not). -
Not pinning the QGIS version: using
qgis/qgis:latestas the only tag means a base-image update can turn a passing pipeline red overnight with no code change, or worse, hide a regression against the LTR your users actually run. Fix by pinning explicitrelease-3_28/release-3_34tags in the matrix and treatinglatestas an early-warning signal, not your only coverage. -
Leaking or mishandling release secrets: putting OSGeo credentials in the workflow YAML, or exposing them to test jobs that run on fork pull requests, risks credential theft. Root cause is over-broad secret scope. Fix by storing them as encrypted repository secrets, injecting them only into the tag-gated release job, and never echoing them in logs.
-
Slow images without caching: each matrix leg pulls a large QGIS image and reinstalls dependencies, so an uncached pipeline can take many minutes per push. Root cause is no layer or dependency caching. Fix by caching the pip directory with
actions/cacheand, on self-hosted runners, keeping pulled images warm; scope the matrix to the versions you genuinely support rather than every release. -
Import order defeats the offscreen setting: if any module imports
qgis.corebefore the environment variable is set, Qt initialises against the wrong platform and later fixes have no effect. Fix by settingQT_QPA_PLATFORMinpytest_configure(as in Step 1) or at the job level so it is present before Python starts, never inside a test body.
Conclusion
Continuous integration turns “it worked when I tested it” into “it works on every QGIS release we support, proven on every push.” The pieces are specific but small in number: a pinned qgis/qgis container so runs are reproducible, a headless display via QT_QPA_PLATFORM=offscreen or Xvfb so QGIS initialises without a screen, pytest-qgis for real PyQGIS fixtures, a version matrix that fans the suite across LTR and latest, a cheap lint gate in front, and a tag-gated release that ships only tested artefacts. Pin your versions, keep secrets out of test jobs, and assert on data rather than pixels, and your plugin will stop breaking silently across QGIS upgrades.
Related
- Headless Automation, CI/CD & Testing for PyQGIS — parent guide covering the full automation and testing stack
- Running QGIS Plugin Tests in GitHub Actions — the complete GitHub Actions workflow, step by step
- Containerizing QGIS with Docker — building and using the qgis/qgis runner base your CI depends on
- Testing PyQGIS with pytest-qgis — the fixtures and assertions the CI pipeline executes
- Publishing a Plugin to the QGIS Plugin Repository — the release target the automated packaging step uploads to