Verifying GeoTIFF SHA-256 Checksums in CI
Part of: Automated Hash Generation for Rasters
A raster that passed review last week can be silently corrupted or swapped before it ships, so the surest guard is a continuous integration job that recomputes every GeoTIFF’s SHA-256 against a committed manifest and fails the build on any drift. This how-to implements that gate as a pytest suite plus a short CI snippet, building directly on Automated Hash Generation for Rasters and the digest function from Generating SHA-256 Hashes for GeoTIFFs in Python.
Prerequisites
- Python 3.10+ with
pytest8.0+ installed in the CI environment. - A committed manifest — here
checksums.json— mapping each raster’s repository-relative path to its expected file-level SHA-256, generated once when the rasters were approved. - The GeoTIFFs available at test time, either committed (Git LFS for large files) or fetched into a known directory before the test runs.
- A CI runner (GitHub Actions is shown, but the assertion is runner-agnostic).
The third outcome is the one naive implementations miss. A loop that iterates over files on disk and looks each up in the manifest will pass cleanly when a raster is deleted, because the missing file simply never enters the loop. Compare the two sets — manifest keys against discovered paths — and fail on a difference in either direction. A dataset silently dropped from a fixture corpus is exactly as much of a regression as one silently altered, and it is far easier to introduce by accident.
Untracked new files deserve their own decision rather than a default. Failing on them forces every added fixture through manifest regeneration, which is usually what you want in a compliance corpus; warning instead is reasonable for a working directory where scratch outputs land beside tracked ones. What you should not do is ignore them, since that is how a raster ends up shipping with no baseline at all.
Implementation
The suite loads the manifest, recomputes each digest with chunked I/O, and parametrizes one test per raster so the CI report names exactly which file drifted. Missing files and unmanifested extras are treated as failures, not silent passes.
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = REPO_ROOT / "checksums.json"
def sha256_file(path: Path, chunk_size: int = 1_048_576) -> str:
"""Compute the file-level SHA-256 of a raster using chunked reads."""
digest = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(chunk_size):
digest.update(chunk)
return digest.hexdigest()
def load_manifest() -> dict[str, str]:
"""Return the committed {relative_path: expected_sha256} mapping."""
if not MANIFEST_PATH.is_file():
raise FileNotFoundError(f"Checksum manifest missing: {MANIFEST_PATH}")
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
MANIFEST: dict[str, str] = load_manifest()
@pytest.mark.parametrize("rel_path, expected", sorted(MANIFEST.items()))
def test_raster_checksum_matches(rel_path: str, expected: str) -> None:
"""Each manifested GeoTIFF must hash to its recorded SHA-256."""
target = REPO_ROOT / rel_path
assert target.is_file(), f"Manifested raster is missing from the tree: {rel_path}"
actual = sha256_file(target)
assert actual == expected, (
f"Checksum drift for {rel_path}\n"
f" expected: {expected}\n"
f" actual: {actual}"
)
def test_no_unmanifested_rasters() -> None:
"""Every .tif under data/ must appear in the manifest (no silent additions)."""
data_dir = REPO_ROOT / "data"
on_disk = {
str(p.relative_to(REPO_ROOT)).replace("\\", "/")
for p in data_dir.rglob("*.tif")
}
unmanifested = sorted(on_disk - set(MANIFEST))
assert not unmanifested, f"Rasters absent from checksums.json: {unmanifested}"
The test_no_unmanifested_rasters case is what makes the gate trustworthy: without it, an attacker or a careless commit could add a new raster that no assertion covers. Comparing the on-disk set against the manifest keys closes that gap.
The CI job installs dependencies and runs the suite; a non-zero pytest exit code fails the pipeline:
name: raster-checksums
on: [push, pull_request]
jobs:
verify-checksums:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true # pull Git LFS-tracked GeoTIFFs
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pytest
- run: pytest tests/test_checksums.py -v
Proving the Gate Can Fail
Run these three once when the gate is written and again whenever the job configuration changes, because a checksum gate is a piece of machinery whose entire value rests on its ability to say no — and it spends almost all of its life saying yes. A gate that has been green for six months is indistinguishable, from the outside, from a gate whose glob pattern stopped matching anything after a directory was renamed.
Keep the mutations as a throwaway branch rather than a permanent test, since committing a deliberately corrupted raster into the corpus creates its own confusion. The second mutation is the one that most often reveals a real defect: implementations that iterate over discovered files and look each one up in the manifest cannot detect a deletion, and that shape is the natural way to write the loop.
One further check is worth adding once the three above pass: run the job twice against an unchanged tree and confirm the second run is as fast as the first is correct. A gate that re-hashes a large corpus on every pull request will be disabled the moment it adds ten minutes to the feedback loop, so measure it early. Caching digests keyed on file size and modification time is usually enough, provided the cache is treated as an optimisation that a scheduled full run periodically bypasses — a cached gate that never revalidates is trusting exactly the metadata an attacker would forge. A nightly job that ignores the cache entirely, on the full corpus, closes that hole at a cost nobody notices.
Verification
Run the suite locally before pushing; a clean tree reports one pass per raster:
$ pytest tests/test_checksums.py -v
tests/test_checksums.py::test_raster_checksum_matches[data/dem_2026.tif-9f3a...] PASSED
tests/test_checksums.py::test_no_unmanifested_rasters PASSED
To confirm the gate actually bites, corrupt one byte of a raster and re-run — the parametrized case for that file must turn red with the expected-versus-actual diff, while every other case stays green. A gate that never fails on injected drift is not a gate.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| manifest path | checksums.json, committed |
Reviewed like code; a diff is the audit trail of an intended change |
| digest scope | byte-exact | CI is verifying artefacts, not dataset identity — drift should be loud |
| on untracked file | fail |
Forces new fixtures through a reviewed manifest update |
| on missing file | fail |
A deletion is drift; set comparison, not per-file lookup |
.gitattributes |
*.tif binary (+ LFS filter) |
Stops CRLF normalisation mangling rasters on Windows runners |
| runner OS matrix | at least two | Catches platform-dependent digests before they reach main |
| job placement | before any publish step | A gate that runs after upload is a report, not a gate |
The runner matrix earns its cost quickly. A digest that is stable on Linux and drifts on Windows is almost always a line-ending or LFS smudge problem, and it will otherwise be discovered by whoever first runs the pipeline on a different platform — typically months later, and typically at the worst moment. Running the same check on two operating systems turns that into a pull-request failure on the day the problem is introduced.
Job placement is the other setting people get wrong by omission. A checksum gate that runs in parallel with, or after, the step that publishes artefacts will faithfully report drift on data that has already left the building. Make it a dependency of the publish job so that failure prevents rather than merely records.
Regenerating the Manifest Deliberately
The rule in the caption is absolute and worth defending against convenience arguments. Someone will eventually propose an “auto-update the manifest on main” job to stop the gate being noisy. That change converts the check into a machine that records whatever it finds, which is precisely the property it was built not to have. Keep regeneration a local, human-initiated action whose output lands in a reviewed commit alongside the changed raster, so a reviewer can see that both moved together and ask why.
Gotchas & edge cases
- File-level hashing flags harmless GeoTIFF rewrites. Re-tiling, adding internal overviews, or switching from
DEFLATEtoZSTDchanges the bytes without touching a single pixel, so a file-level manifest will fail even though the geographic data is identical. If your workflow legitimately re-encodes rasters, pin the manifest to a content-level digest of the pixel arrays and CRS instead, as covered in Generating SHA-256 Hashes for GeoTIFFs in Python. - Line-ending and LFS smudge surprises. Never let Git treat
.tifas text — a missing*.tif filter=lfs -textin.gitattributeslets CRLF normalization mangle binary rasters on Windows runners, producing a checksum that drifts only on one platform. Add an explicit binary attribute for raster extensions. - Manifest and rasters drifting apart. Regenerating rasters without regenerating
checksums.json(or vice versa) turns the gate into noise. Make manifest regeneration a deliberate, reviewed step in your automated hash generation for rasters workflow, and require the manifest diff to be part of the same pull request as the raster change.