mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 06:32:05 +00:00
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:
parent
87e79072ad
commit
08da408a13
8 changed files with 361 additions and 4 deletions
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"enabledManagers": ["npm", "custom.regex"],
|
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||||
"minimumReleaseAge": "3 days",
|
"minimumReleaseAge": "3 days",
|
||||||
"internalChecksFilter": "strict",
|
"internalChecksFilter": "strict",
|
||||||
"customManagers": [
|
"customManagers": [
|
||||||
|
|
@ -17,6 +17,12 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packageRules": [
|
"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"],
|
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
|
||||||
"matchUpdateTypes": ["major", "replacement"],
|
"matchUpdateTypes": ["major", "replacement"],
|
||||||
|
|
|
||||||
15
.github/workflows/ci.yaml
vendored
15
.github/workflows/ci.yaml
vendored
|
|
@ -23,9 +23,11 @@ jobs:
|
||||||
# repository, independent of (and in addition to) the reusable workflow.
|
# repository, independent of (and in addition to) the reusable workflow.
|
||||||
# `npm run verify` is the single command that chains: format check, lint
|
# `npm run verify` is the single command that chains: format check, lint
|
||||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||||
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
|
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||||
# maintainability gate). If the reusable workflow is later confirmed to run
|
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard
|
||||||
# every gate, this job can be slimmed to `npm run governance`.
|
# 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:
|
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||||
# PR -> the PR target branch (origin/<base_ref>)
|
# PR -> the PR target branch (origin/<base_ref>)
|
||||||
|
|
@ -53,6 +55,13 @@ jobs:
|
||||||
base="origin/dev"
|
base="origin/dev"
|
||||||
fi
|
fi
|
||||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
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
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
|
||||||
35
.github/workflows/terraform-isolation.yaml
vendored
Normal file
35
.github/workflows/terraform-isolation.yaml
vendored
Normal 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}"
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"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": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|
|
||||||
120
scripts/check-terraform-isolation.mjs
Normal file
120
scripts/check-terraform-isolation.mjs
Normal 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)));
|
||||||
|
}
|
||||||
115
scripts/check-terraform-isolation.test.mjs
Normal file
115
scripts/check-terraform-isolation.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [
|
||||||
const GOVERNED_ROOTS = ["src/", "config/"];
|
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
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) {
|
function isGoverned(relativePath) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -194,6 +202,20 @@ function plural(count, word) {
|
||||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
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() {
|
function main() {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const baseRef = resolveBaseRef();
|
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));
|
console.log("─".repeat(64));
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||||
|
|
|
||||||
35
scripts/terraform-validate.mjs
Normal file
35
scripts/terraform-validate.mjs
Normal 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(", ")}.`);
|
||||||
Loading…
Add table
Reference in a new issue