From 08da408a13bb803be19b83bfdea118bedc258e85 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 10 Sep 2026 19:15:13 -0400 Subject: [PATCH] ci(governance): wire Terraform and CDK gates and isolate Terraform PRs Governance now runs the import-plan checker tests, Terraform fmt and validate for terraform/live/dev, the isolation gate tests, and the CDK build, tests, and synth in both modes. A new terraform-isolation workflow fails PRs that change terraform/** together with application code; the terraform-isolation-override label is the reviewed exception. Renovate gains the terraform manager. --- .github/renovate.json | 8 +- .github/workflows/ci.yaml | 15 ++- .github/workflows/terraform-isolation.yaml | 35 ++++++ package.json | 4 + scripts/check-terraform-isolation.mjs | 120 +++++++++++++++++++++ scripts/check-terraform-isolation.test.mjs | 115 ++++++++++++++++++++ scripts/governance-check.mjs | 33 ++++++ scripts/terraform-validate.mjs | 35 ++++++ 8 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/terraform-isolation.yaml create mode 100644 scripts/check-terraform-isolation.mjs create mode 100644 scripts/check-terraform-isolation.test.mjs create mode 100644 scripts/terraform-validate.mjs diff --git a/.github/renovate.json b/.github/renovate.json index af6c5ee0..38bd081a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "enabledManagers": ["npm", "custom.regex"], + "enabledManagers": ["npm", "custom.regex", "terraform"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", "customManagers": [ @@ -17,6 +17,12 @@ } ], "packageRules": [ + { + "description": ["Group non-major Terraform provider updates"], + "matchManagers": ["terraform"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "terraform minor and patch" + }, { "description": ["Do not open major or replacement PRs until approved on the dashboard"], "matchUpdateTypes": ["major", "replacement"], diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2222ef4f..9a39ad3b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,9 +23,11 @@ jobs: # repository, independent of (and in addition to) the reusable workflow. # `npm run verify` is the single command that chains: format check, lint # (--max-warnings=0), type-check + build, unit tests, then the governance - # checks in scripts/governance-check.mjs (godfile ratchet + changed-file - # maintainability gate). If the reusable workflow is later confirmed to run - # every gate, this job can be slimmed to `npm run governance`. + # checks in scripts/governance-check.mjs (godfile ratchet, changed-file + # maintainability gate, Terraform fmt/validate, Terraform import-plan guard + # tests, Terraform isolation gate tests, CDK build/test/synth). If the + # reusable workflow is later confirmed to run every gate, this job can be + # slimmed to `npm run governance`. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) @@ -53,6 +55,13 @@ jobs: base="origin/dev" fi printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - name: Set up Terraform + # Same minor as the HCP workspace (1.16.x) so fmt/validate see what + # the remote run will see. + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" diff --git a/.github/workflows/terraform-isolation.yaml b/.github/workflows/terraform-isolation.yaml new file mode 100644 index 00000000..14c53730 --- /dev/null +++ b/.github/workflows/terraform-isolation.yaml @@ -0,0 +1,35 @@ +name: Terraform isolation + +# Fails a pull request that changes `terraform/**` together with deployable +# application code (see scripts/check-terraform-isolation.mjs). A merge that +# does both queues an HCP VCS run and a content release at the same time, and +# the two race for the workspace lock. +# +# Runs on label events too, so adding or removing the +# `terraform-isolation-override` label re-evaluates the gate without a push. + +on: + pull_request: + branches: [main, dev, staging] + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + terraform-isolation: + name: Terraform and application changes are isolated + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Check changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }} + run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}" diff --git a/package.json b/package.json index ed95210d..50346ac3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "test:e2e": "playwright test", "test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:ui": "playwright test --ui", + "test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py", + "test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs", + "test:terraform": "node scripts/terraform-validate.mjs", + "test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/check-terraform-isolation.mjs b/scripts/check-terraform-isolation.mjs new file mode 100644 index 00000000..7b28c149 --- /dev/null +++ b/scripts/check-terraform-isolation.mjs @@ -0,0 +1,120 @@ +// Terraform/application change isolation gate. +// +// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run +// on the workspace. If the same merge also changes deployable application +// code, the content release and the VCS run race for the workspace lock +// (backend incident, 2026-09-04). This gate fails a pull request that mixes the +// two, so Terraform changes ship in their own PR and their VCS run is confirmed +// or discarded by a human before the next content release. +// +// Files that may accompany a Terraform change without triggering a release: +// the Terraform tree itself, its plan-guard tooling, and documentation. +// +// Usage: +// node scripts/check-terraform-isolation.mjs --base --head +// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin +// +// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets +// it only when the PR carries the `terraform-isolation-override` label, which +// reviewers grant to the rare change that must introduce Terraform variables +// together with the workflow that consumes them. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const OVERRIDE_LABEL = "terraform-isolation-override"; + +export function isTerraformPath(file) { + return file.startsWith("terraform/"); +} + +export function mayAccompanyTerraform(file) { + if (isTerraformPath(file)) return true; + if (file.endsWith(".md")) return true; + if (file.startsWith("docs/")) return true; + if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true; + return false; +} + +/** + * @param {string[]} files changed paths relative to the repository root + * @returns {{ terraform: string[], application: string[], mixed: boolean }} + */ +export function classifyChangedFiles(files) { + const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort(); + const terraform = unique.filter(isTerraformPath); + const application = unique.filter((file) => !mayAccompanyTerraform(file)); + return { + terraform, + application, + mixed: terraform.length > 0 && application.length > 0, + }; +} + +function changedFilesFromGit(base, head) { + const mergeBase = execFileSync("git", ["merge-base", base, head], { + cwd: ROOT, + encoding: "utf8", + }).trim(); + return execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head], + { cwd: ROOT, encoding: "utf8" }, + ) + .split("\n") + .filter(Boolean); +} + +function parseArgs(argv) { + const options = { base: null, head: "HEAD", stdin: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--base") options.base = argv[++index]; + else if (argument === "--head") options.head = argv[++index]; + else if (argument === "--stdin") options.stdin = true; + else throw new Error(`unknown argument: ${argument}`); + } + if (!options.stdin && !options.base) { + throw new Error("provide --base (and optionally --head ) or --stdin"); + } + return options; +} + +function main(argv) { + const options = parseArgs(argv); + const files = options.stdin + ? readFileSync(0, "utf8").split("\n") + : changedFilesFromGit(options.base, options.head); + const result = classifyChangedFiles(files); + const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true"; + + console.log("─".repeat(64)); + console.log( + `terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`, + ); + if (!result.mixed) { + console.log(" PASS: Terraform and application changes are not mixed"); + return 0; + } + console.log(" Terraform files:"); + for (const file of result.terraform) console.log(` ${file}`); + console.log(" Application files that cannot ship in the same PR:"); + for (const file of result.application) console.log(` ${file}`); + if (override) { + console.log( + ` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`, + ); + return 0; + } + console.log( + ` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`, + ); + return 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/check-terraform-isolation.test.mjs b/scripts/check-terraform-isolation.test.mjs new file mode 100644 index 00000000..24c6c87d --- /dev/null +++ b/scripts/check-terraform-isolation.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + OVERRIDE_LABEL, + classifyChangedFiles, + mayAccompanyTerraform, +} from "./check-terraform-isolation.mjs"; + +const SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "check-terraform-isolation.mjs", +); + +function runGate(files, env = {}) { + return spawnSync(process.execPath, [SCRIPT, "--stdin"], { + input: `${files.join("\n")}\n`, + encoding: "utf8", + env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env }, + }); +} + +test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => { + for (const file of [ + "terraform/live/dev/main.tf", + "terraform/live/modules/environment-owned/main.tf", + "terraform/README.md", + "README.md", + "docs/adr/0003-terraform.md", + "scripts/check-terraform-import-plan.py", + "scripts/terraform_import_plan_resources.py", + "scripts/test-terraform-import-plan-check.py", + "scripts/terraform-validate.mjs", + "scripts/check-terraform-isolation.mjs", + ]) { + assert.equal(mayAccompanyTerraform(file), true, file); + } +}); + +test("application, workflow, CDK, and dependency files count as application changes", () => { + for (const file of [ + "src/App.tsx", + "public/favicon.ico", + "index.html", + "package.json", + "package-lock.json", + ".env.production", + "vite.config.ts", + ".github/workflows/deploy.yml", + "infra/cdk/lib/frontend-stack.ts", + "scripts/deploy-web.sh", + "scripts/governance-check.mjs", + "e2e/login.spec.ts", + ]) { + assert.equal(mayAccompanyTerraform(file), false, file); + } +}); + +test("terraform-only and application-only changes are not mixed", () => { + assert.equal( + classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed, + false, + ); + assert.equal( + classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed, + false, + ); + assert.equal(classifyChangedFiles([]).mixed, false); +}); + +test("terraform plus application is mixed and lists the offending files", () => { + const result = classifyChangedFiles([ + "terraform/live/dev/main.tf", + "src/App.tsx", + "README.md", + " ", + "src/App.tsx", + ]); + assert.equal(result.mixed, true); + assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]); + assert.deepEqual(result.application, ["src/App.tsx"]); +}); + +test("CLI exits 1 on a mixed change and 0 when isolated", () => { + const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]); + assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr); + assert.match(mixed.stdout, /FAIL/); + assert.match(mixed.stdout, /src\/App\.tsx/); + + const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]); + assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr); + assert.match(isolated.stdout, /PASS/); +}); + +test("CLI override downgrades a mixed change to a warning that names the label", () => { + const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "true", + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /WARNING/); + assert.match(result.stdout, new RegExp(OVERRIDE_LABEL)); + + const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], { + TERRAFORM_ISOLATION_OVERRIDE: "yes", + }); + assert.equal(notTrue.status, 1); +}); + +test("CLI refuses to run without a base ref or --stdin", () => { + const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" }); + assert.notEqual(result.status, 0); +}); diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 8cabc70f..f777ce09 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [ const GOVERNED_ROOTS = ["src/", "config/"]; const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//; const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/; +// Repository-level gates that run after the source gates. Each is an npm +// script so it can also be run on its own. +const REPOSITORY_GATES = [ + ["Terraform import-plan contract", "test:terraform-import-plan"], + ["Terraform isolation gate", "test:terraform-isolation"], + ["Terraform formatting and validation", "test:terraform"], + ["CDK build, tests, and synth", "test:infra"], +]; function isGoverned(relativePath) { return ( @@ -194,6 +202,20 @@ function plural(count, word) { return `${count} ${word}${count === 1 ? "" : "s"}`; } +function runRepositoryGate(label, script) { + // Reuse the npm that launched us when available (matches its version and + // config); fall back to PATH for direct `node scripts/governance-check.mjs`. + const npmCli = process.env.npm_execpath; + const executable = npmCli ? process.execPath : "npm"; + const args = npmCli ? [npmCli, "run", script] : ["run", script]; + const result = spawnSync(executable, args, { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + }); + return { label, status: result.status, error: result.error }; +} + function main() { const failures = []; const baseRef = resolveBaseRef(); @@ -281,6 +303,17 @@ function main() { } } + for (const [label, script] of REPOSITORY_GATES) { + console.log("─".repeat(64)); + console.log(`${label}: npm run ${script}`); + const gate = runRepositoryGate(label, script); + if (gate.error) { + failures.push(`${label}: could not start: ${gate.error.message}`); + } else if (gate.status !== 0) { + failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`); + } + } + console.log("─".repeat(64)); if (failures.length > 0) { console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`); diff --git a/scripts/terraform-validate.mjs b/scripts/terraform-validate.mjs new file mode 100644 index 00000000..59ee4140 --- /dev/null +++ b/scripts/terraform-validate.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TERRAFORM = process.env.TERRAFORM_BIN || "terraform"; +// Only dev has a live root. Staging adoption (SH-287) adds its own root here. +const ENVIRONMENTS = ["dev"]; +const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment)); + +function run(args, cwd = ROOT) { + const result = spawnSync(TERRAFORM, args, { + cwd, + encoding: "utf8", + stdio: "inherit", + }); + if (result.error) { + throw new Error(`could not start Terraform: ${result.error.message}`, { + cause: result.error, + }); + } + if (result.status !== 0) { + throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`); + } +} + +run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]); +for (const root of ROOTS) { + // -backend=false never touches HCP state; -lockfile=readonly refuses to + // silently rewrite the committed provider lock. + run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root); + run(["validate", "-no-color"], root); +} + +console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);