Skip to content

fix(validate): run the five checks that were CI-only, from one copy each (#175) - #317

Merged
Gerrrt merged 1 commit into
mainfrom
gerrrt/validate-parity-175
Sep 4, 2026
Merged

fix(validate): run the five checks that were CI-only, from one copy each (#175)#317
Gerrrt merged 1 commit into
mainfrom
gerrrt/validate-parity-175

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #175. All five checks now run locally, from one implementation each.

# Check Now lives in
1 Image versions not duplicated outside compose.yaml scripts/check_image_pins.py
2 No image uses a floating tag scripts/check_image_pins.py
3 Every image is pinned by digest scripts/check_image_pins.py
4 Parse every dashboard PromQL expression scripts/validate.sh
5 SOPS files are actually encrypted scripts/check-sops-encrypted.sh

Plus scripts/check-tracked-artefacts.sh, replacing the two divergent copies.

The three greps went where the issue suggested

check_image_pins.py already runs in both places, and its docstring already explains why a grep cannot see a pin that is simply absent (#65). The greps that paragraph is about now sit beside the parser, so it is checkable rather than a note about code elsewhere.

Scope is git ls-files, not a walk of . — the one deliberate change in behaviour. The shell versions walked the tree, which is right in CI's clean checkout and wrong locally: this repository keeps git worktrees under .claude/worktrees/, so a local run would have descended into full copies of itself and reported another branch's findings as this one's. Verified: an untracked :latest under .claude/ produces zero hits. Tracked is also the right question, since an untracked scratch file pinning :latest harms nobody.

The floating-tag check keeps the original's blind spot on purpose. FOO=alpine:latest is not caught — both the old grep and this require start-of-line or whitespace before the name. Confirmed identical against the same input. Parity rather than regression, and still a real gap if anyone wants it closed separately.

The digest check reads parsed YAML rather than awk '$1 == "image:"', so a quoted or flow-style value is not a hole, and it fails rather than passes when it finds no compose files.

The tracked-artefact divergence

The issue's table, re-tested against the new single implementation:

tracked file old CI old validate.sh now
stacks/observability/.env caught caught caught
stacks/other-stack/.env caught missed caught
some/other/.rendered/x caught missed caught
nested/certificates/key.pem caught missed caught
stacks/observability/.env.example allowed allowed allowed

The patterns are unanchored, which is where the old copy went wrong: ^certificates/ matched only at the repository root, and a secret does not become safe by being nested.

Two things that bit during the work

  • The emitted PromQL file needed chmod 0644. mktemp creates 0600, and when PROMTOOL is the docker fallback the reader is the image's own unprivileged user — it fails as permission denied, which looks exactly like a broken query and is not one. It is also written inside REPO_ROOT, because that fallback bind-mounts REPO_ROOT and nothing else.
  • check-sops-encrypted.sh fails when the glob matches nothing, rather than reporting "all encrypted" over zero files. That is the --emit-promql guard's argument (make validate and CI have drifted apart in both directions #68) applied to the one artefact whose absence would otherwise be silent.

Checks

validate.sh48 PASS (up from 45), 0 FAIL, 2 SKIP, the usual environmental two. New lines:

  PASS observability: dashboard panel queries parse as PromQL
  PASS lab: no dashboards — no panel queries to parse
  PASS every SOPS file is encrypted
  PASS no rendered, decrypted, purge-secrets, certificate or backup files tracked

Each folded-in check was also tested against a violation in a throwaway clone: a version pin outside compose.yaml, a space-preceded :latest, and a digest stripped from a real image were all caught with the right message.

🤖 Generated with Claude Code

…ach (#175)

`validate.sh` claims "Everything CI runs, runnable locally" and five checks
lived as inline shell in ci.yml with no local equivalent. The SOPS assert is
the one that mattered: a file that is not encrypted is a committed plaintext
secret, and the only thing that looked was a job you cannot consult before
pushing — after which it has to be purged from history rather than reverted.

Three image greps fold into check_image_pins.py, which already runs in both
places and whose docstring already explains why a grep cannot see a pin that is
simply absent (#65). They are now beside the parser that argument is about.

Scope is `git ls-files`, not a walk of `.`. The shell versions walked the tree,
which is right in CI's clean checkout and wrong here: this repository keeps git
worktrees under .claude/worktrees/, so a local run would have descended into
full copies of itself and reported another branch's findings as this one's.
Tracked is also the right question — an untracked scratch file pinning :latest
harms nobody. The floating-tag pattern keeps the original's blind spot exactly:
`FOO=alpine:latest` is missed there too, because both require start-of-line or
whitespace before the name. Parity, not a regression, and still a real gap.

The digest check reads the parsed compose file rather than an awk over `image:`
lines, so a quoted or flow-style value is not a hole, and it fails rather than
passes when it finds no compose files at all.

The dashboard PromQL parse uses the promtool binary-or-docker fallback the file
already has, with a counted skip when neither is reachable. Its temp file is
written inside REPO_ROOT, because the docker fallback bind-mounts REPO_ROOT and
nothing else, and chmod 0644, because mktemp makes it 0600 and the reader is
then the image's own user — which fails as "permission denied" and looks
exactly like a broken query.

The tracked-artefact check was written out twice and the copies had drifted.
validate.sh's missed a second stack's .env, a nested .rendered/ and a
certificates/ anywhere but the repository root — all three now caught, and
.env.example still allowed. One script, both callers, second copy deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gerrrt
Gerrrt merged commit 2c4b0be into main Sep 4, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/validate-parity-175 branch September 4, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make validate still does not run five checks CI runs

1 participant