mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-10-07 16:18:55 +00:00
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:
parent
4c3e13a07a
commit
e7712e6d0f
3 changed files with 180 additions and 11 deletions
8
.github/workflows/ci-terraform.yaml
vendored
8
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -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")
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue