To test a geospatial CLI across GDAL versions, define a GitHub Actions strategy.matrix over python-version and gdal-version, run each job inside the matching ghcr.io/osgeo/gdal container (or a conda-forge gdal=<ver> pin), install your package, run pytest, and upload coverage per job. Set fail-fast: false so one red combination does not cancel the rest. This page is part of the Packaging & CI/CD for Python GIS CLI Tools guide within the broader CLI Architecture & Design Patterns for Python GIS reference.
Prerequisites
- Python 3.10 or later, with your CLI installable via
pip install -e . - A test suite using
pytestandpytest-cov - Familiarity with GitHub Actions workflow YAML
- GDAL is provided by the container image or conda environment in CI — you do not install it with pip
The core problem is that GDAL is a C library, and its Python bindings (rasterio, pyproj, fiona/pyogrio) are compiled against a specific ABI. A test that passes against GDAL 3.4 can regress on GDAL 3.8 because a driver was renamed, a default changed, or axis-order handling shifted. A version matrix surfaces these regressions before your users do. For how these pins fit into your release story, see the parent Packaging & CI/CD for Python GIS CLI Tools guide.
How the Matrix Fans Out
Each cell in the matrix is an isolated job with its own GDAL container and Python interpreter. The diagram below shows how one workflow expands into a grid of jobs, with one combination excluded because no compatible build exists.
Complete Working Implementation
The workflow below runs your CLI test suite against three GDAL versions and two Python versions using the official OSGeo container images. It pins rasterio and pyproj to builds compatible with the container GDAL, runs pytest with coverage, and uploads a per-job coverage artifact. Save it as .github/workflows/gdal-matrix.yml:
name: gdal-matrix
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
# Run every job inside the exact GDAL the leg targets. The bindings
# already present in the image match this GDAL's ABI.
container:
image: ghcr.io/osgeo/gdal:ubuntu-small-$NaN
strategy:
# Do not cancel sibling jobs when one GDAL version regresses.
fail-fast: false
matrix:
python-version: ["3.10", "3.12"]
gdal-version: ["3.4.3", "3.6.4", "3.8.5"]
include:
# Add one extra leg pinning the newest GDAL without expanding
# the full cross product.
- python-version: "3.12"
gdal-version: "3.9.2"
exclude:
# GDAL 3.4 image ships no interpreter compatible with 3.12.
- python-version: "3.12"
gdal-version: "3.4.3"
name: py$NaN · gdal$NaN
steps:
- uses: actions/checkout@v4
- name: Report the resolved GDAL version
run: |
gdalinfo --version
echo "GDAL_VERSION=$(gdal-config --version)" >> "$GITHUB_ENV"
- name: Install test dependencies and the package
run: |
python3 -m pip install --upgrade pip
# Pin bindings to the container GDAL so pip does not pull a
# wheel built against a different ABI (GDAL_VERSION set above).
case "$GDAL_VERSION" in
3.8*|3.9*) RASTERIO=1.3.11 ;;
*) RASTERIO=1.3.9 ;;
esac
python3 -m pip install \
"rasterio==${RASTERIO}" \
"pyproj>=3.6,<3.8" \
pytest pytest-cov
python3 -m pip install -e . --no-build-isolation
- name: Run the CLI test suite with coverage
run: |
pytest -q \
--cov=geocli --cov-report=xml:coverage.xml \
--junitxml=results.xml
- name: Upload coverage for this matrix leg
uses: actions/upload-artifact@v4
with:
name: coverage-py$NaN-gdal$NaN
path: coverage.xml
if-no-files-found: error
The reprojection assertion the matrix is meant to protect lives in an ordinary pytest file. This test proves that a point transformed from EPSG:4326 to EPSG:3857 lands where Web Mercator expects it, and it is stable across GDAL point releases because it rounds the result:
# tests/test_reproject.py
import math
import pytest
from pyproj import CRS, Transformer
def reproject_point(lon: float, lat: float) -> tuple[float, float]:
"""Reproject a lon/lat point to Web Mercator (EPSG:3857).
always_xy=True forces longitude-first input regardless of the
authority axis order, so this call is stable across GDAL/PROJ versions.
"""
transformer = Transformer.from_crs(
CRS.from_epsg(4326),
CRS.from_epsg(3857),
always_xy=True,
)
return transformer.transform(lon, lat)
def test_reprojection_to_web_mercator():
# Null Island's neighbour: 1 degree east, on the equator.
easting, northing = reproject_point(1.0, 0.0)
# 1 degree of longitude at the equator in EPSG:3857.
assert math.isclose(easting, 111319.49, abs_tol=0.5)
# The equator maps to northing 0 in Web Mercator.
assert math.isclose(northing, 0.0, abs_tol=1e-6)
@pytest.mark.parametrize("epsg", [3857, 32633])
def test_target_crs_is_projected(epsg):
# Guard against a CRS regression: both targets must be projected,
# never geographic, or downstream pixel maths breaks.
assert CRS.from_epsg(epsg).is_projected
Step Annotations
-
container.imageinterpolatesmatrix.gdal-version— Running the whole job insideghcr.io/osgeo/gdal:ubuntu-small-<ver>means the GDAL C library,gdal-config, and the bundled Python bindings all match the version under test. There is no separate GDAL install step and no risk of a cached system GDAL shadowing it. -
fail-fast: false— By default GitHub Actions cancels every sibling job the moment one fails. For a compatibility matrix that is exactly the wrong behaviour: you want to see that GDAL 3.8 is the only red leg, not have the run aborted before GDAL 3.4 and 3.6 finish. Set it tofalse. -
include:adds a single extra leg — Theincludeblock appends onepython-version: 3.12/gdal-version: 3.9.2job without multiplying it across every Python version. Use it to smoke-test a bleeding-edge GDAL on your newest interpreter only. -
exclude:drops an impossible combination — The GDAL 3.4 image predates Python 3.12, so that cell can never install. Naming the exact pair underexcludekeeps the matrix green instead of carrying a permanently red job that trains reviewers to ignore failures. -
gdalinfo --versionbefore anything else — Printing the resolved version at the top of the log is your proof that the container pin took effect. If a step silently fell back to a cached GDAL, this line makes it obvious instead of letting a stale binding pass the suite. -
Pinning
rasterioandpyprojto the container GDAL — pip wheels forrasteriobundle their own GDAL, which can override the container’s. Installing arasterioversion built for the container GDAL, or building with--no-build-isolationagainst the system GDAL, keeps the ABI consistent. This is the single most common source of matrix flakiness. -
always_xy=Truein the transformer — This forces longitude-first input and output regardless of the CRS authority axis order, making the reprojection deterministic across PROJ and GDAL point releases. The named gotcha below explains why this matters.
Named Gotcha: Axis Order Changes Between GDAL Versions
The single most common cross-version failure is authority-compliant axis order. Since GDAL 3 and PROJ 6, coordinate transformations honour the axis order declared by the CRS authority, so EPSG:4326 is latitude-then-longitude, not the longitude-first convention many older scripts assume. A test that hardcodes transformer.transform(lon, lat) against a Transformer built without always_xy=True can silently swap coordinates, and the exact behaviour drifts as pyproj and its bundled PROJ move between GDAL 3.4, 3.6, and 3.8. Driver defaults shift too — for example, creation-option defaults and NULL-handling in some drivers changed across these releases.
The fix is twofold. First, always build transformers with always_xy=True so your CLI’s coordinate order is explicit and version-independent, as shown in the test above. Second, pin pyproj and rasterio to versions built against the GDAL in each matrix leg, rather than letting pip resolve whatever wheel it prefers. When your CLI reads pins or driver preferences from configuration, keep them consistent across environments using the Environment Variable Sync pattern so local runs and CI resolve the same GDAL behaviour. For exercising the command entry points themselves, drive them with Testing Click Commands with CliRunner for GIS Tools.
Verification
A correctly configured matrix shows one green check per surviving combination on the pull request, and each job log names the GDAL it used. Confirm the pins locally before pushing:
# Confirm the container GDAL and the binding GDAL agree.
gdalinfo --version
python3 -c "import rasterio; print('rasterio sees GDAL', rasterio.gdal_version())"
python3 -c "import pyproj; print('PROJ', pyproj.proj_version_str)"
# Run only the reprojection guard, quietly.
pytest tests/test_reproject.py -q
If gdalinfo --version and rasterio.gdal_version() disagree, a mismatched wheel slipped in and the leg is testing the wrong GDAL. When both report the same version and pytest exits 0, the matrix leg is trustworthy. A non-zero exit from pytest (exit code 1) on a single GDAL version, with siblings green, is the exact regression signal this workflow exists to produce.
Troubleshooting
| Symptom | Root Cause | Fix |
|---|---|---|
rasterio.gdal_version() differs from gdalinfo --version |
pip pulled a wheel bundling its own GDAL | Pin rasterio to the container GDAL or install --no-build-isolation |
| Reprojection off by swapped coordinates | Authority axis order (lat/lon vs lon/lat) | Build Transformer with always_xy=True |
| Whole run cancels on first red leg | fail-fast defaults to true |
Set fail-fast: false under strategy |
| One matrix cell can never install | GDAL image predates the Python version | Add the pair to exclude: |
PROJ: proj_create: cannot find proj.db |
PROJ_DATA unset in the container |
Export PROJ_DATA=/usr/share/proj before pytest |
FAQ
Should I test against GDAL container images or conda-forge builds?
Use the official ghcr.io/osgeo/gdal container images when you want the exact C library and system bindings the OSGeo project ships, and they start fast in CI. Use conda-forge gdal=<ver> pins when your users install via conda or when you need a GDAL version not published as an image. Many projects run both as separate matrix legs to cover both installation paths.
Why does my reprojection test pass on GDAL 3.4 but fail on GDAL 3.8?
The most common cause is authority-compliant axis order. Since PROJ 6 and GDAL 3, transformations respect the CRS axis order, so EPSG:4326 is latitude then longitude. If your test hardcodes longitude-first inputs it can drift across GDAL and PROJ point releases. Pin pyproj and rasterio to versions built against the container GDAL and assert on rounded coordinates rather than exact floats.
How do I skip a known-incompatible GDAL and Python combination?
Add an exclude entry under strategy.matrix that names the exact python-version and gdal-version pair to drop, or use include to add a single extra leg without expanding the full cross product. This keeps the matrix green when a specific combination has no compatible wheel or container image.
How do I confirm each job actually used the GDAL version I intended?
Add a step that runs gdalinfo --version and python -c to print rasterio.gdal_version() before pytest. Printing the resolved version in the log proves the container or conda pin took effect and makes a silent fallback to a cached GDAL obvious in the job output.
Related
- Packaging & CI/CD for Python GIS CLI Tools — parent guide covering wheels, container builds, and release automation for geospatial command-line tools
- Building a Docker Image with GDAL for a Python CLI — package the same pinned GDAL you test against into a reproducible runtime image