ci(terraform): fail mixed app and Terraform changes

This commit is contained in:
Adam Moussa 2026-10-01 20:32:36 -04:00
parent ee5b843ca1
commit 4c3e13a07a
No known key found for this signature in database
4 changed files with 243 additions and 0 deletions

View file

@ -10,6 +10,16 @@ name: CI — Terraform
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<sha> # vX.Y.Z # uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<sha> # vX.Y.Z
# with: # with:
# terraform-version: "1.16.0" # terraform-version: "1.16.0"
# app-paths: |
# src/
# package.json
# package-lock.json
#
# app-paths is optional. Empty skips isolation and leaves fmt/init/validate
# unchanged. A trailing slash is a directory prefix. Any other line is an
# exact file. pull_request classifies the merge-base of the base SHA to HEAD.
# merge_group classifies each first-parent commit against its parent, so a
# Terraform-only PR stacked with an app-only PR still passes.
on: on:
workflow_call: workflow_call:
@ -22,6 +32,10 @@ on:
description: "Directory containing Terraform sources" description: "Directory containing Terraform sources"
type: string type: string
default: "terraform" default: "terraform"
app-paths:
description: "Newline-separated deployable paths. A trailing slash is a prefix. Any other entry is an exact file. Empty skips isolation."
type: string
default: ""
permissions: permissions:
contents: read contents: read
@ -41,6 +55,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with: with:
@ -55,3 +70,50 @@ jobs:
- name: Terraform validate - name: Terraform validate
run: terraform validate run: terraform validate
- name: Checkout isolation checker
if: inputs.app-paths != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Sea-Haven-Industries/.github
ref: ${{ github.workflow_sha }}
path: .ci-org-github
persist-credentials: false
- name: App and Terraform isolation
if: inputs.app-paths != ''
working-directory: ${{ github.workspace }}
env:
APP_PATHS: ${{ inputs.app-paths }}
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
CHECKER: ${{ github.workspace }}/.ci-org-github/scripts/check_app_terraform_isolation.py
run: |
set -euo pipefail
classify() {
python3 "${CHECKER}"
}
case "${EVENT_NAME}" in
pull_request)
if [[ -z "${PR_BASE_SHA}" ]]; then
echo "FAIL: pull_request base SHA is empty" >&2
exit 1
fi
merge_base="$(git merge-base "${PR_BASE_SHA}" HEAD)"
git diff --name-only --diff-filter=ACMR "${merge_base}" HEAD | classify
;;
merge_group)
if [[ -z "${MERGE_GROUP_BASE_SHA}" ]]; then
echo "FAIL: merge_group base SHA is empty" >&2
exit 1
fi
while IFS= read -r sha; do
[[ -z "${sha}" ]] && continue
git diff --name-only --diff-filter=ACMR "${sha}^" "${sha}" | classify
done < <(git rev-list --reverse --first-parent "${MERGE_GROUP_BASE_SHA}..HEAD")
;;
*)
echo "SKIP: live isolation runs on pull_request and merge_group (event: ${EVENT_NAME})"
;;
esac

View file

@ -53,6 +53,10 @@ jobs:
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Isolation checker tests
run: python3 scripts/test_check_app_terraform_isolation.py
shell: bash
- name: Install actionlint - name: Install actionlint
env: env:
ACTIONLINT_VERSION: 1.7.12 ACTIONLINT_VERSION: 1.7.12

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Fail when a change set mixes Terraform with deployable application files.
APP_PATHS is newline-separated. A trailing slash is a directory prefix.
Any other entry is an exact file. Paths that are not listed are neutral, so
workflows, docs, and tests may travel with either side. An empty APP_PATHS
skips the check.
"""
from __future__ import annotations
import argparse
import os
import sys
def parse_app_rules(raw: str) -> tuple[frozenset[str], frozenset[str]]:
prefixes: set[str] = set()
exact: set[str] = set()
for line in raw.splitlines():
item = line.strip().replace("\\", "/")
if not item or item.startswith("#"):
continue
if item.endswith("/"):
prefixes.add(item)
else:
exact.add(item)
return frozenset(prefixes), frozenset(exact)
def is_terraform_path(path: str) -> bool:
normalized = path.replace("\\", "/")
return normalized == "terraform" or normalized.startswith("terraform/")
def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool:
normalized = path.replace("\\", "/")
if normalized in exact:
return True
for prefix in prefixes:
if normalized.startswith(prefix) or f"{normalized}/" == prefix:
return True
return False
def isolation_violation(
paths: list[str], app_paths: str
) -> tuple[list[str], list[str]] | None:
prefixes, exact = parse_app_rules(app_paths)
if not prefixes and not exact:
return None
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)})
if terraform_files and app_files:
return terraform_files, app_files
return None
def first_isolation_violation(
file_sets: list[list[str]], app_paths: str
) -> tuple[list[str], list[str]] | None:
for paths in file_sets:
violation = isolation_violation(paths, app_paths)
if violation is not None:
return violation
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
help="Changed paths. Omit and pass newline-separated paths on stdin.",
)
args = parser.parse_args()
paths = list(args.paths)
if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()]
violation = isolation_violation(paths, os.environ.get("APP_PATHS", ""))
if violation is None:
print("PASS: application and Terraform changes are isolated")
return 0
terraform_files, app_files = violation
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
print("terraform:", file=sys.stderr)
for path in terraform_files:
print(f" {path}", file=sys.stderr)
print("application:", file=sys.stderr)
for path in app_files:
print(f" {path}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Tests for check_app_terraform_isolation."""
from __future__ import annotations
import unittest
from check_app_terraform_isolation import first_isolation_violation, isolation_violation
APP_PATHS = "src/\npackage.json\npackage-lock.json\n"
class IsolationTests(unittest.TestCase):
def test_terraform_only(self) -> None:
self.assertIsNone(
isolation_violation(
["terraform/lambda.tf", "terraform/README.md"],
APP_PATHS,
)
)
def test_app_only(self) -> None:
self.assertIsNone(
isolation_violation(
["src/processPaymentCsv.js", "package.json", "package-lock.json"],
APP_PATHS,
)
)
def test_docs_and_workflows_with_terraform(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/lambda.tf",
".github/workflows/deploy.yaml",
"SETUP.md",
"scripts/check_app_terraform_isolation.py",
],
APP_PATHS,
)
)
def test_mixed_app_and_terraform_fails(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js", "package.json"],
APP_PATHS,
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/lambda.tf"])
self.assertEqual(app_files, ["package.json", "src/processPaymentCsv.js"])
def test_empty_app_paths_skips(self) -> None:
self.assertIsNone(
isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js"],
"",
)
)
def test_separate_commits_pass_when_classified_alone(self) -> None:
self.assertIsNone(
first_isolation_violation(
[
["terraform/lambda.tf"],
["src/processPaymentCsv.js"],
],
APP_PATHS,
)
)
def test_union_of_separate_commits_fails(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/processPaymentCsv.js"],
APP_PATHS,
)
self.assertIsNotNone(violation)
if __name__ == "__main__":
unittest.main()