Recording Fixture Provenance Metadata in CI
A provenance record is what lets a failing run answer the only question that matters at 9 a.m.: did the code change, did the data change, or did the environment change? This guide sits beneath fixture versioning and provenance and shows how to emit that record from a pytest suite, what it must contain to be useful, and the one detail teams get wrong that makes the whole thing worthless — writing it only when the run fails.
The record is small, cheap and unglamorous. Its value is entirely in comparison, which is why it has to exist for the passing runs too.
Root cause: attribution needs a baseline
A failing run in isolation says nothing about cause. The code, the data and the runtime all changed at some point between the last success and now, and without a record of what each was, the investigation starts by reconstructing them — from a build log if one survives, from a container tag if it was pinned, from a fixture directory that may have been regenerated since.
With a record on both runs the comparison is mechanical: three fields differ or they do not, and which ones differ names the cause. That is the entire mechanism, and its only prerequisite is that the successful run wrote something down.
Record reference
| Field | Source | Answers |
|---|---|---|
code_revision |
CI environment | Which commit ran |
fixture_ids |
Computed per fixture | Which spatial state ran |
generator_revision |
Git revision of the generator module | Which implementation produced it |
config_hash |
Hash of the generation config | Which parameters |
seed |
The generator’s seed | Which draw |
geos_version / proj_version |
Imported libraries, at runtime | Which engines |
proj_data_version |
The grid package | Which datum shifts |
image_digest |
CI environment | Which runtime |
outcome |
pytest exit status | Whether this is a baseline or a failure |
Step-by-step implementation
The recorder targets pytest 7+ and writes a JSON artefact regardless of outcome.
Step 1 — Collect the environment once per session
Reading versions from the libraries that were actually imported matters — a shell command may resolve to a different installation than the one the tests use.
# conftest.py
import json, os, platform
from pathlib import Path
def _engine_versions() -> dict:
import shapely, pyproj, rasterio
return {
"geos_version": shapely.geos_version_string,
"proj_version": pyproj.proj_version_str,
"gdal_version": rasterio.__gdal_version__,
"python": platform.python_version(),
}
Step 2 — Register fixture identities as they are used
A session-scoped collector lets any fixture announce itself, so the record covers exactly what the run touched rather than everything that exists.
import pytest
@pytest.fixture(scope="session")
def provenance() -> dict:
return {"fixtures": {}, "engines": _engine_versions(),
"code_revision": os.environ.get("GIT_SHA", "unknown"),
"image_digest": os.environ.get("IMAGE_DIGEST", "unknown")}
@pytest.fixture
def parcels(provenance, tmp_path):
gdf = build_parcels(seed=20260811)
provenance["fixtures"]["parcels"] = {
"fixture_id": fixture_id(gdf, key="parcel_id", grid_size=0.001),
"generator": "fixtures.parcels:build_parcels",
"seed": 20260811,
"config_hash": config_hash(),
}
return gdf
Step 3 — Write the record whatever happened
The sessionfinish hook runs on success and on failure, which is the property that makes the baseline exist.
def pytest_sessionfinish(session, exitstatus):
prov = session.config._store.get("provenance", None) or {}
prov["outcome"] = "pass" if exitstatus == 0 else f"fail:{exitstatus}"
out = Path(os.environ.get("PROVENANCE_PATH", "provenance.json"))
out.write_text(json.dumps(prov, indent=2, sort_keys=True))
Step 4 — Publish it as an artefact, always
A record written into a container that is then discarded has recorded nothing. The publish step must run regardless of the job’s outcome.
- name: Publish provenance
if: always() # the failing run is the one that needs it
uses: actions/upload-artifact@v4
with:
name: provenance-$
path: provenance.json
retention-days: 90
Verify the fix
Confirm the record survives a failing run, which is the case that matters:
pytest -q tests/ ; ls -l provenance.json && jq '.outcome, .engines' provenance.json
The file must exist with an outcome of fail:1 after a deliberate failure. If it exists only after a success, the write is in the wrong hook; if it does not exist at all in CI, the publish step is conditioned on success.
Reading two records against each other
The comparison is worth automating, because the manual version is a diff of two JSON files and reviewers reliably skip it. A short script that takes the failing run’s record and the last passing one, and reports which of the three groups differ, turns the whole mechanism into one line of output in the job summary.
The three groups are the ones described in the parent page: code, data and environment. Reporting them by group rather than field by field is what makes the output actionable — a reader does not need to know that proj_data_version moved, only that the environment changed and the image owner should look. Fields within a group can be listed underneath for whoever does look.
One refinement is worth adding once the basic comparison works: report which fixtures changed rather than that some did. A run touching a dozen fixtures where one moved is a much narrower investigation than “the data changed”, and the record already contains everything needed to say so.
Making the record useful to a human, not just a script
A JSON artefact is the right storage form and the wrong reading form. Two additions make it usable by whoever opens the failed job first, and both are cheap.
A one-line job summary. Most CI systems allow a job to write a short markdown summary that appears at the top of the run page. A single line stating which of the three groups changed — code, data, environment — puts the answer where nobody has to download anything. When nothing changed but the outcome, that line says so, which is itself informative because it points at nondeterminism.
Fixture identities in the failure message. When an assertion fails, the message should carry the identity of the fixture it ran against. This is the same principle as recording a generator’s seed: a failure that names its input is reproducible from the message alone, while one that names only a rule requires the reader to work out what data was involved.
Neither replaces the artefact. The artefact is what a comparison script reads and what remains available in three months; the summary and the message are what a person reads in the first minute, and the first minute is where most of the wasted time in a failing build is spent.
There is a third addition worth making once the first two exist: publish the record for the default branch’s scheduled run as well as for merge requests. That run is the natural baseline — it has no change under review, so any difference in it is drift rather than intent — and comparing a merge-request failure against it separates “this branch broke something” from “this was already broken” without any further investigation.
Failure modes and edge cases
- Writing the record in a teardown that does not run on error. A fixture finaliser can be skipped when a session aborts. Use the session-finish hook, which runs on collection errors too.
- Recording versions from a shell command.
gdalinfo --versionmay resolve to a different installation than the one Python imported. Read from the imported modules. - Publishing conditioned on success. The default in most CI systems is to skip subsequent steps after a failure, which discards the record precisely when it is needed. Condition the publish to run always.
- A record with no retention. An artefact that expires in a day cannot be compared against a failure that appears a week later. Retain long enough to cover a slow investigation.
- Recording every fixture that exists. The record should cover what the run used, not the fixture directory’s contents, or it grows without bound and stops naming anything specific.
- Timestamps as the only ordering. Two runs on different runners can have clock skew. Order by the CI run number, and keep the timestamp for humans rather than for comparison.
What not to put in the record
A provenance record grows by accretion unless something stops it, and a bloated record is read by nobody. Three categories are worth excluding deliberately.
Anything derivable. The container image digest identifies the image; listing every package inside it duplicates information the digest already fixes and makes the record unreadable. Record the digest and let anyone who needs the contents resolve it.
Anything that changes on every run regardless. A timestamp, a run identifier, a hostname — these belong in the CI system’s own metadata, and including them in a comparison means every comparison reports differences. Keep the record to fields whose stability is itself informative.
Anything sensitive. A fixture path can disclose a customer name; a query recorded verbatim can contain a coordinate. The record is an artefact that may be retained for months and shared more widely than the run itself, so it should carry identifiers rather than contents. This is the same discipline that keeps raw coordinates out of log lines, applied to a longer-lived artefact.
The test for whether a field belongs is simple: would its difference between two runs tell somebody what to do next? If yes, it belongs. If it would merely be noticed and dismissed, it makes the useful fields harder to find.
Conclusion
A provenance record is a few kilobytes of JSON whose entire value is that it exists on both sides of a comparison. Collecting engine versions from imported modules, letting each fixture register its own identity, writing at session finish regardless of outcome and publishing unconditionally turns “did the data change?” from an investigation into one line of a job summary — completing the bookkeeping that fixture versioning and provenance exists to provide.
Related
- Fixture Versioning and Provenance — the parent layer and the record’s field set.
- Hashing Spatial Fixtures for Content-Addressed Storage — computing the fixture identity this record carries.
- Containerized GIS Test Runtimes — the engine versions the environment group compares.
- CI/CD Spatial Quality Gates — failure attribution, of which this record is the mechanism.