Gating Merges on a QGIS Version Matrix
Turn a QGIS version matrix into a merge gate: deterministic job names for branch protection, fail-fast disabled so the failure pattern is visible, advisory…
TL;DR: make each matrix job’s name deterministic, mark the long-term-release entries as required status checks in branch protection, and leave the rolling latest entry advisory — so a deprecation warns you without blocking work, while a break on a version real users run cannot be merged. This page is part of the continuous integration for QGIS projects guide.
Complete Runnable Workflow
# .github/workflows/tests.yml
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
pytest:
# A stable, predictable name is what branch protection refers to.
name: pytest (QGIS $)
runs-on: ubuntu-latest
container:
image: qgis/qgis:$
continue-on-error: $
strategy:
fail-fast: false # one red entry must not hide the others
matrix:
include:
- qgis: release-3_28 # older LTR
advisory: false
- qgis: release-3_34 # current LTR
advisory: false
- qgis: latest # rolling release
advisory: true
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
run: |
python3 -m pip install --break-system-packages -r requirements-dev.txt
- name: Run the suite
env:
QT_QPA_PLATFORM: offscreen
run: |
xvfb-run -a python3 -m pytest -v --junitxml=report-$.xml
- name: Publish the report
if: always()
uses: actions/upload-artifact@v4
with:
name: report-$
path: report-$.xml
Architecture Breakdown
The job name is the contract
Branch protection refers to required checks by name. pytest (QGIS release-3_34) is a string in the repository settings, and nothing connects it to the workflow file beyond that string matching.
Rename the job — or change the matrix variable it interpolates — and the required check is no longer produced. The gate does not fail; it silently stops existing, and pull requests start merging without it. That failure mode is the reason to fix the names deliberately and to review branch protection whenever the matrix changes.
fail-fast: false is not optional
The default cancels the remaining matrix jobs as soon as one fails, which is exactly wrong for a version matrix. Knowing that a change breaks on the oldest LTR and passes everywhere else is different information from knowing it breaks everywhere, and the default throws that away.
The three patterns mean three different things. Everything red is your change. Only the latest entry red is an upcoming deprecation, which is information rather than a problem. Only an old entry red usually means you used an API that arrived later than your declared minimum version.
Advisory entries with continue-on-error
The rolling latest release should run and should not block. continue-on-error: true reports the result without failing the workflow, so the entry stays visible in the pull request without becoming a reason to stop.
The distinction is about who is affected. A break on an LTR affects people running QGIS today; a break on the rolling release affects people who will be running it in six months, and the right response is an issue rather than a blocked merge.
Keeping the Matrix Honest as QGIS Moves
QGIS ships a new long-term release roughly annually, and the matrix must move with it. Three mechanical steps keep it accurate:
Add the new LTR as an advisory entry as soon as it is released, so you learn about breakage before anyone depends on it. Promote it to required once it is the current LTR and the suite is green. Drop the oldest entry when your qgisMinimumVersion rises past it — and raise that value in metadata.txt in the same commit, so what the plugin claims and what is tested cannot drift apart.
Leaving an old entry in place long after you have stopped supporting it is a slow tax: every version-specific workaround stays in the code because something still tests it.
Writing Code That Passes on Several Versions
from qgis.core import Qgis
def use_versioned_api(layer):
"""Branch on the QGIS version rather than on a try/except around an import."""
if Qgis.QGIS_VERSION_INT >= 33400:
return layer.newApiMethod()
return layer.legacyMethod()
Qgis.QGIS_VERSION_INT is an integer — 33400 for 3.34 — which makes comparisons unambiguous. Branching on it is preferable to catching AttributeError, because the version test says what it means and a stray attribute error elsewhere cannot be mistaken for a version difference.
Every such branch should be exercised by the matrix. A guard tested on only one version is a guard you are trusting rather than checking.
What a Gate Is Actually Promising
A required check is a promise to everyone who installs the plugin: this code has been run against the QGIS you have. That framing is worth holding on to, because it decides the arguments about what belongs in the matrix.
An entry is worth its runner minutes if real users are on that version. It is not worth them because it is tidy to cover every release, or because the number of green ticks looks reassuring. The matrix is a statement about who you support, and it should match what qgisMinimumVersion claims and what your documentation promises.
The corollary is that a gate you routinely override is worse than no gate. Every merge that bypasses a red check teaches the team that red is negotiable, and the check stops carrying information long before anybody decides to remove it. If an entry is failing for reasons nobody intends to fix, demote it to advisory honestly rather than merging past it.
Production Best Practices
- Fix the job names, and review branch protection whenever the matrix changes.
- Set
fail-fast: false, so the pattern of failures is visible. - Make the LTR entries required and the rolling entry advisory.
- Upload the test report as an artifact, so a failure can be read without rerunning.
- Raise
qgisMinimumVersionin the same commit that drops a matrix entry. - Cover both sides of every version guard, or the guard is untested code.
Frequently Asked Questions
How many versions should the matrix cover?
Two long-term releases plus the rolling one is the shape most plugins settle on. It covers the overwhelming majority of installations, it keeps the job count small enough that the matrix finishes quickly, and it gives an early warning channel through the rolling entry.
Adding more mostly buys duplicate signal: two point releases of the same LTR series almost never differ in ways that break a plugin.
Why did the required check disappear from my pull request?
Because the job producing it was not created — a renamed job, a matrix entry removed, or a workflow that did not run because of a path filter. GitHub reports a required check as pending forever in some of those cases and simply omits it in others, and neither is obvious.
Whenever a pull request seems to be waiting on nothing, compare the check names in branch protection against the job names the workflow actually produced.
Should the matrix run on every push?
On pull requests, certainly. On every push to every branch it is usually more compute than the signal is worth — running on pull requests and on pushes to the default branch is a good balance, and it is what the workflow above does.
How do I test against a QGIS version with no container image?
Build one. A Dockerfile on a Debian base pinning the QGIS apt repository to a specific version gives you an image for anything packaged, and it is worth doing when a customer runs a version the official images no longer publish. The containerizing QGIS guide covers the build.
Can I skip the matrix for a documentation-only change?
Yes, with a path filter on the workflow — but be careful that the filter does not exclude a change that turns out to matter, and be aware that a required check which never runs blocks the merge rather than passing it. GitHub’s paths-ignore combined with required checks is a well-known source of stuck pull requests, and the usual remedy is a small job that always runs and reports success.
What about testing against different Python versions?
QGIS bundles its own Python, so the Python version is a property of the QGIS image rather than something to vary independently. Adding a Python dimension to the matrix mostly produces combinations that do not exist in the wild.
Related
- Continuous Integration for QGIS Projects — the parent guide covering the whole pipeline
- Running QGIS Plugin Tests in GitHub Actions — the workflow this page gates on
- Structuring metadata.txt for Distribution — where the declared minimum version lives