diff --git a/.github/workflows/secrets-codegen-check.yml b/.github/workflows/secrets-codegen-check.yml new file mode 100644 index 00000000..c50f19e3 --- /dev/null +++ b/.github/workflows/secrets-codegen-check.yml @@ -0,0 +1,135 @@ +name: Secrets codegen drift check + +# Verifies that the SOPS-encrypted runtime payloads embedded in @gen/env are +# in sync with the source-of-truth SOPS YAMLs under .stack/secrets/vars/. +# +# Why this exists (see beads stackpanel-04d for full context): +# The runtime alchemy deploy reads the embedded payload at +# packages/gen/env/src/runtime/generated-payloads/_envs/.ts +# and the encrypted JSON at +# packages/gen/env/data/_envs/.sops.json +# NOT the source SOPS YAML directly. Those embedded files only get +# regenerated by `stackpanel codegen build env`, which runs as part of +# the devshell shell-hook. If a contributor edits a SOPS source (via +# `sops`, `chore: rekey`, `himitsu set`, etc.) and commits without +# re-entering the devshell, the embedded payload silently keeps shipping +# the old plaintext. This was the bug behind PR #15/#17: a Cloudflare +# API token rotation merged to main but the embedded payload still +# carried the under-scoped previous token, so every deploy 401'd. +# +# This workflow re-runs codegen in CI and fails if it produced any change +# under the embedded-payload tree. The remediation is printed inline so +# contributors don't need to dig through docs. +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + # Source SOPS YAMLs (everything in .stack/secrets/ except generated/cached state) + - ".stack/secrets/vars/**" + - ".stack/secrets/apps/**" + - ".stack/secrets/**.yaml" + # Nix-side env declarations (apps..env / stackpanel.envs.) + - ".stack/config.nix" + - ".stack/data/**.nix" + - "nix/stackpanel/db/schemas/secrets**" + - "nix/stackpanel/lib/codegen/**" + - "nix/stackpanel/modules/env-codegen/**" + # The codegen implementation itself + - "apps/stackpanel-go/internal/codegen/**" + # The output tree (catches manual edits / accidental rollbacks) + - "packages/gen/env/data/**" + - "packages/gen/env/src/runtime/generated-payloads/**" + # The workflow file + - ".github/workflows/secrets-codegen-check.yml" + push: + branches: [main] + paths: + - ".stack/secrets/vars/**" + - ".stack/secrets/apps/**" + - ".stack/secrets/**.yaml" + - ".stack/config.nix" + - ".stack/data/**.nix" + - "nix/stackpanel/db/schemas/secrets**" + - "nix/stackpanel/lib/codegen/**" + - "nix/stackpanel/modules/env-codegen/**" + - "apps/stackpanel-go/internal/codegen/**" + - "packages/gen/env/data/**" + - "packages/gen/env/src/runtime/generated-payloads/**" + - ".github/workflows/secrets-codegen-check.yml" + workflow_dispatch: + +concurrency: + group: secrets-codegen-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + with: + extra-conf: | + accept-flake-config = true + extra-substituters = https://devenv.cachix.org https://darkmatter.cachix.org + extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= darkmatter.cachix.org-1:7R5qAiOVHxDpFy7yguECfC1JqVDgMdckGc+CDKk2pWA= + + - name: Setup Cachix + uses: cachix/cachix-action@v16 + with: + name: darkmatter + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + extraPullNames: devenv + + - name: Run codegen + env: + # Same key the deploy workflows use — it has decrypt access for every + # SOPS file under .stack/secrets/vars/ and packages/gen/env/data/. + # The codegen needs to *decrypt* the source YAMLs (to resolve env vars) + # and then re-*encrypt* the resolved plaintext back into the embedded + # payload, both of which require this key. + SOPS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY_DEV }} + run: | + set -euo pipefail + # Devshell entry runs the shell-hook which: + # 1. writes .stack/gen/codegen/env-manifest.json (the codegen input) + # 2. invokes `stackpanel codegen build` itself + # We re-invoke `stackpanel codegen build` explicitly afterwards as a + # belt-and-braces guarantee that the build was run with the same SOPS + # key the embedded payloads were encrypted with. + nix develop --impure --command bash -lc ' + set -euo pipefail + stackpanel codegen build + ' + + - name: Verify no drift + run: | + set -euo pipefail + # Limit the diff to the files that actually matter for runtime drift. + # We deliberately do NOT diff `packages/gen/env/src//...` because + # those are the typed env wrappers — codegen rewrites them on every + # devshell entry (timestamp/comment churn) and that churn is + # cosmetically noisy without affecting deploy behaviour. See + # stackpanel-04d for the rationale: the ONLY drift class that broke + # production was the embedded encrypted payload + its TS wrapper. + target_paths=( + packages/gen/env/data/_envs + packages/gen/env/src/runtime/generated-payloads/_envs + ) + + if git diff --quiet -- "${target_paths[@]}"; then + echo "OK: embedded SOPS payloads are in sync with source schemas." + exit 0 + fi + + echo "::error title=Embedded SOPS payloads are stale::Run \`nix develop --impure --command stackpanel codegen build\` and commit the resulting changes under packages/gen/env/." + echo + echo "===== drift detected in =====" >&2 + git diff --name-only -- "${target_paths[@]}" >&2 + echo + echo "===== diff (truncated to 200 lines) =====" >&2 + git diff -- "${target_paths[@]}" | head -200 >&2 + + exit 1