diff --git a/scripts/check-image-pins.py b/scripts/check-image-pins.py
index 2cb9e78..c3b99af 100644
--- a/scripts/check-image-pins.py
+++ b/scripts/check-image-pins.py
@@ -7,10 +7,17 @@ Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on:
- 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
+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.
"""
@@ -19,7 +26,8 @@ import sys
from pathlib import Path
BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"}
-IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)")
+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 = []
@@ -33,8 +41,14 @@ def check(root: Path) -> int:
if not m:
continue
ref = m.group(1)
- if "@sha256:" in ref:
- continue # digest-pinned — strongest pin there is
+ 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)"))
@@ -51,4 +65,13 @@ def check(root: Path) -> int:
return 0
if __name__ == "__main__":
- sys.exit(check(Path(__file__).resolve().parent.parent))
+ 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))
diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py
index 8de99fe..8f37c1f 100644
--- a/scripts/test_gate_decoys.py
+++ b/scripts/test_gate_decoys.py
@@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
# MUST have a decoy below that has been seen to fail.
COVERS = {
+ "image-pins": "the pin's LABEL without the pin: a registry PORT (`:5000`) read as a tag, `@sha256:` with no digest behind it, a QUOTED `\"image\":` key, an interpolated `${APP_IMAGE:-nginx}`, `:LATEST`/`:latest` in quotes - vs the inert shapes that must pass (a commented `# image: nginx`, an `x-image:` extension field, a README line) and the genuine pins (a tag, a real 64-hex digest, `:latest@sha256:` pinned by its digest, a port with a tag) (R-426)",
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
"catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)",
"probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in "
@@ -688,6 +689,67 @@ def family_gate_cases():
shutil.rmtree(ws, ignore_errors=True)
+def image_pins_cases():
+ """check-image-pins.py reads templates/*/docker-compose.yml under --root (R-426).
+
+ Each case is ONE planted template in a scratch catalog, so a case's verdict is that line's verdict
+ and nothing else. The real tree is also judged (it must pass), so a gate that convicts everything
+ fails here too.
+ """
+ global ran
+ ws = tempfile.mkdtemp(prefix="catalog-pins-")
+ DIG = "@sha256:" + "0123456789abcdef" * 4
+ try:
+ def run(name, image_line, expect_rc, must=(), extra_file=None):
+ global ran
+ cat = os.path.join(ws, "cat")
+ shutil.rmtree(cat, ignore_errors=True)
+ d = os.path.join(cat, "templates", "demo")
+ os.makedirs(d)
+ io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(
+ "services:\n demo:\n image: demo/demo:1.0\n demo-web:\n" + image_line + "\n"
+ " restart: unless-stopped\n")
+ if extra_file:
+ io.open(os.path.join(d, extra_file[0]), "w", encoding="utf-8").write(extra_file[1])
+ r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py"), "--root=" + cat], ROOT)
+ out = r.stdout + r.stderr
+ ran += 1
+ if r.returncode == expect_rc and all(m in out for m in must):
+ print(" ok %-52s rc=%d (expected %d)" % ("image-pins: " + name, r.returncode, expect_rc))
+ else:
+ fails.append("image-pins: %s: rc=%d expected %d; missing %s\n%s" % (
+ name, r.returncode, expect_rc, [m for m in must if m not in out], out[-600:]))
+
+ # ── THE FACTS: an image the box would pull floating. Each must be REFUSED. ───────────────
+ run("FACT: untagged", " image: nginx", 1, ("NO TAG",))
+ run("FACT: a registry PORT is not a tag", " image: registry.local:5000/nginx", 1, ("NO TAG",))
+ run("FACT: quoted :latest", ' image: "nginx:latest"', 1, ("floating tag :latest",))
+ run("FACT: :LATEST in capitals", " image: nginx:LATEST", 1, ("floating tag",))
+ run("FACT: a floating alias (:edge)", " image: alpine:edge", 1, ("floating tag :edge",))
+ run("FACT: untagged with a comment saying pinned", " image: nginx # pinned 1.27", 1, ("NO TAG",))
+ run("FACT: a QUOTED key is the same field", ' "image": nginx', 1, ("NO TAG",))
+ run("FACT: an interpolated ref cannot be read", " image: ${APP_IMAGE:-nginx}", 1, ("INTERPOLATED",))
+ run("FACT: @sha256: with no digest behind it", " image: nginx@sha256:pinned", 1, ("NOT A DIGEST",))
+ # ── INERT / GENUINE: must PASS ───────────────────────────────────────────────────────────
+ run("INERT: a commented-out untagged image", " # image: nginx", 0, ("image-pin gate OK",))
+ run("INERT: an x- extension field (Compose ignores it)", " x-image: nginx", 0, ("image-pin gate OK",))
+ run("INERT: an untagged image in README.md", " image: nginx:1.27.3", 0, ("image-pin gate OK",),
+ extra_file=("README.md", "image: nginx\n"))
+ run("GENUINE: a concrete tag", " image: nginx:1.27.3-alpine", 0, ("image-pin gate OK",))
+ run("GENUINE: a registry port WITH a tag", " image: registry.local:5000/nginx:1.27", 0, ("image-pin gate OK",))
+ run("GENUINE: a real digest", " image: nginx" + DIG, 0, ("image-pin gate OK",))
+ run("GENUINE: :latest pinned by its digest", " image: nginx:latest" + DIG, 0, ("image-pin gate OK",))
+ # the real catalog must pass too — a gate that convicts everything is not a gate
+ r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py")], ROOT)
+ ran += 1
+ if r.returncode == 0:
+ print(" ok %-52s rc=0 (expected 0)" % "image-pins: GENUINE: the real catalog")
+ else:
+ fails.append("image-pins: the real catalog was refused rc=%d\n%s" % (r.returncode, (r.stdout + r.stderr)[-600:]))
+ finally:
+ shutil.rmtree(ws, ignore_errors=True)
+
+
def mem_sum_cases():
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
global ran
@@ -1311,6 +1373,7 @@ i18n:
finally:
shutil.rmtree(clone, ignore_errors=True)
+ image_pins_cases()
onboarding_cases()
family_gate_cases()
mem_sum_cases()