mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 22:03:11 +00:00
* feat(terraform): ship dev content CD through Terraform (SH-300) GitHub uploads immutable release prefixes; Terraform owns live publish. Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set. * fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
137 lines
5.2 KiB
JavaScript
137 lines
5.2 KiB
JavaScript
// 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. 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 <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)));
|
|
}
|