#!/usr/bin/env python3 """check-image-pins.py — catalog gate: no :latest / untagged images in templates. Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on: - an explicit `:latest` tag (including `:latest@sha256:...` — the tag is a lie there, but the digest pins it, so that shape is allowed and only the bare tag is banned), - a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`), - a missing tag entirely (`image: nginx` → implicit :latest). A digest reference (`repo@sha256:<64 hex>`) counts as pinned — only a REAL digest: the label `@sha256:` followed by anything else is not a pin (R-426 decoy). Registry ports (`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the LAST path segment. Also refused (R-426, found by the decoy suite `scripts/test_gate_decoys.py`): a QUOTED key (`"image": nginx` is the same Compose field and was not read at all), and an INTERPOLATED ref (`${APP_IMAGE:-nginx}` — the tag the box will run cannot be read from the file; write a literal). `--root=` judges another checkout (the decoy suite's seam); default is this repo. Standing rule (CLAUDE.md): never :latest or untagged images in templates — pin a concrete version tag; deployed apps pin to their running digest. """ import re import sys from pathlib import Path BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"} IMAGE_RE = re.compile(r"^\s*[\"']?image[\"']?\s*:\s*[\"']?([^\s\"'#]+)") DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$") def check(root: Path) -> int: failures = [] files = sorted(root.glob("templates/*/docker-compose.yml")) if not files: print(f"ERROR: no templates found under {root}/templates/", file=sys.stderr) return 2 for f in files: for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1): m = IMAGE_RE.match(line) if not m: continue ref = m.group(1) if "$" in ref: failures.append((f, lineno, ref, "INTERPOLATED ref (the pin cannot be read; write a literal)")) continue if "@" in ref: if DIGEST_RE.search(ref): continue # digest-pinned — strongest pin there is failures.append((f, lineno, ref, "NOT A DIGEST (@sha256: needs 64 hex characters)")) continue last_seg = ref.rsplit("/", 1)[-1] if ":" not in last_seg: failures.append((f, lineno, ref, "NO TAG (implicit :latest)")) continue tag = last_seg.rsplit(":", 1)[-1] if tag.lower() in BANNED_TAGS: failures.append((f, lineno, ref, f"floating tag :{tag}")) if failures: print("UNPINNED IMAGES FOUND:") for f, lineno, ref, why in failures: print(f" {f.as_posix()}:{lineno}: {ref} [{why}]") return 1 print(f"image-pin gate OK — {len(files)} templates, 0 unpinned images") return 0 if __name__ == "__main__": root = Path(__file__).resolve().parent.parent for a in sys.argv[1:]: if a.startswith("--root="): root = Path(a.split("=", 1)[1]) elif a == "--all": pass # the runner passes it to every gate; this gate already reads every template, hidden ones too else: print(f"unknown argument: {a} (usage: check-image-pins.py [--root=])", file=sys.stderr) sys.exit(2) sys.exit(check(root))