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.

Intent, solution, and installation are three different artefacts

The confusion that makes conda environments feel unreproducible comes from treating one file as all three things. An environment.yml states intent — what you want and from where. A solve turns that intent into a solution: exact builds, exact versions, for one platform. Installation then materialises that solution. Only the middle artefact is reproducible, and only if it is committed.

Intent, solution, installation — only the middle one is reproducible Three stages left to right. The environment file states intent: package names, channels and loose version ranges, with a note that re-solving it months later can legitimately produce a different answer because the channel has moved. The solve produces a lock file containing exact versions and build strings for a specific platform, marked as the only reproducible artefact and the one that must be committed. Installation reads the lock file and materialises an identical environment on any machine of that platform. Beneath, a warning path shows installing directly from the environment file in continuous integration, which performs a fresh solve on every run and therefore reproduces nothing. environment.yml intent: names, channels, ranges re-solving later may differ — legitimately the lock file exact versions + build strings the only reproducible artefact — commit it installation materialises the same environment on any machine of that platform solve install The common mistake: installing straight from environment.yml in CI. That performs a fresh solve on every run, so two runs a week apart can install different builds of GEOS from identical source. The distinction matters most for spatial stacks, because the packages that move are the compiled ones whose behaviour differs between builds.

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")

Why the platform matters more here than elsewhere

A lock file is per-platform, and that constraint has sharper consequences for spatial work than for ordinary Python. The compiled stack differs between a developer’s machine and the runner in ways that are invisible until a geometry result changes: different compilers, different optimisation flags, occasionally a different upstream patch level of GEOS carried by the same package version.

The workable arrangement is to solve for every platform the team actually uses and commit all of the resulting locks, then select by platform at install time. Solving only for the runner’s platform saves a moment and gives developers an environment nobody verified; solving only for the developer’s platform gives a gate running on something the lock never described.

Symptom Usual cause Fix
Works locally, geometry differs in CI Locks solved for one platform only Solve and commit a lock per platform
Lock file changes on every solve Loose ranges in the intent file Constrain the intent, re-solve deliberately
Install is slow despite the lock Solver still running Install from the lock, never from intent
A package version differs from the lock Something installed outside conda afterwards Assert the resolved stack at start-up
The lock cannot be solved at all Conflicting pins across the spatial stack Relax the binding, never the C library
One intent, one lock per platform A single environment file feeds a solve step that runs once per target platform, producing three committed lock files: Linux on x86-64, Linux on ARM64, and macOS on ARM64. At install time the lock matching the current platform is selected. Annotations record which population each lock serves — the CI runners, the ARM build agents and the developers' laptops. A closing warning states that solving for only one platform leaves either the developers or the runner operating in an environment no lock file ever described, which is where "works locally" reports come from. environment.yml one statement of intent solve once per platform linux-64.lock linux-aarch64.lock osx-arm64.lock the CI runners ARM build agents developer laptops install select by platform Solving for one platform only leaves either the developers or the runner in an environment no lock ever described — the origin of most “works locally” reports.

The last row of the table is worth expanding, because it is where teams make the wrong trade under pressure. When a solve fails, the temptation is to relax the C library pin — allowing a range on GEOS or PROJ — because that usually resolves the conflict immediately. It also reintroduces exactly the non-determinism the lock exists to remove. The correct move is to relax the binding version instead, or to accept an older stack until the conflict clears upstream, because the binding’s exact version rarely changes a geometric result and the engine’s always can.

Relax from the top of the stack, never from the bottom A three-tier dependency stack. The base tier holds the compiled engines GEOS, PROJ and GDAL and is marked as the last thing to relax, because loosening it changes geometric results and reintroduces the non-determinism the lock exists to prevent. The middle tier holds the Python bindings and is marked as safe to relax first, since the binding version rarely affects a geometric outcome. The top tier holds the application's own dependencies and is marked as safest of all. Arrows on the right indicate the order in which pins should be loosened when a solve cannot be satisfied: top tier first, base tier last and only deliberately. application dependencies no effect on geometry python bindings rarely changes a geometric result GEOS · PROJ · GDAL changing this changes answers relax first relax second relax last, deliberately and re-run the drift checks Under deadline pressure the instinct is the reverse, because loosening the engine pin clears the conflict fastest. It also removes the guarantee.

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.

Where conda beats a wheel-based image, and where it does not

Conda earns its place when the spatial stack is the hard part of the environment: it resolves the compiled libraries and the Python bindings together, from one channel, with one solver, which removes the class of problem where a wheel’s bundled engine and a system package disagree. For a data-science-shaped project with GDAL, rasterio, PROJ, and a scientific stack alongside them, nothing else resolves that cleanly.

It costs image size and start-up time, and those costs land on every CI run. A slim wheel-based image starts faster and pulls less, which matters when the gate has a five-minute budget and the runner is cold. Where the spatial dependencies are few and the wheels are well-maintained, the wheel route is the cheaper answer and the reproducibility gap is closed by hash-pinning rather than by a solver.

A reasonable rule: use conda when the environment is the difficulty, and wheels when the environment is routine and speed is the constraint. What is not reasonable is mixing the two in one environment. Installing a spatial package with pip into a conda environment that already provides its C library produces two engines and an ABI question, which is the least debuggable configuration available.

Refreshing the lock without losing the guarantee

A lock is not permanent, and the refresh should be a scheduled, reviewable event rather than a reaction to a broken build. Re-solve on a cadence, commit the resulting lock as its own change, and run the full suite against it before it becomes the pinned lock. Because the diff of a lock file is unreadable, the review that matters is the test run rather than the file — which is why the refresh should never be bundled into a change that also alters code.

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.

Commit the lock files alongside the intent file so a reader can see both what was asked for and what was resolved.

Assert the solved stack at start-up as well, so a manual change inside a running environment cannot go unnoticed.

Treat a lock refresh as its own change, never bundled with code.