Skip to content

feat(ci): validate every stack, not just the estate's (#263) - #278

Merged
Gerrrt merged 1 commit into
mainfrom
gerrrt/multi-stack-validators-263
Sep 4, 2026
Merged

feat(ci): validate every stack, not just the estate's (#263)#278
Gerrrt merged 1 commit into
mainfrom
gerrrt/multi-stack-validators-263

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #263.

Stacked on #276 — this PR is
based on that branch, because #263 exists to validate what #276 adds. Its own
commit is the last one; review that.

The problem, restated from the issue

STACK ?= observability reached the lifecycle and secrets targets and stopped.
Every validator carried its own copy of stacks/observability:
validate.sh, check_docs.py, check_dashboards.py,
check_compose_health.py, check_loki_rules.sh, seed-validation-env.sh,
pin-digests.sh and ci.yml. stacks/lab therefore landed as a stack CI had
never seen — compose not config-checked, rules not promtool-tested,
dashboards not checked against its own datasources, images pinned and
digest-verified by nothing.

The answer to the issue's open question

Iterate, not matrix. A GitHub Actions matrix would put stack knowledge back
into ci.yml, and lint.sh already asserts that ci.yml, the Makefile and
validate.sh delegate to the same scripts rather than carrying their own
copies. Iterating keeps one definition and one set of commands.

scripts/stacks.sh is that definition. Everything that iterates reads it —
including the Python checkers, via subprocess, because a second
implementation in Python would be a second definition of what a stack is, and
that is the definition deciding what gets checked at all.

A stack is a directory under stacks/ holding a compose.yaml. A
directory without one is an error, not a skip — the issue's acceptance
criterion. Silently skipping a malformed directory is indistinguishable from
the defect being fixed.

error: no compose.yaml in: empty
A directory under stacks/ is a stack, and a stack is its compose file
(ADR-0004). Nothing validates, deploys or pins images for a directory without
one, so this fails rather than skipping it — a stack nothing checks is the
defect this list exists to prevent (#263).

Three checks got stronger, not just wider

Rules without unit tests now fail. check rules parses PromQL and never
asks whether an expression can be true — ContainerHighMemory was unfireable
for months while passing it (#63). A new stack arriving with rules and no tests
is that waiting to happen, and the moment to say so is when the rules land.

The reload-config.sh cross-check split in two. SERVICES is the union
across stacks, and reload-config.sh now skips what a stack does not declare,
so "not defined in this compose file" stopped being a defect on its own. Per
file, an entry the stack does declare must still carry the healthcheck the
probe stands on — now applied to every stack rather than only the estate's.
Across the complete set, every entry must be declared somewhere, which is
what still catches an array naming a service nothing has. ABSENT_BINARIES got
the same treatment. The completeness half is only claimed when the script
discovered the stacks itself; explicit paths mean the caller chose the scope and
no repo-wide claim can follow from a subset.

check_docs.py's PROSE list globs stacks/*/README.md instead of naming
the estate's, so a version pin in any stack README goes stale loudly (#73).

Absences are passes, not skips

A skip means "could not check, and therefore proved nothing", and
validate.sh counts them precisely so a run cannot claim to have checked what
it did not (#68). "This stack has no Alertmanager" is a verified fact about the
tree, not a gap in the run. Inflating SKIPPED with by-design absences is how
that number stops being read — the skip count is still 2, the same two as
before.

Two bugs in my own new code, found and fixed

  • mapfile -t STACKS < <(./scripts/stacks.sh) does not carry the
    subshell's exit status, so if ! mapfile ... always succeeds — the
    stack-list guard would never have fired. Now a command substitution.
  • ((n_committed)) && cp ... under set -e exits the script when the
    count is zero, which would have killed check_loki_rules.sh on a stack with
    dashboards and no rules.

Verification

make validate — all checks passed, 2 skipped (the same two as before).

Compose
  PASS lab: docker compose config
  PASS observability: docker compose config
Prometheus
  PASS lab: promtool check config
  PASS lab: promtool check rules (1 file(s))
  PASS lab: promtool test rules (1 file(s))
  PASS observability: promtool check rules (10 file(s))
  PASS observability: promtool test rules (8 file(s))
...
  PASS reloaded and claimed services all exist in some stack

make check-digests now covers both stacks — the lab's four images were
verified against the registry for the first time.

Each new guard was proved by introducing the fault, not by reading the code:

Fault introduced Result
mkdir stacks/empty fails, naming the directory and why
Renamed a SERVICES entry reloads blackbox-exportr, which no stack defines
Version pin in stacks/lab/README.md only compose.yaml may carry a version
Deleted lab.test.yaml 1 rule file(s) and no promtool tests … (#63)

Also corrected

stacks/lab/README.md and its dashboards README said "nothing validates this
stack" and "check_dashboards.py does not enforce them here". Both were true
when written and are now false, so both were rewritten — along with what
make validate still does not prove about that stack, which is that it has
never actually been run.

🤖 Generated with Claude Code

`STACK ?= observability` parameterised the lifecycle and secrets targets and
stopped. Every validator carried its own `stacks/observability` — validate.sh,
four Python checkers, check_loki_rules.sh, seed-validation-env.sh, ci.yml and
pin-digests.sh — so stacks/lab landed as a stack CI had never seen: compose not
`config`-checked, rules not promtool-tested, dashboards not checked against its
own datasources, images pinned and digest-verified by nothing.

scripts/stacks.sh is now the one definition of what a stack is, and everything
that iterates stacks reads it — including the Python checkers, because a second
implementation in Python would be a second definition, and it is the definition
that decides what gets checked at all. A stack is a directory under stacks/
holding a compose.yaml; a directory without one is an ERROR rather than a skip,
which is the acceptance criterion this issue asked for. Silently skipping a
malformed directory is indistinguishable from the defect being fixed.

Three checks got stronger rather than merely wider.

Rules without promtool unit tests are now a failure. `check rules` parses
PromQL and never asks whether an expression can be true — ContainerHighMemory
was unfireable for months while passing it (#63) — so a new stack arriving with
rules and no tests is that waiting to happen, and the moment to say so is when
the rules land.

The reload-config.sh cross-check split in two. SERVICES is the union across
stacks and reload-config.sh now skips what a stack does not declare, so "not
defined in this compose file" stopped being a defect on its own. Per file, an
entry the stack DOES declare must still carry the healthcheck the probe stands
on — now applied to every stack rather than only the estate's. Across the
complete set, every entry must be declared somewhere, which is what still
catches an array naming a service nothing has. ABSENT_BINARIES got the same
treatment. The completeness half is only claimed when the script discovered the
stacks itself; explicit paths mean the caller chose the scope.

check_docs.py's PROSE list globs stacks/*/README.md instead of naming the
estate's, so a version pin in a stack README goes stale loudly (#73).

Absences are reported as passes, not skips. A skip means "could not check, and
therefore proved nothing", and validate.sh counts them precisely so a run
cannot claim to have checked what it did not (#68). "This stack has no
Alertmanager" is a verified fact about the tree, not a gap in the run —
inflating SKIPPED with by-design absences is how that count stops being read.

Two bugs found while writing it, both in code this commit adds: `mapfile <
<(...)` does not carry the subshell's exit status, so the stack-list guard
would never have fired; and `((n)) && cp ...` under `set -e` exits the script
when n is zero, which would have killed check_loki_rules.sh on a stack with
dashboards and no rules.

Verified by introducing each fault: an empty stacks/ directory, a renamed
SERVICES entry, a version pin in the lab README, and a rule file with its tests
removed. All four fail with the sentence that names the cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The CI digest-pin-by-digest step can incorrectly pass if scripts/stacks.sh --paths fails due to the current process-substitution pipeline structure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR resolves #263 by making stack validation iterate over every directory under stacks/ (failing on malformed stack directories), so CI and make validate cover all stacks rather than being pinned to stacks/observability.

Changes:

  • Introduces scripts/stacks.sh as the single source of truth for stack enumeration (and validates compose.yaml presence).
  • Updates scripts/validate.sh, CI workflow, and supporting scripts to run validation per-stack (including per-stack .env seeding and per-stack checks).
  • Updates documentation to reflect multi-stack validation and stronger cross-stack assertions.
File summaries
File Description
stacks/lab/README.md Updates lab stack docs to reflect that CI/validate now cover the stack.
stacks/lab/grafana/dashboards/README.md Updates dashboard docs to reflect multi-stack dashboard validation behavior.
scripts/validate.sh Iterates validations across all stacks listed by scripts/stacks.sh and adjusts per-stack checks.
scripts/stacks.sh New: defines/validates the stack list by scanning stacks/*/compose.yaml.
scripts/seed-validation-env.sh Adds optional stack argument; derives guard requirements from that stack’s compose.yaml.
scripts/check_loki_rules.sh Adds --stack support and treats “no rules/dashboards” as a pass (not a skip).
scripts/check_docs.py Expands PROSE checks to include every stacks/*/README.md.
scripts/check_dashboards.py Adds --stack support and treats “no dashboards” as a pass for non-emit modes.
scripts/check_compose_health.py Adds --cross-stack mode and splits per-stack vs cross-stack assertions.
Makefile Makes digest pin/verify targets iterate across all stacks.
docs/roadmap.md Updates roadmap narrative to reflect #263 being addressed and the stronger checks.
.github/workflows/ci.yml Removes pinned STACK env and loops validation steps over all stacks.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/ci.yml
Comment on lines +142 to +144
done < <(./scripts/stacks.sh --paths | while read -r sd; do
awk '$1 == "image:" { print $2 }' "$sd/compose.yaml"
done)
Comment on lines +398 to +402
declared: set[str] = set()
for entry in listed:
compose_path = REPO / entry / "compose.yaml"
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
declared |= set(compose.get("services") or {})
Comment thread scripts/validate.sh
# A .env is required for the ${VAR:?} guards; seeded by the same script CI
# uses, so a variable added there cannot pass locally and fail in CI. Written
# to a temp file rather than ${STACK}/.env so a local run never leaves an .env
# to a temp file rather than ${sd}/.env so a local run never leaves an .env
Comment on lines +35 to +39
validators multi-stack. A dashboard committed to this directory is checked by
`make validate` and by CI the same way the estate's seven are: JSON validity,
unique uid, provisioned datasource references, and its PromQL and LogQL
parsed by promtool and by Loki's own ruler. Today it reports "no dashboards
— nothing to check", which is a pass and not a skip.
Base automatically changed from gerrrt/build-stacks-lab-264 to main September 4, 2026 15:02
@Gerrrt
Gerrrt merged commit bd3c04f into main Sep 4, 2026
4 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/multi-stack-validators-263 branch September 4, 2026 15:04
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.

Every validator is pinned to stacks/observability — a second stack would be checked by nothing

2 participants