fix(ci): count deletions and honor the Terraform working directory

Deleted paths were excluded from the isolation diff, so a mixed change could pass. The checker now treats working-directory as the Terraform prefix.
This commit is contained in:
Adam Moussa 2026-10-01 20:42:40 -04:00
parent 4c3e13a07a
commit e7712e6d0f
No known key found for this signature in database
3 changed files with 180 additions and 11 deletions

View file

@ -19,7 +19,8 @@ name: CI — Terraform
# unchanged. A trailing slash is a directory prefix. Any other line is an # 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. # 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 # merge_group classifies each first-parent commit against its parent, so a
# Terraform-only PR stacked with an app-only PR still passes. # Terraform-only PR stacked with an app-only PR still passes. The classified
# diff includes deletions. Terraform paths are those under working-directory.
on: on:
workflow_call: workflow_call:
@ -85,6 +86,7 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env: env:
APP_PATHS: ${{ inputs.app-paths }} APP_PATHS: ${{ inputs.app-paths }}
TERRAFORM_DIR: ${{ inputs.working-directory }}
EVENT_NAME: ${{ github.event_name }} EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
@ -101,7 +103,7 @@ jobs:
exit 1 exit 1
fi fi
merge_base="$(git merge-base "${PR_BASE_SHA}" HEAD)" merge_base="$(git merge-base "${PR_BASE_SHA}" HEAD)"
git diff --name-only --diff-filter=ACMR "${merge_base}" HEAD | classify git diff --name-only --diff-filter=ACMRD "${merge_base}" HEAD | classify
;; ;;
merge_group) merge_group)
if [[ -z "${MERGE_GROUP_BASE_SHA}" ]]; then if [[ -z "${MERGE_GROUP_BASE_SHA}" ]]; then
@ -110,7 +112,7 @@ jobs:
fi fi
while IFS= read -r sha; do while IFS= read -r sha; do
[[ -z "${sha}" ]] && continue [[ -z "${sha}" ]] && continue
git diff --name-only --diff-filter=ACMR "${sha}^" "${sha}" | classify git diff --name-only --diff-filter=ACMRD "${sha}^" "${sha}" | classify
done < <(git rev-list --reverse --first-parent "${MERGE_GROUP_BASE_SHA}..HEAD") done < <(git rev-list --reverse --first-parent "${MERGE_GROUP_BASE_SHA}..HEAD")
;; ;;
*) *)

View file

@ -5,6 +5,9 @@ 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 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 workflows, docs, and tests may travel with either side. An empty APP_PATHS
skips the check. skips the check.
TERRAFORM_DIR is the Terraform working directory (default terraform). A path
is Terraform when it equals that directory or sits under it.
""" """
from __future__ import annotations from __future__ import annotations
@ -28,9 +31,15 @@ def parse_app_rules(raw: str) -> tuple[frozenset[str], frozenset[str]]:
return frozenset(prefixes), frozenset(exact) return frozenset(prefixes), frozenset(exact)
def is_terraform_path(path: str) -> bool: def terraform_prefix(terraform_dir: str) -> str:
prefix = terraform_dir.replace("\\", "/").strip().strip("/")
return prefix or "terraform"
def is_terraform_path(path: str, terraform_dir: str = "terraform") -> bool:
normalized = path.replace("\\", "/") normalized = path.replace("\\", "/")
return normalized == "terraform" or normalized.startswith("terraform/") prefix = terraform_prefix(terraform_dir)
return normalized == prefix or normalized.startswith(f"{prefix}/")
def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool: def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool:
@ -44,12 +53,16 @@ def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> b
def isolation_violation( def isolation_violation(
paths: list[str], app_paths: str paths: list[str],
app_paths: str,
terraform_dir: str = "terraform",
) -> tuple[list[str], list[str]] | None: ) -> tuple[list[str], list[str]] | None:
prefixes, exact = parse_app_rules(app_paths) prefixes, exact = parse_app_rules(app_paths)
if not prefixes and not exact: if not prefixes and not exact:
return None return None
terraform_files = sorted({path for path in paths if is_terraform_path(path)}) terraform_files = sorted(
{path for path in paths if is_terraform_path(path, terraform_dir)}
)
app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)}) app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)})
if terraform_files and app_files: if terraform_files and app_files:
return terraform_files, app_files return terraform_files, app_files
@ -57,10 +70,12 @@ def isolation_violation(
def first_isolation_violation( def first_isolation_violation(
file_sets: list[list[str]], app_paths: str file_sets: list[list[str]],
app_paths: str,
terraform_dir: str = "terraform",
) -> tuple[list[str], list[str]] | None: ) -> tuple[list[str], list[str]] | None:
for paths in file_sets: for paths in file_sets:
violation = isolation_violation(paths, app_paths) violation = isolation_violation(paths, app_paths, terraform_dir)
if violation is not None: if violation is not None:
return violation return violation
return None return None
@ -77,12 +92,18 @@ def main() -> int:
paths = list(args.paths) paths = list(args.paths)
if not paths and not sys.stdin.isatty(): if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()] paths = [line.strip() for line in sys.stdin if line.strip()]
violation = isolation_violation(paths, os.environ.get("APP_PATHS", "")) terraform_dir = terraform_prefix(os.environ.get("TERRAFORM_DIR", "terraform"))
violation = isolation_violation(
paths, os.environ.get("APP_PATHS", ""), terraform_dir
)
if violation is None: if violation is None:
print("PASS: application and Terraform changes are isolated") print("PASS: application and Terraform changes are isolated")
return 0 return 0
terraform_files, app_files = violation terraform_files, app_files = violation
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr) print(
f"FAIL: do not mix deployable application files with {terraform_dir}/",
file=sys.stderr,
)
print("terraform:", file=sys.stderr) print("terraform:", file=sys.stderr)
for path in terraform_files: for path in terraform_files:
print(f" {path}", file=sys.stderr) print(f" {path}", file=sys.stderr)

View file

@ -3,10 +3,19 @@
from __future__ import annotations from __future__ import annotations
import os
import re
import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from check_app_terraform_isolation import first_isolation_violation, isolation_violation from check_app_terraform_isolation import first_isolation_violation, isolation_violation
WORKFLOW = (
Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci-terraform.yaml"
)
APP_PATHS = "src/\npackage.json\npackage-lock.json\n" APP_PATHS = "src/\npackage.json\npackage-lock.json\n"
@ -76,6 +85,143 @@ class IsolationTests(unittest.TestCase):
) )
self.assertIsNotNone(violation) self.assertIsNotNone(violation)
def test_non_default_terraform_dir_mixed_fails(self) -> None:
violation = isolation_violation(
["infra/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra",
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["infra/main.tf"])
self.assertEqual(app_files, ["src/app.js"])
def test_default_dir_leaves_other_prefixes_neutral(self) -> None:
self.assertIsNone(
isolation_violation(["infra/main.tf", "src/app.js"], "src/\n")
)
def test_terraform_prefix_does_not_match_a_longer_directory(self) -> None:
self.assertIsNone(
isolation_violation(
["infrastructure/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra",
)
)
def test_terraform_dir_trailing_slash(self) -> None:
violation = isolation_violation(
["infra/main.tf", "src/app.js"],
"src/\n",
terraform_dir="infra/",
)
self.assertIsNotNone(violation)
def test_blank_terraform_dir_defaults_to_terraform(self) -> None:
violation = isolation_violation(
["terraform/lambda.tf", "src/app.js"],
"src/\n",
terraform_dir=" ",
)
self.assertIsNotNone(violation)
class WorkflowDiffTests(unittest.TestCase):
def test_classified_diffs_include_deletions(self) -> None:
self.assertEqual(_workflow_diff_filters(), ["ACMRD", "ACMRD"])
def test_workflow_passes_working_directory(self) -> None:
self.assertIn(
"TERRAFORM_DIR: ${{ inputs.working-directory }}",
WORKFLOW.read_text(),
)
def test_deleted_app_file_is_classified(self) -> None:
diff_filter = _workflow_diff_filters()[0]
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
base = _commit_base(repo)
(repo / "terraform" / "lambda.tf").write_text("changed\n")
(repo / "src" / "processPaymentCsv.js").unlink()
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "decommission handler")
omitted = _changed_paths(repo, base, "HEAD", "ACMR")
included = _changed_paths(repo, base, "HEAD", diff_filter)
self.assertNotIn("src/processPaymentCsv.js", omitted)
self.assertIn("src/processPaymentCsv.js", included)
self.assertIn("terraform/lambda.tf", included)
self.assertIsNotNone(isolation_violation(included, APP_PATHS))
def test_deleted_terraform_file_is_classified(self) -> None:
diff_filter = _workflow_diff_filters()[0]
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
base = _commit_base(repo)
(repo / "terraform" / "lambda.tf").unlink()
(repo / "src" / "processPaymentCsv.js").write_text("changed\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "remove lambda")
omitted = _changed_paths(repo, base, "HEAD", "ACMR")
included = _changed_paths(repo, base, "HEAD", diff_filter)
self.assertNotIn("terraform/lambda.tf", omitted)
violation = isolation_violation(included, APP_PATHS)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/lambda.tf"])
self.assertEqual(app_files, ["src/processPaymentCsv.js"])
def _workflow_diff_filters() -> list[str]:
return re.findall(r"--diff-filter=([A-Z]+)", WORKFLOW.read_text())
def _git(repo: Path, *args: str) -> str:
env = os.environ.copy()
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
completed = subprocess.run(
[
"git",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
*args,
],
cwd=repo,
check=True,
capture_output=True,
text=True,
env=env,
)
return completed.stdout
def _commit_base(repo: Path) -> str:
_git(repo, "init", "-b", "main")
(repo / "terraform").mkdir()
(repo / "src").mkdir()
(repo / "terraform" / "lambda.tf").write_text("resource\n")
(repo / "src" / "processPaymentCsv.js").write_text("export {}\n")
_git(repo, "add", ".")
_git(repo, "commit", "-m", "base")
return _git(repo, "rev-parse", "HEAD").strip()
def _changed_paths(repo: Path, base: str, head: str, diff_filter: str) -> list[str]:
output = _git(
repo,
"diff",
"--name-only",
f"--diff-filter={diff_filter}",
base,
head,
)
return [line for line in output.splitlines() if line]
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()