diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml index 9935d1c..8728926 100644 --- a/.github/workflows/ci-terraform.yaml +++ b/.github/workflows/ci-terraform.yaml @@ -10,6 +10,16 @@ name: CI — Terraform # uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@ # vX.Y.Z # with: # 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: workflow_call: @@ -22,6 +32,10 @@ on: description: "Directory containing Terraform sources" type: string 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: contents: read @@ -41,6 +55,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: @@ -55,3 +70,50 @@ jobs: - name: 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 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9067c5c..0c7164e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,6 +53,10 @@ jobs: steps: - 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 env: ACTIONLINT_VERSION: 1.7.12 diff --git a/scripts/check_app_terraform_isolation.py b/scripts/check_app_terraform_isolation.py new file mode 100644 index 0000000..d946231 --- /dev/null +++ b/scripts/check_app_terraform_isolation.py @@ -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()) diff --git a/scripts/test_check_app_terraform_isolation.py b/scripts/test_check_app_terraform_isolation.py new file mode 100644 index 0000000..15c122f --- /dev/null +++ b/scripts/test_check_app_terraform_isolation.py @@ -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()