Reproducible Conda Environments for Spatial CI

Teams that resolve the spatial stack through conda-forge get the C libraries and their Python bindings solved together, which sidesteps the ABI mismatches that plague mixed apt-and-wheel installs — but only if the environment is locked. This guide sits beneath containerized GIS test runtimes and shows how to turn a conda environment.yml from a re-solving, drifting spec into a byte-reproducible lockfile that produces the same GDAL, GEOS and PROJ on every CI run and every developer machine. The failure this prevents is subtle: an environment.yml with loose version ranges re-solves on each run, so two builds a week apart can carry different GEOS builds and disagree on a topology verdict without any code change.

Why environment.yml alone is not reproducible

A plain environment.yml lists what you want, not what the solver chose. Each time conda resolves it, it picks the newest compatible builds from the channel at that moment, so the resolved GEOS or PROJ can change as conda-forge publishes updates. Reproducibility requires capturing the solved set — exact package builds, in dependency order, per platform — as a lockfile that CI installs verbatim rather than re-solving. That is what conda-lock produces.

Lock-strategy reference

Artifact Reproducible? Cross-platform? Use for
environment.yml (ranges) No — re-solves Yes Human-editable source of intent
conda list --explicit Yes, per platform No Single-OS pinning
conda-lock multi-platform lock Yes Yes CI across Linux/macOS runners
Built container image Yes Per image Fastest gate startup

Step-by-step implementation

The workflow targets conda-lock, conda-forge and a Linux CI runner, with the same lock reusable locally.

Step 1 — Declare intent with a pinned channel

Pin the channel to conda-forge only and disable channel mixing so a package never resolves from an unexpected source.

# environment.yml — source of intent, not the lock
name: gis-test
channels: [conda-forge]
channel_priority: strict
dependencies:
  - python=3.12
  - gdal=3.9.2
  - geos=3.12.1
  - proj=9.4.1
  - geopandas=0.14.*
  - shapely=2.0.*
  - pytest=7.*

Step 2 — Solve once and lock

pip install conda-lock
conda-lock lock -f environment.yml -p linux-64 -p osx-arm64
# produces conda-lock.yml with exact builds per platform — commit it

Step 3 — Install from the lock in CI

# .github/workflows/spatial.yml
      - uses: mamba-org/setup-micromamba@v1
        with:
          environment-file: conda-lock.yml     # install the lock, never re-solve
          environment-name: gis-test

Step 4 — Assert the solved stack at runtime

A session fixture fails fast if the environment CI actually built does not match the pinned versions — the same guard the containerized runtimes work uses for Docker images.

# conftest.py
import pytest, shapely, pyproj
from osgeo import gdal

@pytest.fixture(scope="session", autouse=True)
def assert_conda_stack():
    assert shapely.geos_version == (3, 12, 1)
    assert pyproj.proj_version_str.startswith("9.4.1")
    assert gdal.__version__.startswith("3.9.2")

Verification pattern

Confirm the lock installs identically by resolving the environment on a clean machine and diffing the explicit package list; an empty diff proves reproducibility.

micromamba create -n verify -f conda-lock.yml
micromamba run -n verify conda list --explicit | sha256sum
# The hash must match across machines and runs

Failure modes and edge cases

  1. Ranges instead of a lock. geos>=3.12 re-solves as conda-forge publishes builds; commit conda-lock.yml, not the loose environment.yml, for CI.
  2. Channel mixing. Without channel_priority: strict, a package can resolve from defaults with a different GEOS build than conda-forge; pin the channel.
  3. Platform-specific lock on the wrong runner. A linux-64 explicit lock fails on an osx-arm64 runner; use conda-lock’s multi-platform lock for mixed fleets.
  4. Stale lock after an intent change. Editing environment.yml without re-running conda-lock leaves CI installing the old solved set; regenerate the lock in the same commit.
  5. Micromamba vs conda solver drift. Solving with one tool and installing with another can differ; standardize on one resolver and lock with it.

Conclusion

Reproducible conda environments for spatial CI come from locking the solved stack, not the loose spec: pin the channel, solve once with conda-lock, install the lock verbatim in CI, and assert the versions at runtime. That turns conda-forge’s unified GDAL/GEOS/PROJ resolution into a deterministic gate runtime, free of the ABI mismatches that come from mixing package sources. For the alternative Docker-pinning path and the broader runtime picture, return to containerized GIS test runtimes.