diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml deleted file mode 100644 index 709e88e..0000000 --- a/.github/workflows/architecture-quality.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Architecture and changed-file quality - -on: - pull_request: - # Required by the merge queue; see ci.yml. - merge_group: - push: - branches: [main] - -permissions: - contents: read - -jobs: - architecture: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: "8.0.x" - - # CI and local run the same complete repository gate. - - name: Repository quality gate - shell: bash - env: - # A merge group carries its own base and head; github.event.before is - # empty on that event. - BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} - HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} - run: bash scripts/governance-check.sh diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml deleted file mode 100644 index cc937fb..0000000 --- a/.github/workflows/ci-terraform.yaml +++ /dev/null @@ -1,60 +0,0 @@ -name: Terraform CI - -# Static checks only. Plans run in HCP Terraform as speculative VCS runs on the -# PR (shoc-backend-dev and shoc-backend-staging). Applies are HCP auto-apply -# on merge to main (dev) and on a vX.Y.Z-staging tag (staging). - -on: - pull_request: - branches: [main, dev] - paths: - - "terraform/**" - - "scripts/**" - - ".github/workflows/ci-terraform.yaml" - - ".github/workflows/deploy.yaml" - - ".github/workflows/deploy-tag.yaml" - - ".github/workflows/release.yaml" - push: - branches: [main] - paths: - - "terraform/**" - - "scripts/**" - - ".github/workflows/ci-terraform.yaml" - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.9.8" - terraform_wrapper: false - - - name: Terraform fmt - run: terraform fmt -check -recursive terraform - - - name: Validate live/dev - run: | - terraform -chdir=terraform/live/dev init -backend=false - terraform -chdir=terraform/live/dev validate - - - name: Validate live/staging - run: | - terraform -chdir=terraform/live/staging init -backend=false - terraform -chdir=terraform/live/staging validate - - - name: Import plan guard tests - run: python3 scripts/test-terraform-import-plan-check.py - - - name: Release promotion script tests - run: | - python3 scripts/test_next_release_tag.py - python3 scripts/test_require_commit_checks.py - python3 scripts/test_check_app_terraform_isolation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 075965d..f8d95dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: Backend CI on: pull_request: - branches: [main, dev, staging] # The merge queue builds main plus the queued pull requests on a temporary # branch and only counts checks that ran on the merge_group event. merge_group: @@ -12,14 +11,15 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-and-test: name: Build and test runs-on: ubuntu-latest timeout-minutes: 20 - concurrency: - group: backend-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true steps: - name: Checkout uses: actions/checkout@v7 @@ -29,6 +29,14 @@ jobs: with: dotnet-version: "8.0.x" + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + - name: Restore run: dotnet restore SeaHavenIndustries.sln @@ -37,3 +45,72 @@ jobs: - name: Test run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release + + architecture: + name: architecture + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "8.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + terraform_wrapper: false + + - name: Repository quality gate + shell: bash + env: + GOVERNANCE_SKIP_BUILD_TEST: "1" + # A merge group carries its own base and head; github.event.before is + # empty on that event. + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} + run: bash scripts/governance-check.sh + + review: + name: review + if: github.event_name != 'push' + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10 + + ci-complete: + name: ci-complete + if: always() + needs: [build-and-test, architecture, review] + runs-on: ubuntu-latest + steps: + - name: All required jobs passed + shell: bash + env: + BUILD: ${{ needs.build-and-test.result }} + ARCH: ${{ needs.architecture.result }} + REVIEW: ${{ needs.review.result }} + run: | + set -euo pipefail + if [[ "${BUILD}" != "success" || "${ARCH}" != "success" ]]; then + echo "Build and test or architecture did not succeed: build=${BUILD} architecture=${ARCH}" + exit 1 + fi + # review is skipped on push to main; it must succeed on pull_request + # and merge_group. + if [[ "${REVIEW}" != "success" && "${REVIEW}" != "skipped" ]]; then + echo "review did not succeed: ${REVIEW}" + exit 1 + fi diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 8daba8b..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Dependency Review -on: - pull_request: - # The dependency-review action resolves the diff from merge_group.base_sha and - # head_sha itself, so the same job satisfies the required check in the merge - # queue. One job, no conditions, one check run per event. - merge_group: -permissions: - contents: read -jobs: - review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7ff766e..a8ecefc 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,8 +31,6 @@ on: - "terraform/**" - "**/*.md" - ".github/workflows/ci.yml" - - ".github/workflows/ci-terraform.yaml" - - ".github/workflows/architecture-quality.yml" - ".github/workflows/release.yaml" - ".github/workflows/deploy-tag.yaml" diff --git a/AGENTS.md b/AGENTS.md index f5c54f1..1adc0e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,9 @@ bash scripts/governance-check.sh ``` The command restores, verifies architecture and changed-file formatting, builds -Release, and runs the full test suite. CI (`architecture-quality` workflow) -calls the same script. See `QUALITY_GATES.md`. +Release, runs the full test suite, and validates Terraform. CI (`ci.yml`) +calls the same script from the `architecture` job (skipping G4/G5) and +aggregates results as `ci-complete`. See `QUALITY_GATES.md`. ## Hard rules (deviation needs an ADR; no wildcard suppressions) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index c1a83a6..835ef97 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -6,32 +6,32 @@ > rule lives in `ARCHITECTURE_AND_CODE_QUALITY.md`. > > **CI and local run the same check.** Every gate below is executed by -> `scripts/governance-check.sh`. The `architecture-quality` workflow calls that -> script so a green run means the same thing locally and in CI. Do not add a -> check to CI that is not also runnable locally through the script (and vice -> versa). +> `scripts/governance-check.sh` locally. The `architecture` job in `ci.yml` +> calls that script (skipping G4/G5, which the `Build and test` job owns). Do +> not add a check to CI that is not also runnable locally through the script +> (and vice versa), except G4/G5 which the local script still runs. ## Gate inventory | # | Gate | Protects (architecture §) | Local command | CI step | |---|------|---------------------------|---------------|---------| -| G1 | Restore | build integrity | `dotnet restore SeaHavenIndustries.sln` | `architecture-quality` → `governance-check.sh` | +| G1 | Restore | build integrity | `dotnet restore SeaHavenIndustries.sln` | `ci.yml` `architecture` → `governance-check.sh` | | G2 | Architecture boundary tests | §1, §3 (dependency direction) | `dotnet test Api.SeaHavenIndustries.Tests ... --filter FullyQualifiedName~ArchitectureTests --no-restore` | same script | | G3 | Changed-file formatting | §1 (conventions) | `dotnet format SeaHavenIndustries.sln --no-restore --verify-no-changes --include ` | same script | -| G4 | Release build | compile correctness | `dotnet build SeaHavenIndustries.sln -c Release --no-restore` | `architecture-quality` and `ci` | -| G5 | Full test suite | behavior | `dotnet test SeaHavenIndustries.sln -c Release --no-build` | `architecture-quality` and `ci` | +| G4 | Release build | compile correctness | `dotnet build SeaHavenIndustries.sln -c Release --no-restore` | `ci.yml` `Build and test` (local script still runs it) | +| G5 | Full test suite | behavior | `dotnet test SeaHavenIndustries.sln -c Release --no-build` | `ci.yml` `Build and test` (local script still runs it) | | G6 | Migration gates | §7 | see §"Migration gates" below | on-demand / release | | G7 | Cancellation forwarding | §6 | behavior tests on changed I/O paths + analyzer | review-enforced on changed paths | -| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` | +| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci.yml` `Build and test` | | G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced | -| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` | -| G11 | Terraform static validation | import configuration integrity | commands below | `ci-terraform` on PRs to `main` or `dev` | -| G13 | App/Terraform isolation | separate delivery lanes | `python3 scripts/check_app_terraform_isolation.py` | `architecture-quality` → `governance-check.sh` | +| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `ci.yml` `architecture` → `governance-check.sh` | +| G11 | Terraform static validation | import configuration integrity | commands below | `ci.yml` `architecture` → `governance-check.sh` | +| G13 | App/Terraform isolation | separate delivery lanes | `python3 scripts/check_app_terraform_isolation.py` | `ci.yml` `architecture` (live classifier: PR + local) | ## How to run locally ```bash -# Complete local repository gate (mirrors the architecture-quality workflow): +# Complete local repository gate (G1–G5, G10, G11, G13): bash scripts/governance-check.sh # By default it compares against the default branch for changed-file formatting. @@ -51,8 +51,11 @@ The script: 6. verifies that the Terraform plan guard rejects create, delete, replacement, unmanaged resource types, and updates not allowlisted by exact address (G10). 7. runs the release-tag, commit-check, and isolation unit tests. -8. rejects a diff that contains both `terraform/` and deployable application - files (G13). +8. runs Terraform fmt/validate for `live/dev` and `live/staging` (G11). +9. rejects a diff that contains both `terraform/` and deployable application + files (G13). Live G13 skips on `merge_group` and `push`. The architecture + job in CI sets `GOVERNANCE_SKIP_BUILD_TEST=1` so steps 4–5 run only locally + and in the Build and test job. G10 permits only exact approved resource address/type pairs for the environment-owned boundary: Elastic @@ -65,9 +68,10 @@ Controlled mode requires one also requires `--environment dev` or `--environment staging`; an empty or incomplete environment plan fails. -G11 runs `terraform fmt -check -recursive terraform`, `terraform init -backend=false`, -and `terraform validate` for both `live/dev` and `live/staging` on every PR to -`main` or `dev`. Org-baseline CloudFormation owns the HCP role substrate, and Terraform +G11 runs `terraform fmt -check -recursive terraform`, `terraform init -backend=false +-input=false -lockfile=readonly`, and `terraform validate` for both `live/dev` +and `live/staging` from `governance-check.sh` on every CI event, including +merge groups. Org-baseline CloudFormation owns the HCP role substrate, and Terraform owns the environment GitHub deploy roles, so no backend CDK or bootstrap root remains in the matrix. HCP plan/apply roles stay in org-baseline; this repository never manages `hcptf-*` roles. @@ -129,8 +133,9 @@ non-empty. A future namespace move that makes reflection return zero types will Runtime/semantic properties (cancellation actually cancelling, a query executing in SQL, a transaction actually committing atomically) are proven by behavior tests or provider evidence, never by reflection. -- A passing script proves G1–G5 ran in sequence. It does not prove the - provider-backed or review-owned gates G6–G9. +- A passing local script proves G1–G5, G10, G11, and G13 ran (G4/G5 are skipped + in the CI architecture job). It does not prove the provider-backed or + review-owned gates G6–G9. ## Pass / fail / skip / not-run reporting @@ -149,8 +154,9 @@ or not-run. Never infer a pass from silence. ## Adding or changing a gate - Any new executable check goes into `scripts/governance-check.sh` **and** the - `architecture-quality` workflow together (or into the full CI for G4/G5-type - checks). One source of truth for local and CI. + `architecture` job in `ci.yml` together (or into the Build and test job for + G4/G5-type checks). One source of truth for local and CI. The required + merge-queue check is `ci-complete`. - A new rule documents its meaning in `ARCHITECTURE_AND_CODE_QUALITY.md` and its command/mapping here. - Explicit, documented exceptions only — no wildcard suppressions (see diff --git a/README.md b/README.md index 2215fb6..30598d7 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,9 @@ one follows the G6 rules in [`QUALITY_GATES.md`](QUALITY_GATES.md). - The PR body uses the three-section layout the template pre-fills: Summary, Changes and value, Ticket. The reasoning is in [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- `main` requires a code-owner review and the `Build and test`, - `architecture` and `review / dependency-review` checks. PRs merge through the - merge queue, so a branch does not need to be updated with `main` before it - merges. +- `main` requires a code-owner review and the `ci-complete` check. PRs merge + through the merge queue, so a branch does not need to be updated with `main` + before it merges. - Application code and `terraform/` do not change in the same PR (G13). ## Deployment diff --git a/scripts/governance-check.sh b/scripts/governance-check.sh index effae29..9744c3e 100755 --- a/scripts/governance-check.sh +++ b/scripts/governance-check.sh @@ -2,9 +2,10 @@ # # governance-check.sh — local/CI parity governance gate for the Seahaven backend. # -# Runs G1 restore, G2 ArchitectureTests, G3 changed-file formatting, G4 Release -# build, and G5 full tests exactly as the `architecture-quality` workflow does. -# A green run here means the same thing locally and in that CI workflow. +# Locally this runs G1–G5, G10, G11, promotion-script tests, and live G13. +# The architecture job in ci.yml sets GOVERNANCE_SKIP_BUILD_TEST=1 so G4/G5 +# run only in the Build and test job. Live G13 runs on pull_request and local +# invocations; it skips merge_group and push. # # Usage: # bash scripts/governance-check.sh @@ -14,6 +15,7 @@ set -euo pipefail SOLUTION="SeaHavenIndustries.sln" ARCH_TEST_PROJECT="Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj" +LIVE_ROOTS=(terraform/live/dev terraform/live/staging) log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; } @@ -81,13 +83,18 @@ else ok "G3: changed-file formatting" fi -log "G4: Release build" -"$DOTNET" build "$SOLUTION" -c Release --no-restore --nologo -ok "G4: Release build" +if [[ "${GOVERNANCE_SKIP_BUILD_TEST:-}" == "1" ]]; then + printf '\033[33mSKIP\033[0m G4: Release build (CI Build and test job owns it)\n' + printf '\033[33mSKIP\033[0m G5: full test suite (CI Build and test job owns it)\n' +else + log "G4: Release build" + "$DOTNET" build "$SOLUTION" -c Release --no-restore --nologo + ok "G4: Release build" -log "G5: full test suite" -"$DOTNET" test "$SOLUTION" -c Release --no-build --nologo -ok "G5: full test suite" + log "G5: full test suite" + "$DOTNET" test "$SOLUTION" -c Release --no-build --nologo + ok "G5: full test suite" +fi log "G10: Terraform import plan safety" python scripts/test-terraform-import-plan-check.py @@ -99,10 +106,26 @@ python3 scripts/test_require_commit_checks.py python3 scripts/test_check_app_terraform_isolation.py ok "Release promotion scripts" -log "G13: application and Terraform isolation (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})" -python3 scripts/check_app_terraform_isolation.py < <( - git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}" -) -ok "G13: application and Terraform isolation" +log "G11: Terraform formatting and validation" +if ! command -v terraform >/dev/null 2>&1; then + bad "G11: terraform is unavailable; install Terraform or set PATH." + exit 1 +fi +terraform fmt -check -recursive terraform +for live_root in "${LIVE_ROOTS[@]}"; do + terraform -chdir="${live_root}" init -backend=false -input=false -lockfile=readonly -no-color + terraform -chdir="${live_root}" validate -no-color +done +ok "G11: Terraform formatting and validation" + +if [[ -n "${GITHUB_EVENT_NAME:-}" && "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then + printf '\033[33mSKIP\033[0m G13: live isolation is a pull-request property (event: %s)\n' "${GITHUB_EVENT_NAME}" +else + log "G13: application and Terraform isolation (${BASE_REF}...${HEAD_REF}, merge base ${DIFF_BASE:0:7})" + python3 scripts/check_app_terraform_isolation.py < <( + git diff --name-only --diff-filter=ACMR "${DIFF_BASE}" "${HEAD_REF}" + ) + ok "G13: application and Terraform isolation" +fi log "governance-check: all required repository gates passed" diff --git a/scripts/require_commit_checks.py b/scripts/require_commit_checks.py index c2d6bdd..5dad928 100644 --- a/scripts/require_commit_checks.py +++ b/scripts/require_commit_checks.py @@ -12,8 +12,7 @@ import urllib.parse import urllib.request REQUIRED_CHECK_NAMES = ( - "Build and test", - "architecture", + "ci-complete", ) diff --git a/scripts/test_require_commit_checks.py b/scripts/test_require_commit_checks.py index e3089d2..2722a85 100644 --- a/scripts/test_require_commit_checks.py +++ b/scripts/test_require_commit_checks.py @@ -12,26 +12,20 @@ class ClassifyChecksTests(unittest.TestCase): def test_success(self) -> None: state, _ = classify_checks( [ - {"name": "Build and test", "status": "completed", "conclusion": "success"}, - {"name": "architecture", "status": "completed", "conclusion": "success"}, + {"name": "ci-complete", "status": "completed", "conclusion": "success"}, ] ) self.assertEqual(state, "success") def test_pending_missing(self) -> None: - state, details = classify_checks( - [ - {"name": "Build and test", "status": "completed", "conclusion": "success"}, - ] - ) + state, details = classify_checks([]) self.assertEqual(state, "pending") - self.assertTrue(any("architecture" in line for line in details)) + self.assertTrue(any("ci-complete" in line for line in details)) def test_pending_in_progress(self) -> None: state, _ = classify_checks( [ - {"name": "Build and test", "status": "in_progress", "conclusion": None}, - {"name": "architecture", "status": "completed", "conclusion": "success"}, + {"name": "ci-complete", "status": "in_progress", "conclusion": None}, ] ) self.assertEqual(state, "pending") @@ -39,8 +33,7 @@ class ClassifyChecksTests(unittest.TestCase): def test_failure(self) -> None: state, _ = classify_checks( [ - {"name": "Build and test", "status": "completed", "conclusion": "failure"}, - {"name": "architecture", "status": "completed", "conclusion": "success"}, + {"name": "ci-complete", "status": "completed", "conclusion": "failure"}, ] ) self.assertEqual(state, "failure") @@ -49,7 +42,7 @@ class ClassifyChecksTests(unittest.TestCase): state, _ = classify_checks( [ { - "name": "Build and test", + "name": "ci-complete", "id": 1, "started_at": "2026-09-16T12:00:00Z", "completed_at": "2026-09-16T12:05:00Z", @@ -57,21 +50,13 @@ class ClassifyChecksTests(unittest.TestCase): "conclusion": "failure", }, { - "name": "Build and test", + "name": "ci-complete", "id": 3, "started_at": "2026-09-16T12:10:00Z", "completed_at": "2026-09-16T12:12:00Z", "status": "completed", "conclusion": "success", }, - { - "name": "architecture", - "id": 2, - "started_at": "2026-09-16T12:00:00Z", - "completed_at": "2026-09-16T12:04:00Z", - "status": "completed", - "conclusion": "success", - }, ] ) self.assertEqual(state, "success") @@ -80,7 +65,7 @@ class ClassifyChecksTests(unittest.TestCase): state, _ = classify_checks( [ { - "name": "architecture", + "name": "ci-complete", "id": 9, "started_at": "2026-09-16T13:00:00Z", "completed_at": "2026-09-16T13:02:00Z", @@ -88,21 +73,13 @@ class ClassifyChecksTests(unittest.TestCase): "conclusion": "failure", }, { - "name": "architecture", + "name": "ci-complete", "id": 4, "started_at": "2026-09-16T12:00:00Z", "completed_at": "2026-09-16T12:01:00Z", "status": "completed", "conclusion": "success", }, - { - "name": "Build and test", - "id": 5, - "started_at": "2026-09-16T12:00:00Z", - "completed_at": "2026-09-16T12:03:00Z", - "status": "completed", - "conclusion": "success", - }, ] ) self.assertEqual(state, "failure")