// 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. The checker has no memory of // a previous pass: the same mixed diff fails again as soon as the override // env is unset (label removal). 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/"); } // Markdown under terraform/ does not queue an HCP VCS run (workspace triggers // are terraform/live/dev/** and terraform/live/modules/**), so it is not a // Terraform change for the mixed-PR check. export function isTerraformInfrastructurePath(file) { return isTerraformPath(file) && !file.endsWith(".md"); } 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; if ( /^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test( file, ) ) { return true; } if (file.startsWith("scripts/testdata/terraform-")) 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(isTerraformInfrastructurePath); 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))); }