Verifying GeoTIFF SHA-256 Checksums in CI
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).
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
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.
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.