From 8ad7438f26628abeab1bd5e810c4d55df13b1907 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 19 Sep 2026 15:22:38 -0400 Subject: [PATCH 1/4] ci: fan out quality gates from this repository --- .github/workflows/ci-terraform.yaml | 56 ------------ .github/workflows/ci.yaml | 130 ++++++++++++++++++++-------- .github/workflows/deploy-web.yaml | 1 - AGENTS.md | 5 +- QUALITY_GATES.md | 20 ++--- README.md | 17 ++-- scripts/governance-check.mjs | 54 +++++++++--- terraform/README.md | 3 +- 8 files changed, 154 insertions(+), 132 deletions(-) delete mode 100644 .github/workflows/ci-terraform.yaml diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml deleted file mode 100644 index a4902d15..00000000 --- a/.github/workflows/ci-terraform.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: Terraform CI - -# Static checks only. Plans run in HCP Terraform as speculative VCS runs on -# the PR (shoc-frontend-new-dev and shoc-frontend-new-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-web.yaml" - push: - branches: [main] - paths: - - "terraform/**" - - "scripts/**" - - ".github/workflows/ci-terraform.yaml" - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - 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 -input=false -lockfile=readonly -no-color - terraform -chdir=terraform/live/dev validate -no-color - - - name: Validate live/staging - run: | - terraform -chdir=terraform/live/staging init -backend=false -input=false -lockfile=readonly -no-color - terraform -chdir=terraform/live/staging validate -no-color - - - name: Import plan guard tests - run: python3 scripts/test-terraform-import-plan-check.py - - - name: App/Terraform isolation tests - run: python3 scripts/test_check_app_terraform_isolation.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ef6ccb02..f725d6fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,25 +13,81 @@ 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 - # Org reusable workflow (Node 24): format check, lint, build, unit tests. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 - with: - node-version: "24" + static: + name: static + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run format:check + - run: npm run lint + + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run build + + unit: + name: unit + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm test -- --shard=${{ matrix.shard }}/2 + + visual: + name: Visual regression + runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.61.1-noble + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run test:e2e:visual + - name: Upload visual diff artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: visual-regression-diffs + path: | + test-results/visual + playwright-report-visual + if-no-files-found: ignore + retention-days: 14 governance: - # Repo-owned guarantee that every frontend quality gate runs from this - # repository, independent of (and in addition to) the reusable workflow. - # `npm run verify` is the single command that chains: format check, lint - # (--max-warnings=0), type-check + build, unit tests, then the governance - # checks in scripts/governance-check.mjs (godfile ratchet, changed-file - # maintainability gate, Terraform fmt/validate, Terraform import-plan - # guard, HCP run guard, CloudFront verify, GitHub workflow shell, and G13 - # app/Terraform isolation). Runs on PRs to main or dev; push is main only. - # If the reusable workflow is later confirmed to run every gate, this job - # can be slimmed to `npm run governance`. + # Repo-owned gates: godfile ratchet, changed-file maintainability, + # Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront + # verify, GitHub workflow shell, isolation classifier tests, and live G13 + # (pull_request and local only). Format, lint, build, and unit tests run + # in the parallel jobs above, not here. # # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) @@ -86,29 +142,29 @@ jobs: tar -xzf actionlint.tar.gz actionlint sudo mv actionlint /usr/local/bin/actionlint - run: npm ci - - run: npm run verify + - run: npm run governance env: GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} - visual-regression: - name: Visual regression + ci-complete: + name: ci-complete + if: always() + needs: [static, build, unit, visual, governance] runs-on: ubuntu-latest - container: mcr.microsoft.com/playwright:v1.61.1-noble steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - cache: npm - - run: npm ci - - run: npm run test:e2e:visual - - name: Upload visual diff artifacts - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: visual-regression-diffs - path: | - test-results/visual - playwright-report-visual - if-no-files-found: ignore - retention-days: 14 + - name: All required jobs passed + shell: bash + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + failed=0 + for result in ${RESULTS}; do + if [[ "${result}" != "success" ]]; then + failed=1 + fi + done + if [[ "${failed}" -ne 0 ]]; then + echo "Required jobs did not all succeed: ${RESULTS}" + exit 1 + fi diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 943cad51..aaa1ac1e 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -28,7 +28,6 @@ on: - "docs/**" - "**/*.md" - ".github/workflows/ci.yaml" - - ".github/workflows/ci-terraform.yaml" - ".github/workflows/deploy-web.yaml" release: types: [published] diff --git a/AGENTS.md b/AGENTS.md index 68229d76..51a13d7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,9 @@ This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScrip build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance checks (`npm run governance`), including G13 app/Terraform isolation. **Do not claim a task is done until `npm run verify` is green locally.** CI runs the same -`npm run verify` in a repo-owned `governance` job, so a green local run mirrors -CI. +gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) +(`static`, `build`, `unit`, `visual`, `governance`) with `ci-complete` failing if +any of those jobs did not succeed. ## Non-negotiable rules (enforced; do not work around) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 75693239..fcaacb21 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -31,7 +31,7 @@ isolation). A task is not done until this is green. | HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | | CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | | GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | -| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` | +| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` (live classifier: PR + local) | ## No-false-pass guarantees @@ -60,15 +60,15 @@ isolation). A task is not done until this is green. - **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint + Prettier on staged files at commit; commitlint enforces Conventional Commits. -- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org - reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs - format/lint/build/tests, **and** a repo-owned `governance` job runs - `npm run verify` (with Terraform 1.16.0 installed) so the maintainability - ratchets and repository gates are guaranteed from this repository regardless - of the reusable workflow. -- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):** - fmt, `init -backend=false`, validate, import-plan unit tests, and G13 - classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`. +- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this + repository owns every job. `static` (`format:check` + `lint`), `build` + (`tsc -b && vite build`), `unit` (`vitest run` in two shards), `visual` + (Playwright visual), and `governance` (`npm run governance`, with Terraform + 1.16.0) run in parallel. `ci-complete` fails unless all of those jobs + succeeded and is the required merge-queue check. Live G13 runs on + `pull_request` and locally; it skips `merge_group` and `push`. Terraform + fmt/validate and the related unit tests run inside `governance` on every + event. ## Toolchain pin diff --git a/README.md b/README.md index 4d0edbc3..76040f38 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or rejects anything else at commit time. - Open PRs against `main`. The PR body uses the three-section layout the template pre-fills: Summary, Changes and value, Ticket. `main` needs the - `governance` and `Build and test / ci` checks and an approving review from a - code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale + `ci-complete` check and an approving review from a code owner + (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals. PRs merge through the merge queue, so a branch does not need to be updated with `main` before it merges. Merged branches are deleted automatically. @@ -148,19 +148,12 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or No stored AWS keys — OIDC only. Infrastructure and content deploy separately: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push - and PRs to `main`, calls - `Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24): - format check, lint, build, tests; **and** runs a repo-owned `governance` job - that calls `npm run verify` so every gate (including the maintainability - ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs) - and the Terraform gates) is guaranteed from this repository. Conventions - and gates are documented under + and PRs to `main`, runs format, lint, build, sharded unit tests, visual + regression, and `npm run governance` as parallel jobs, then `ci-complete`. + Conventions and gates are documented under [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and [`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md). -- **Terraform CI** - ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)) - — fmt, `init -backend=false`, validate, and import-plan tests. - **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml)) — push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys `staging`. Syncs `dist/` to the bucket root and invalidates `/*`. diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 46172487..84b4c5cf 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -218,13 +218,23 @@ function runRepositoryGate(label, script) { return { label, status: result.status, error: result.error }; } +function shouldRunLiveIsolation() { + const eventName = process.env.GITHUB_EVENT_NAME; + return !eventName || eventName === "pull_request"; +} + function runIsolationGate(baseRef) { - const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]); - return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { + // Diff from the merge base, not the moving base tip. A two-dot diff against + // a branch that has advanced reports everything the base gained after the + // branch point as if this change reverted it. + const mergeBase = gitText(["merge-base", baseRef, "HEAD"]); + const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]); + const result = spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { cwd: ROOT, encoding: "utf8", input: `${files.join("\n")}\n`, }); + return { ...result, mergeBase }; } function main() { @@ -326,22 +336,40 @@ function main() { } console.log("─".repeat(64)); - console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`); - if (!baseRef) { + if (!shouldRunLiveIsolation()) { + console.log( + `G13: skipped — live isolation is a pull-request property (event: ${process.env.GITHUB_EVENT_NAME})`, + ); + } else if (!baseRef) { + console.log("G13: application and Terraform isolation (no base...HEAD)"); console.log(" FAIL (no valid base ref)"); failures.push( "G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.", ); } else { - const isolation = runIsolationGate(baseRef); - if (isolation.error) { - console.log(" FAIL (could not start)"); - failures.push(`G13: could not start: ${isolation.error.message}`); - } else { - const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim(); - if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); - if (isolation.status !== 0) { - failures.push("G13: do not mix deployable application files with terraform/"); + let isolation; + try { + isolation = runIsolationGate(baseRef); + } catch (error) { + console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`); + console.log(" FAIL (could not resolve merge base)"); + const message = error instanceof Error ? error.message : String(error); + failures.push(`G13: could not resolve merge base against HEAD: ${message}`); + } + if (isolation) { + const mergeBase = isolation.mergeBase ?? "unresolvable"; + console.log( + `G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`, + ); + if (isolation.error) { + console.log(" FAIL (could not start)"); + failures.push(`G13: could not start: ${isolation.error.message}`); + } else { + const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim(); + if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); + if (isolation.status !== 0) { + failures.push("G13: do not mix deployable application files with terraform/"); + } } } } diff --git a/terraform/README.md b/terraform/README.md index a2922962..ddeb0bc5 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -56,7 +56,8 @@ bash scripts/test-verify-cloudfront-release.sh PRs cannot mix `terraform/` with deployable application files. Workflow, docs, and gate-script changes may travel with either side. G13 is -`python3 scripts/check_app_terraform_isolation.py` against the PR base. +`python3 scripts/check_app_terraform_isolation.py` against the merge base of +the PR. `npm run test:terraform` and `npm run verify` wrap the same gates. They never create an HCP run or touch AWS. From af0ae60b80e5d0d15bf1557f5e9841a4be883d6e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 19 Sep 2026 15:41:10 -0400 Subject: [PATCH 2/4] ci: shard unit tests four ways --- .github/workflows/ci.yaml | 4 ++-- QUALITY_GATES.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f725d6fe..f44095b9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -57,7 +57,7 @@ jobs: node-version: "24" cache: npm - run: npm ci - - run: npm test -- --shard=${{ matrix.shard }}/2 + - run: npm test -- --shard=${{ matrix.shard }}/4 visual: name: Visual regression diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index fcaacb21..a8e4885e 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -62,7 +62,7 @@ isolation). A task is not done until this is green. Prettier on staged files at commit; commitlint enforces Conventional Commits. - **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this repository owns every job. `static` (`format:check` + `lint`), `build` - (`tsc -b && vite build`), `unit` (`vitest run` in two shards), `visual` + (`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual` (Playwright visual), and `governance` (`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete` fails unless all of those jobs succeeded and is the required merge-queue check. Live G13 runs on From ec9812ae8d1dfb39ea99807b2c49c175f887dca1 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:47:08 +0000 Subject: [PATCH 3/4] fix(style): correct line-wrapping formatting errors --- QUALITY_GATES.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index a8e4885e..7dcc3bbc 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -14,23 +14,23 @@ isolation). A task is not done until this is green. ## Gate matrix -| Gate | Command / rule source | Enforced by | Scope | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- | -| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | -| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | -| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | -| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | -| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | -| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | -| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | -| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | -| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | -| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | -| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | -| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | -| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | -| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | -| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | +| Gate | Command / rule source | Enforced by | Scope | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------ | +| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | +| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | +| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | +| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | +| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | +| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | +| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | +| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | +| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | +| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | +| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | +| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | +| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | +| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | | G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` (live classifier: PR + local) | ## No-false-pass guarantees From cc7ecb9b5a517f1266aef1114307b74de507a5cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 20:20:30 +0000 Subject: [PATCH 4/4] fix(ci): classify G13 per queued PR on merge_group Run live isolation on the merge-queue event so ci-complete actually gates mixed change sets, without treating the group union as one PR. --- .github/workflows/ci.yaml | 3 +- QUALITY_GATES.md | 44 ++++++------ README.md | 7 +- package.json | 2 +- scripts/g13-live-isolation.mjs | 29 ++++++++ scripts/governance-check.mjs | 103 ++++++++++++++++++++-------- scripts/test-g13-live-isolation.mjs | 73 ++++++++++++++++++++ terraform/README.md | 2 +- 8 files changed, 207 insertions(+), 56 deletions(-) create mode 100644 scripts/g13-live-isolation.mjs create mode 100644 scripts/test-g13-live-isolation.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f44095b9..5e2c75db 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -86,7 +86,8 @@ jobs: # Repo-owned gates: godfile ratchet, changed-file maintainability, # Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront # verify, GitHub workflow shell, isolation classifier tests, and live G13 - # (pull_request and local only). Format, lint, build, and unit tests run + # (pull_request, merge_group per queued PR, and local). Format, lint, build, + # and unit tests run # in the parallel jobs above, not here. # # GOVERNANCE_BASE points the changed-file gate at the right diff: diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 7dcc3bbc..a5596f8b 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -14,24 +14,24 @@ isolation). A task is not done until this is green. ## Gate matrix -| Gate | Command / rule source | Enforced by | Scope | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------ | -| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | -| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | -| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | -| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | -| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | -| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | -| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | -| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | -| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | -| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | -| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | -| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | -| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | -| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | -| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | -| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` | `governance` + CI | Deployable app files vs `terraform/` (live classifier: PR + local) | +| Gate | Command / rule source | Enforced by | Scope | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- | +| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo | +| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) | +| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app | +| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` | +| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX | +| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX | +| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX | +| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX | +| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) | +| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref | +| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps | +| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` | +| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile | +| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl | +| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint | +| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) | ## No-false-pass guarantees @@ -66,9 +66,11 @@ isolation). A task is not done until this is green. (Playwright visual), and `governance` (`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete` fails unless all of those jobs succeeded and is the required merge-queue check. Live G13 runs on - `pull_request` and locally; it skips `merge_group` and `push`. Terraform - fmt/validate and the related unit tests run inside `governance` on every - event. + `pull_request` (merge-base range), `merge_group` (each queued PR as a + first-parent commit), and locally. It skips `push`. A Terraform-only PR + stacked with an app-only PR still passes; a mixed change set still fails. + Terraform fmt/validate and the related unit tests run inside `governance` on + every event. ## Toolchain pin diff --git a/README.md b/README.md index 76040f38..974e0bf1 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,10 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or approvals. PRs merge through the merge queue, so a branch does not need to be updated with `main` before it merges. Merged branches are deleted automatically. -- PRs cannot mix `terraform/` with deployable application files (G13). Workflow, - docs, and gate-script changes may travel with either side. `deploy-web.yaml` - still ignores `terraform/**` so a Terraform-only merge does not sync the bucket. +- A change set cannot mix `terraform/` with deployable application files (G13), + including each queued PR on the merge-group check. Workflow, docs, and + gate-script changes may travel with either side. `deploy-web.yaml` still + ignores `terraform/**` so a Terraform-only merge does not sync the bucket. - Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts `vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a prod distribution exists. diff --git a/package.json b/package.json index 3ce507f5..b1fc3700 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py", "test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh", "test:github-workflows": "bash scripts/check-github-workflows.sh", - "test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py", + "test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", diff --git a/scripts/g13-live-isolation.mjs b/scripts/g13-live-isolation.mjs new file mode 100644 index 00000000..3e2d8f65 --- /dev/null +++ b/scripts/g13-live-isolation.mjs @@ -0,0 +1,29 @@ +/** + * Live G13 scheduling and how a GitHub event is split into change sets. + * + * Isolation is a per-change rule. pull_request and local runs classify the + * merge-base...HEAD range (one PR). merge_group classifies each first-parent + * commit vs its parent (one queued PR per squash or merge-commit). The group + * union is not a change set: a Terraform-only PR stacked with an app-only PR + * must still pass. push is not classified; landing already happened. + */ + +export function shouldRunLiveIsolation(eventName) { + return !eventName || eventName === "pull_request" || eventName === "merge_group"; +} + +export function usesPerCommitIsolation(eventName) { + return eventName === "merge_group"; +} + +/** + * File lists to run through check_app_terraform_isolation.py. + * `commitDiffs` is first-parent order (oldest first); ignored except on + * merge_group. + */ +export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) { + if (usesPerCommitIsolation(eventName)) { + return commitDiffs.map((commit) => commit.files); + } + return [rangeFiles]; +} diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 84b4c5cf..025489cb 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs"; + const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, ".."); const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json"); @@ -218,23 +220,77 @@ function runRepositoryGate(label, script) { return { label, status: result.status, error: result.error }; } -function shouldRunLiveIsolation() { - const eventName = process.env.GITHUB_EVENT_NAME; - return !eventName || eventName === "pull_request"; +function classifyIsolationPaths(files) { + return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { + cwd: ROOT, + encoding: "utf8", + input: files.length > 0 ? `${files.join("\n")}\n` : "", + }); } -function runIsolationGate(baseRef) { +function firstParentCommitDiffs(baseRef) { + const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]); + return shas.map((sha) => ({ + sha, + files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]), + })); +} + +function runIsolationGate(baseRef, eventName) { + if (usesPerCommitIsolation(eventName)) { + const commits = firstParentCommitDiffs(baseRef); + return { + mode: "per-commit", + results: commits.map((commit) => ({ + ...classifyIsolationPaths(commit.files), + sha: commit.sha, + })), + }; + } // Diff from the merge base, not the moving base tip. A two-dot diff against // a branch that has advanced reports everything the base gained after the // branch point as if this change reverted it. const mergeBase = gitText(["merge-base", baseRef, "HEAD"]); const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]); - const result = spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], { - cwd: ROOT, - encoding: "utf8", - input: `${files.join("\n")}\n`, - }); - return { ...result, mergeBase }; + return { + mode: "range", + mergeBase, + results: [{ ...classifyIsolationPaths(files), sha: null }], + }; +} + +function recordIsolationFailures(isolation, failures) { + const startError = isolation.results.find((result) => result.error); + if (startError) { + failures.push(`G13: could not start: ${startError.error.message}`); + } + if (isolation.results.some((result) => !result.error && result.status !== 0)) { + failures.push("G13: do not mix deployable application files with terraform/"); + } +} + +function logIsolationGate(baseRef, isolation) { + if (isolation.mode === "per-commit") { + console.log( + `G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`, + ); + for (const result of isolation.results) { + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + const prefix = result.sha ? result.sha.slice(0, 7) : "commit"; + if (output) { + console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`); + } + } + return; + } + const mergeBase = isolation.mergeBase ?? "unresolvable"; + console.log( + `G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`, + ); + const result = isolation.results[0]; + if (!result) return; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); } function main() { @@ -336,9 +392,10 @@ function main() { } console.log("─".repeat(64)); - if (!shouldRunLiveIsolation()) { + const eventName = process.env.GITHUB_EVENT_NAME; + if (!shouldRunLiveIsolation(eventName)) { console.log( - `G13: skipped — live isolation is a pull-request property (event: ${process.env.GITHUB_EVENT_NAME})`, + `G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`, ); } else if (!baseRef) { console.log("G13: application and Terraform isolation (no base...HEAD)"); @@ -349,28 +406,16 @@ function main() { } else { let isolation; try { - isolation = runIsolationGate(baseRef); + isolation = runIsolationGate(baseRef, eventName); } catch (error) { console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`); - console.log(" FAIL (could not resolve merge base)"); + console.log(" FAIL (could not resolve isolation diffs)"); const message = error instanceof Error ? error.message : String(error); - failures.push(`G13: could not resolve merge base against HEAD: ${message}`); + failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`); } if (isolation) { - const mergeBase = isolation.mergeBase ?? "unresolvable"; - console.log( - `G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`, - ); - if (isolation.error) { - console.log(" FAIL (could not start)"); - failures.push(`G13: could not start: ${isolation.error.message}`); - } else { - const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim(); - if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); - if (isolation.status !== 0) { - failures.push("G13: do not mix deployable application files with terraform/"); - } - } + logIsolationGate(baseRef, isolation); + recordIsolationFailures(isolation, failures); } } diff --git a/scripts/test-g13-live-isolation.mjs b/scripts/test-g13-live-isolation.mjs new file mode 100644 index 00000000..5e402e4c --- /dev/null +++ b/scripts/test-g13-live-isolation.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + liveIsolationFileSets, + shouldRunLiveIsolation, + usesPerCommitIsolation, +} from "./g13-live-isolation.mjs"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py"); +const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"]; +const APP_ONLY = ["src/app/routes.tsx"]; + +function classify(files) { + const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" }); + return result.status; +} + +function anySetFails(fileSets) { + return fileSets.some((files) => classify(files) !== 0); +} + +describe("shouldRunLiveIsolation", () => { + it("runs locally and on pull_request", () => { + assert.equal(shouldRunLiveIsolation(undefined), true); + assert.equal(shouldRunLiveIsolation(""), true); + assert.equal(shouldRunLiveIsolation("pull_request"), true); + }); + + it("runs on merge_group so the required check classifies the candidate", () => { + assert.equal(shouldRunLiveIsolation("merge_group"), true); + assert.equal(usesPerCommitIsolation("merge_group"), true); + }); + + it("skips push and other CI events", () => { + assert.equal(shouldRunLiveIsolation("push"), false); + assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false); + assert.equal(usesPerCommitIsolation("pull_request"), false); + }); +}); + +describe("liveIsolationFileSets", () => { + it("classifies the PR range as one change set", () => { + const range = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("pull_request", range, [ + { files: TERRAFORM_ONLY }, + { files: APP_ONLY }, + ]); + assert.deepEqual(sets, [range]); + assert.equal(anySetFails(sets), true); + }); + + it("classifies each queued PR, not the merge-group union", () => { + const union = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("merge_group", union, [ + { files: TERRAFORM_ONLY }, + { files: APP_ONLY }, + ]); + assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]); + assert.equal(anySetFails(sets), false); + assert.equal(classify(union), 1); + }); + + it("fails a single queued PR that mixes terraform and app files", () => { + const mixed = [...TERRAFORM_ONLY, ...APP_ONLY]; + const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]); + assert.equal(anySetFails(sets), true); + }); +}); diff --git a/terraform/README.md b/terraform/README.md index ddeb0bc5..40b78a09 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -57,7 +57,7 @@ bash scripts/test-verify-cloudfront-release.sh PRs cannot mix `terraform/` with deployable application files. Workflow, docs, and gate-script changes may travel with either side. G13 is `python3 scripts/check_app_terraform_isolation.py` against the merge base of -the PR. +the PR, and against each queued PR (first-parent commit) on `merge_group`. `npm run test:terraform` and `npm run verify` wrap the same gates. They never create an HCP run or touch AWS.