From 0f957df9e648b10f405d167638edeebdf14290c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 15:26:17 +0000 Subject: [PATCH] refactor(docs): let compose.yaml be the only place an image version appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assertion 6 of check_docs.py asserted that a version quoted in prose *matched* compose.yaml. That kept the documents honest but kept the duplication, and the duplication is the defect: Dependabot edits only compose.yaml, so a version written anywhere else is stale from the next bump onward, and the matching check would have turned every Dependabot PR red until someone hand-edited a table. ci.yml already refuses duplicated pins in shell, YAML and the Makefile — "Image versions are NOT duplicated here ... hardcoded copies went stale silently" — but its grep does not cover Markdown, which is exactly how the six in the stack README survived to become #73. So: drop the versions from the service table, keep the image names, and flip the assertion from "must match" to "must not appear". A version pin reappearing in prose now fails the build even when the version is correct, which is the case the old check could not catch. Mutation-tested: reintroducing `grafana/loki:3.7.6` — the *current* pin, which the previous check accepted — now fails with "only compose.yaml may carry a version". Refs #73 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W1QX12EJLwMfRAWsbpfRJz --- README.md | 6 +++-- scripts/check_docs.py | 44 +++++++++++++++++++++++----------- stacks/observability/README.md | 21 ++++++++++------ 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e1236ed..ae1c135 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,10 @@ incident. - **CI that validates the documentation too.** Six assertions cross-check this prose against the configs it describes — rule and panel counts, the SNMP inventory against `docs/network.md`, the host/stack and ports tables - against `compose.yaml`, and every image version quoted in Markdown. A - document that disagrees with the repository fails the build. + against `compose.yaml`, and a ban on image versions in prose — Dependabot + edits only `compose.yaml`, so a version written anywhere else is stale + from the next bump. A document that disagrees with the repository fails + the build. - **Supply chain pinned by digest.** Every image carries both a tag and a `sha256:` digest, so a moved tag cannot change what deploys. CI enforces it; `make pin-digests` re-resolves them from the registry. diff --git a/scripts/check_docs.py b/scripts/check_docs.py index df24185..56c8d81 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -24,7 +24,7 @@ 3. Host and stack table docs/architecture.md <-> docs/network.md, stacks/ 4. Ports table docs/architecture.md <-> compose.yaml 5. Compute table docs/hardware.md <-> docs/network.md - 6. Image versions prose <-> compose.yaml + 6. Image versions no version pins in prose; compose.yaml owns them Only present-tense documents are checked. `docs/roadmap.md` and `docs/adr/` record what was true when the work landed — `roadmap.md` still says "(34 rules)" @@ -468,20 +468,35 @@ def check_compute_table() -> list[str]: # --------------------------------------------------------------------------- -# 6. Image versions quoted in prose +# 6. Image versions must not appear in prose at all # --------------------------------------------------------------------------- def check_image_versions() -> list[str]: - pinned = {} + """compose.yaml is the only place an image version may appear. + + An earlier version of this check asserted that a version quoted in prose + *matched* compose.yaml. That keeps the documents honest but keeps the + duplication, and the duplication is the actual defect: Dependabot edits + only compose.yaml, so a version written anywhere else is stale from the + next bump onward. All six in the stack README went stale exactly that way + (#73), and matching them would have made every Dependabot PR red until + someone hand-edited a table. + + ci.yml already refuses duplicated pins in shell, YAML and the Makefile — + "hardcoded copies went stale silently" — but its grep does not cover + Markdown, which is how the six survived. This is that rule, extended to + prose. + """ + pinned = set() doc = yaml.safe_load(COMPOSE.read_text(encoding="utf-8")) for svc in (doc.get("services") or {}).values(): image = str((svc or {}).get("image", "")) - if not image: - continue - repository, _, tag = image.partition("@")[0].rpartition(":") - pinned[repository] = tag + if image: + pinned.add(image.partition("@")[0].rpartition(":")[0]) - # Only inline code spans. Prose mentioning an image without a version, and - # anything inside a URL, is not a pin and must not be treated as one. + # Only inline code spans, and only a repository compose.yaml actually + # pins. An OS version in a hardware table is not an image pin, and neither + # is a registry path mentioned without a tag — naming the image is fine, + # naming its version is what goes stale. span = re.compile(r"`([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*):([^`\s]+)`") problems = [] for rel in PROSE: @@ -490,11 +505,12 @@ def check_image_versions() -> list[str]: continue for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): for match in span.finditer(line): - repository, tag = match.group(1), match.group(2).split("@")[0] - if repository in pinned and tag != pinned[repository]: + repository = match.group(1) + if repository in pinned: problems.append( - f"{rel}:{n} says {repository}:{tag}; compose.yaml pins " - f"{pinned[repository]}" + f"{rel}:{n} pins {repository}:{match.group(2)}; only " + f"compose.yaml may carry a version — drop the tag and " + f"write `{repository}`" ) return problems @@ -508,7 +524,7 @@ def main() -> int: ("host and stack mapping", check_host_stack_table), ("ports table against compose.yaml", check_ports), ("compute table against docs/network.md", check_compute_table), - ("image versions quoted in prose", check_image_versions), + ("image versions in prose (compose.yaml owns them)", check_image_versions), ) total = 0 diff --git a/stacks/observability/README.md b/stacks/observability/README.md index 64eaf6f..38cb236 100644 --- a/stacks/observability/README.md +++ b/stacks/observability/README.md @@ -8,12 +8,12 @@ make up # from the repository root | Service | Image | Port | Purpose | | --- | --- | --- | --- | -| `prometheus` | `prom/prometheus:v3.14.0` | 9090 | Metrics store, remote-write receiver, rule evaluation | -| `alertmanager` | `prom/alertmanager:v0.34.0` | 9093 | Alert routing, grouping, inhibition | -| `loki` | `grafana/loki:3.7.6` | 3100 | Log store | -| `grafana` | `grafana/grafana-oss:13.0.2` | 3000 | Dashboards | -| `snmp-exporter` | `prom/snmp-exporter:v0.30.1` | *internal* | SNMP polling proxy | -| `alloy` | `grafana/alloy:v1.18.1` | 12345 (localhost) | Metric and log collection | +| `prometheus` | `prom/prometheus` | 9090 | Metrics store, remote-write receiver, rule evaluation | +| `alertmanager` | `prom/alertmanager` | 9093 | Alert routing, grouping, inhibition | +| `loki` | `grafana/loki` | 3100 | Log store | +| `grafana` | `grafana/grafana-oss` | 3000 | Dashboards | +| `snmp-exporter` | `prom/snmp-exporter` | *internal* | SNMP polling proxy | +| `alloy` | `grafana/alloy` | 12345 (localhost) | Metric and log collection | ## Layout @@ -50,7 +50,14 @@ grafana/ block dropped. - **Adding an SNMP target needs no restart** — file_sd re-reads every 5 minutes. Adding a *module* does, because snmp-exporter reads its config once. -- **Image tags are pinned.** CI fails on `:latest`. Dependabot proposes bumps. +- **Image tags are pinned, and the versions are not repeated here.** Every image + in `compose.yaml` carries a tag *and* a `sha256:` digest; CI fails on + `:latest` and on any image missing a digest, and Dependabot proposes bumps. + The table above deliberately names images without versions — Dependabot only + edits `compose.yaml`, so a version written anywhere else goes stale the moment + it lands, which is what happened to all six of these (#73). + `scripts/check_docs.py` now fails the build if a version pin reappears in + prose. `docker compose images` prints what is actually running. ## Validate before deploying