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 pytest 8.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).
What the CI gate compares, and the three outcomes Tracked rasters are hashed and compared against a committed manifest, yielding three distinct outcomes that must be handled differently. checksums.json committed, reviewed rasters on disk re-hashed each run set comparison MATCH → pass no action DIGEST DIFFERS → fail loudly print both digests and the path IN MANIFEST, NOT ON DISK → fail a deletion is drift too

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

Three mutations the gate must catch Each mutation is applied to a scratch branch and the build must fail; a green build identifies exactly which comparison is missing. MUTATION BUILD MUST IF GREEN, YOU ARE MISSING… Flip one byte in a tracked .tif FAIL the digest comparison itself Delete a raster in the manifest FAIL the set comparison (most common gap) Add an untracked .tif FAIL the untracked-file policy

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

Intended change versus drift An intended raster change requires a human to regenerate the manifest in a reviewed commit; an unintended change simply fails, with no automatic regeneration available. A raster's digest changed INTENDED NOT INTENDED Run the regeneration script locally Commit manifest + raster together Reviewer sees both diffs side by side Gate passes on the next run Build fails; nothing auto-heals Investigate: corruption? wrong file? a driver upgrade rewriting tags? CI must NOT be able to regenerate The moment CI can rewrite the manifest, the gate can no longer detect anything.

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 DEFLATE to ZSTD changes 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 .tif as text — a missing *.tif filter=lfs -text in .gitattributes lets 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.