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/qgis Docker 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-qgis installed in the runner: this plugin bootstraps a real QgsApplication once per test session and exposes a qgis_app and qgis_iface fixture, 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_*.py module that imports qgis.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 latest so 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:

  1. 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.
  2. 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-run or launch Xvfb :99 and export DISPLAY=: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.

Continuous integration pipeline for a QGIS plugin A left-to-right pipeline. A git push or pull request triggers a lint stage running flake8 and black, then a build stage that compiles resources, then a test stage that fans out into three parallel jobs against QGIS 3.28 LTR, QGIS 3.34 LTR and QGIS latest, which converge into a package stage that produces a zip, then a release stage that publishes to the QGIS Plugin Repository on a version tag. git push / PR lint flake8 / black build compile res. test: QGIS 3.28 LTR pytest-qgis test: QGIS 3.34 LTR pytest-qgis test: QGIS latest pytest-qgis package zip release tag → repository

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.

python
"""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:

bash
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.

yaml
# .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.

yaml
  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.

yaml
  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-run for a genuine X server.

  • Missing Xvfb or platform plugin: the error could not load the Qt platform plugin "xcb" or could not connect to display means Qt found no surface. Root cause is a job that neither sets QT_QPA_PLATFORM=offscreen nor runs under xvfb-run. Fix by setting the env var at job level and installing xvfb if you rely on xvfb-run (the qgis/qgis image includes it; slimmer bases may not).

  • Not pinning the QGIS version: using qgis/qgis:latest as 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 explicit release-3_28 / release-3_34 tags in the matrix and treating latest as 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/cache and, 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.core before the environment variable is set, Qt initialises against the wrong platform and later fixes have no effect. Fix by setting QT_QPA_PLATFORM in pytest_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