mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-10-07 11:38:55 +00:00
ci(terraform): fail mixed app and Terraform changes
This commit is contained in:
parent
ee5b843ca1
commit
4c3e13a07a
4 changed files with 243 additions and 0 deletions
62
.github/workflows/ci-terraform.yaml
vendored
62
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -10,6 +10,16 @@ name: CI — Terraform
|
|||
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@<sha> # 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
|
||||
|
|
|
|||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
96
scripts/check_app_terraform_isolation.py
Normal file
96
scripts/check_app_terraform_isolation.py
Normal 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())
|
||||
81
scripts/test_check_app_terraform_isolation.py
Normal file
81
scripts/test_check_app_terraform_isolation.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue