image pinning: eliminate :latest from all 5 unpinned templates + standing gate
bentopdf :latest -> v2.8.6; calibre-web :latest -> v4.0.6 (== running digest on demo 9201, c31a738b - pin is a no-op); papra :latest -> 26.6.1-rootless (latest was the rootless variant); recipe-importer :latest -> v0.9.11 (tag pre-existed, digest-equal, no retag needed); termix :latest -> 2.5.0. All five pins digest-identical to what :latest resolved to on 2026-07-12. New gate scripts/check-image-pins.py (catches floating tags AND untagged refs; red-proofed both shapes). Standing rule in CLAUDE.md + REUSE.md row.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
#!/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:...`) counts as pinned. Registry ports
|
||||
(`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the
|
||||
LAST path segment.
|
||||
|
||||
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\"'#]+)")
|
||||
|
||||
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 "@sha256:" in ref:
|
||||
continue # digest-pinned — strongest pin there is
|
||||
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__":
|
||||
sys.exit(check(Path(__file__).resolve().parent.parent))
|
||||
Reference in New Issue
Block a user