diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af13c9..818b159 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,18 +54,11 @@ jobs: # The ${VAR:?} guards in compose.yaml exist so a missing secret fails # loudly at deploy time. CI supplies throwaway values so validation never - # needs a decryption key. + # needs a decryption key. The list of variables lives in the script, which + # scripts/validate.sh also calls — inlining it here is what let CI and the + # local run drift apart. The .env written here stays gitignored. - name: Seed a validation-only .env - run: | - cp "$STACK/.env.example" "$STACK/.env" - { - echo "GRAFANA_ADMIN_PASSWORD=validation-only" - # RENDER_UID/GID are written by render-config.sh from the deploying - # user, so they are host-specific and absent from .env.example. Any - # value satisfies the ${VAR:?} guards that compose.yaml sets on them. - echo "RENDER_UID=65534" - echo "RENDER_GID=65534" - } >> "$STACK/.env" + run: ./scripts/seed-validation-env.sh "$STACK/.env" # Single source of truth: whatever compose.yaml pins is what gets tested. - name: Resolve pinned images from compose.yaml diff --git a/scripts/seed-validation-env.sh b/scripts/seed-validation-env.sh new file mode 100755 index 0000000..e120be2 --- /dev/null +++ b/scripts/seed-validation-env.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Write a throwaway .env that satisfies compose.yaml's ${VAR:?} guards. +# +# The guards exist so a missing secret fails loudly at deploy time rather than +# starting a stack with defaults. Validation still has to get past them, so both +# CI and scripts/validate.sh need an .env holding values that are never +# deployed. That list used to be written out twice — once in +# .github/workflows/ci.yml and once in scripts/validate.sh — and it drifted the +# first time it changed: the commit adding the RENDER_UID/RENDER_GID guards +# updated only validate.sh, so it passed locally and failed in CI. +# +# Nothing written here is a secret and nothing here is ever deployed. The values +# only need to exist. +# +# Usage: scripts/seed-validation-env.sh +# +# The caller owns the output path and its cleanup: CI writes the gitignored +# stacks/observability/.env, validate.sh writes an mktemp file it removes on +# exit. Keeping lifetime out here is what lets one script serve both. + +set -euo pipefail + +OUT="${1:?usage: seed-validation-env.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STACK="${REPO_ROOT}/stacks/observability" + +cat "${STACK}/.env.example" > "${OUT}" +{ + echo "GRAFANA_ADMIN_PASSWORD=validation-only" + # RENDER_UID/GID are written to .env by render-config.sh from the deploying + # user, so they are host-specific and deliberately absent from .env.example. + echo "RENDER_UID=65534" + echo "RENDER_GID=65534" +} >> "${OUT}" + +# Sharing this script stops the two callers drifting from each other. This check +# stops both of them drifting from compose.yaml: a newly added ${VAR:?} guard +# fails here, naming the variable, instead of surfacing later as an opaque +# compose interpolation error in whichever caller runs first. +missing=() +while read -r var; do + [[ -n "${var}" ]] || continue + grep -qE "^${var}=" "${OUT}" || missing+=("${var}") +done < <(grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*:\?' "${STACK}/compose.yaml" \ + | sed 's/^\${//; s/:?$//' | sort -u) + +if ((${#missing[@]})); then + printf 'compose.yaml guards %s, which the validation .env does not set.\n' \ + "${missing[*]}" >&2 + printf 'Add a throwaway value for it to %s\n' "${BASH_SOURCE[0]}" >&2 + exit 1 +fi diff --git a/scripts/validate.sh b/scripts/validate.sh index 191764a..fe040be 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -34,20 +34,15 @@ have_docker() { have docker && docker info >/dev/null 2>&1; } head_ "Compose" # --------------------------------------------------------------------------- if have docker; then - # A .env is required for the ${VAR:?} guards; use the example values plus - # throwaway secrets so validation does not depend on a decryption key. + # 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 + # sitting next to a real one. TMP_ENV="$(mktemp)" trap 'rm -f "${TMP_ENV}"' EXIT - cat "${STACK}/.env.example" > "${TMP_ENV}" - { - echo "GRAFANA_ADMIN_PASSWORD=validation-only" - # RENDER_UID/GID come from the deploying user via render-config.sh and are - # host-specific, so they are deliberately not in .env.example. Any value - # validates — this only needs to satisfy the ${VAR:?} guards. - echo "RENDER_UID=65534" - echo "RENDER_GID=65534" - } >> "${TMP_ENV}" - if docker compose --env-file "${TMP_ENV}" -f "${STACK}/compose.yaml" config -q 2>/dev/null; then + if ! ./scripts/seed-validation-env.sh "${TMP_ENV}"; then + fail "seed a validation-only .env" + elif docker compose --env-file "${TMP_ENV}" -f "${STACK}/compose.yaml" config -q 2>/dev/null; then pass "docker compose config" else docker compose --env-file "${TMP_ENV}" -f "${STACK}/compose.yaml" config -q