From 8d5abeeefead13529dd5c12fc7763b3feb0d4de8 Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:50:36 -0700 Subject: [PATCH] ci: seed the validation .env from one script, not two copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The throwaway .env that gets compose.yaml's `${VAR:?}` guards past validation was built in two places: a `run:` block in ci.yml and a heredoc in validate.sh. Both copied .env.example and appended the same variables, and nothing tied them together. They drifted the first time the list changed. 2431173 added the RENDER_UID/RENDER_GID guards and updated only validate.sh, so the change passed locally and failed in CI: error while interpolating services.alertmanager.user: required variable RENDER_UID is missing a value: run make render and needed a follow-up commit (165394c) to fix the copy that was missed. Extract scripts/seed-validation-env.sh and have both callers invoke it, so the list of variables exists once. The script takes an output path and writes; lifetime stays with the caller, which is the only thing the two genuinely disagree about — CI wants the gitignored stacks/observability/.env, validate.sh wants an mktemp file it removes on a trap. Hardcoding either inside the script would have left it able to serve only one of them. Sharing the script stops the two copies drifting from each other, but both could still drift from compose.yaml, so the script also asserts it covers every `${VAR:?}` it finds there. A newly guarded variable now fails at seeding time and names itself: compose.yaml guards NEW_VAR, which the validation .env does not set. rather than surfacing as an interpolation error in whichever caller runs first. validate.sh runs without `set -e`, so a failed seed had to be caught explicitly or the compose check would have run against a half-written file and reported its confusing error instead; the existing if/else grew a leading `if ! seed` branch that reports through the usual fail(). Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 15 +++------- scripts/seed-validation-env.sh | 53 ++++++++++++++++++++++++++++++++++ scripts/validate.sh | 19 +++++------- 3 files changed, 64 insertions(+), 23 deletions(-) create mode 100755 scripts/seed-validation-env.sh 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