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

yaml
# .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
What has to line up for a gate to work A four-step chain: the workflow names each job deterministically, branch protection lists those names as required, the pull request cannot merge until they pass, and a renamed job breaks the link silently. job names deterministic branch protection lists them merge blocked until they pass rename a job gate silently gone referenced enforced beware

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.

One matrix entry failed — now what? A decision tree over three failure shapes: every entry failed, only the latest entry failed, and only one older entry failed. Which entries went red? all of them your change not a version issue only latest an upcoming change guard by version only an old LTR you used a new API guard or raise the floor

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.

Which matrix entries should block a merge A grid of three QGIS tracks showing whether each should be a required check, what a failure means, and what to do when it fails. blocks a merge? a failure means response older LTR yes existing users break fix before merging current LTR yes most users break fix before merging latest no a deprecation is coming open an issue

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

python
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 qgisMinimumVersion in 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.