pez-infra/.github/workflows/validate-terraform.yml
Rasmus Wejlgaard a218acac34 ci: extract shared SOPS/tofu steps into composite actions
The SOPS install + version, the decrypt loop, the OpenTofu version, and
the Backblaze backend-credential extraction were copy-pasted across
terraform.yml (twice), validate-terraform.yml, and _deploy-core.yml.
A version bump meant editing the same string in up to four places and
was easy to do partially.

Pull them into three local composite actions so each is defined once:
  - setup-tofu          (pins OpenTofu version)
  - sops-decrypt        (installs SOPS, decrypts *.enc.* in place)
  - tofu-backend-creds  (exports Backblaze S3 creds to GITHUB_ENV)

Behaviour is unchanged; sops-decrypt also matches *.enc.env everywhere
(previously only _deploy-core did), which is a no-op in terraform/.
2026-06-18 20:23:35 +01:00

122 lines
4.3 KiB
YAML

name: Validate Terraform
on:
pull_request:
paths:
- "terraform/**"
- ".github/workflows/validate-terraform.yml"
permissions:
contents: read
pull-requests: write
# Requires these repository secrets:
# AGE_SECRET_KEY — age private key for SOPS decryption
#
# Dependabot PRs run with no access to these secrets and a read-only token,
# so they take a lightweight, secret-free path (init + validate, no plan/
# comment). Provider-version bumps are still resolved and validated.
jobs:
plan:
name: tofu plan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-tofu
# --- Dependabot: secret-free validation -------------------------------
- name: Validate (no secrets)
if: github.actor == 'dependabot[bot]'
working-directory: terraform/
run: |
# secrets.yaml is decrypted from SOPS at plan time and can't be
# produced here, so stub the keys the config reads (kept in sync by
# deriving them from the actual secrets["..."] references).
# Stub values must satisfy provider config validators: hcloud
# requires a 64-char token, and Grafana's fleet_management_auth
# must look like `username:password`.
stub64=$(printf 'stub%.0s' {1..16})
grep -rhoE 'secrets\["[^"]+"\]' . \
| sed -E 's/.*secrets\["([^"]+)"\].*/\1/' \
| sort -u \
| while read -r key; do
case "$key" in
*_auth) echo "$key: \"stub:stub\"" ;;
*) echo "$key: \"$stub64\"" ;;
esac
done > secrets.yaml
tofu init -backend=false
tofu validate
# --- Human PRs: full plan against real backend ------------------------
- name: Decrypt secrets
if: github.actor != 'dependabot[bot]'
uses: ./.github/actions/sops-decrypt
with:
age-key: ${{ secrets.AGE_SECRET_KEY }}
- name: Set backend credentials
if: github.actor != 'dependabot[bot]'
uses: ./.github/actions/tofu-backend-creds
- name: tofu init
if: github.actor != 'dependabot[bot]'
working-directory: terraform/
run: tofu init
- name: tofu validate
if: github.actor != 'dependabot[bot]'
working-directory: terraform/
run: tofu validate
- name: tofu plan
id: plan
if: github.actor != 'dependabot[bot]'
working-directory: terraform/
continue-on-error: true
run: |
set -o pipefail
tofu plan -no-color 2>&1 | tee plan_output.txt
- name: Post plan as PR comment
if: github.actor != 'dependabot[bot]'
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const raw = fs.readFileSync('terraform/plan_output.txt', 'utf8');
const filtered = raw.split('\n').filter(l => !l.includes(': Refreshing state...')).join('\n');
const truncated = filtered.length > 65000
? filtered.slice(0, 65000) + '\n\n...(output truncated)'
: filtered;
const outcome = '${{ steps.plan.outcome }}';
const header = outcome === 'failure' ? '## Terraform Plan — FAILED' : '## Terraform Plan';
const body = `${header}\n\`\`\`\n${truncated}\n\`\`\``;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.startsWith('## Terraform Plan'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if plan failed
if: github.actor != 'dependabot[bot]' && steps.plan.outcome == 'failure'
run: exit 1