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.
This commit is contained in:
Adam Moussa 2026-09-10 19:15:13 -04:00
parent 87e79072ad
commit 08da408a13
No known key found for this signature in database
8 changed files with 361 additions and 4 deletions

View file

@ -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"],

View file

@ -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/<base_ref>)
@ -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"

View file

@ -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}"

View file

@ -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 .",

View file

@ -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 <ref> --head <ref>
// 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 <ref> (and optionally --head <ref>) 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)));
}

View file

@ -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);
});

View file

@ -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")})`);

View file

@ -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(", ")}.`);