mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
Merge pull request #239 from Sea-Haven-Industries/chore/parallel-ci-gates
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
ci: fan out quality gates from this repository
This commit is contained in:
commit
e51551c160
11 changed files with 328 additions and 155 deletions
56
.github/workflows/ci-terraform.yaml
vendored
56
.github/workflows/ci-terraform.yaml
vendored
|
|
@ -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
|
|
||||||
131
.github/workflows/ci.yaml
vendored
131
.github/workflows/ci.yaml
vendored
|
|
@ -13,25 +13,82 @@ on:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
static:
|
||||||
name: Build and test
|
name: static
|
||||||
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
runs-on: ubuntu-latest
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
steps:
|
||||||
with:
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
node-version: "24"
|
- 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, 3, 4]
|
||||||
|
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 }}/4
|
||||||
|
|
||||||
|
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:
|
governance:
|
||||||
# Repo-owned guarantee that every frontend quality gate runs from this
|
# Repo-owned gates: godfile ratchet, changed-file maintainability,
|
||||||
# repository, independent of (and in addition to) the reusable workflow.
|
# Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront
|
||||||
# `npm run verify` is the single command that chains: format check, lint
|
# verify, GitHub workflow shell, isolation classifier tests, and live G13
|
||||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
# (pull_request, merge_group per queued PR, and local). Format, lint, build,
|
||||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
# and unit tests run
|
||||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan
|
# in the parallel jobs above, not here.
|
||||||
# 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`.
|
|
||||||
#
|
#
|
||||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||||
# PR -> the PR target branch (origin/<base_ref>)
|
# PR -> the PR target branch (origin/<base_ref>)
|
||||||
|
|
@ -86,29 +143,29 @@ jobs:
|
||||||
tar -xzf actionlint.tar.gz actionlint
|
tar -xzf actionlint.tar.gz actionlint
|
||||||
sudo mv actionlint /usr/local/bin/actionlint
|
sudo mv actionlint /usr/local/bin/actionlint
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run governance
|
||||||
env:
|
env:
|
||||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||||
|
|
||||||
visual-regression:
|
ci-complete:
|
||||||
name: Visual regression
|
name: ci-complete
|
||||||
|
if: always()
|
||||||
|
needs: [static, build, unit, visual, governance]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- name: All required jobs passed
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
shell: bash
|
||||||
with:
|
env:
|
||||||
node-version: "24"
|
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||||
cache: npm
|
run: |
|
||||||
- run: npm ci
|
set -euo pipefail
|
||||||
- run: npm run test:e2e:visual
|
failed=0
|
||||||
- name: Upload visual diff artifacts
|
for result in ${RESULTS}; do
|
||||||
if: failure()
|
if [[ "${result}" != "success" ]]; then
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
failed=1
|
||||||
with:
|
fi
|
||||||
name: visual-regression-diffs
|
done
|
||||||
path: |
|
if [[ "${failed}" -ne 0 ]]; then
|
||||||
test-results/visual
|
echo "Required jobs did not all succeed: ${RESULTS}"
|
||||||
playwright-report-visual
|
exit 1
|
||||||
if-no-files-found: ignore
|
fi
|
||||||
retention-days: 14
|
|
||||||
|
|
|
||||||
1
.github/workflows/deploy-web.yaml
vendored
1
.github/workflows/deploy-web.yaml
vendored
|
|
@ -28,7 +28,6 @@ on:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "**/*.md"
|
- "**/*.md"
|
||||||
- ".github/workflows/ci.yaml"
|
- ".github/workflows/ci.yaml"
|
||||||
- ".github/workflows/ci-terraform.yaml"
|
|
||||||
- ".github/workflows/deploy-web.yaml"
|
- ".github/workflows/deploy-web.yaml"
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
|
||||||
|
|
@ -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
|
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
|
||||||
checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
|
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
|
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
|
gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)
|
||||||
CI.
|
(`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)
|
## Non-negotiable rules (enforced; do not work around)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,24 @@ isolation). A task is not done until this is green.
|
||||||
|
|
||||||
## Gate matrix
|
## Gate matrix
|
||||||
|
|
||||||
| Gate | Command / rule source | Enforced by | Scope |
|
| Gate | Command / rule source | Enforced by | Scope |
|
||||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------- |
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
|
||||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
| 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`) |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
|
||||||
|
|
||||||
## No-false-pass guarantees
|
## No-false-pass guarantees
|
||||||
|
|
||||||
|
|
@ -60,15 +60,17 @@ isolation). A task is not done until this is green.
|
||||||
|
|
||||||
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
|
||||||
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
Prettier on staged files at commit; commitlint enforces Conventional Commits.
|
||||||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
|
||||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
repository owns every job. `static` (`format:check` + `lint`), `build`
|
||||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
(`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
|
||||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
(Playwright visual), and `governance` (`npm run governance`, with Terraform
|
||||||
ratchets and repository gates are guaranteed from this repository regardless
|
1.16.0) run in parallel. `ci-complete` fails unless all of those jobs
|
||||||
of the reusable workflow.
|
succeeded and is the required merge-queue check. Live G13 runs on
|
||||||
- **Terraform CI ([`.github/workflows/ci-terraform.yaml`](.github/workflows/ci-terraform.yaml)):**
|
`pull_request` (merge-base range), `merge_group` (each queued PR as a
|
||||||
fmt, `init -backend=false`, validate, import-plan unit tests, and G13
|
first-parent commit), and locally. It skips `push`. A Terraform-only PR
|
||||||
classifier unit tests on `terraform/**` changes for PRs to `main` or `dev`.
|
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
|
## Toolchain pin
|
||||||
|
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -131,14 +131,15 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||||
rejects anything else at commit time.
|
rejects anything else at commit time.
|
||||||
- Open PRs against `main`. The PR body uses the three-section layout the
|
- Open PRs against `main`. The PR body uses the three-section layout the
|
||||||
template pre-fills: Summary, Changes and value, Ticket. `main` needs 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
|
`ci-complete` check and an approving review from a code owner
|
||||||
code owner (`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
|
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
|
||||||
approvals. PRs merge through the merge queue, so a branch does not need to
|
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
|
be updated with `main` before it merges. Merged branches are deleted
|
||||||
automatically.
|
automatically.
|
||||||
- PRs cannot mix `terraform/` with deployable application files (G13). Workflow,
|
- A change set cannot mix `terraform/` with deployable application files (G13),
|
||||||
docs, and gate-script changes may travel with either side. `deploy-web.yaml`
|
including each queued PR on the merge-group check. Workflow, docs, and
|
||||||
still ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
|
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
|
- 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
|
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
|
||||||
prod distribution exists.
|
prod distribution exists.
|
||||||
|
|
@ -148,19 +149,12 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||||
|
|
||||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||||
and PRs to `main`, calls
|
and PRs to `main`, runs format, lint, build, sharded unit tests, visual
|
||||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
regression, and `npm run governance` as parallel jobs, then `ci-complete`.
|
||||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
Conventions and gates are documented under
|
||||||
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
|
|
||||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
[`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))
|
- **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
|
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
|
||||||
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
|
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||||
"test:github-workflows": "bash scripts/check-github-workflows.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": "eslint . --max-warnings=0",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|
|
||||||
29
scripts/g13-live-isolation.mjs
Normal file
29
scripts/g13-live-isolation.mjs
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
|
||||||
|
|
||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
const ROOT = path.resolve(SCRIPT_DIR, "..");
|
||||||
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
|
||||||
|
|
@ -218,15 +220,79 @@ function runRepositoryGate(label, script) {
|
||||||
return { label, status: result.status, error: result.error };
|
return { label, status: result.status, error: result.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
function runIsolationGate(baseRef) {
|
function classifyIsolationPaths(files) {
|
||||||
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", baseRef, "HEAD"]);
|
|
||||||
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
|
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
input: `${files.join("\n")}\n`,
|
input: files.length > 0 ? `${files.join("\n")}\n` : "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
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() {
|
function main() {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const baseRef = resolveBaseRef();
|
const baseRef = resolveBaseRef();
|
||||||
|
|
@ -326,23 +392,30 @@ function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("─".repeat(64));
|
console.log("─".repeat(64));
|
||||||
console.log(`G13: application and Terraform isolation (${baseRef ?? "no base"}..HEAD)`);
|
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||||
if (!baseRef) {
|
if (!shouldRunLiveIsolation(eventName)) {
|
||||||
|
console.log(
|
||||||
|
`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)");
|
||||||
console.log(" FAIL (no valid base ref)");
|
console.log(" FAIL (no valid base ref)");
|
||||||
failures.push(
|
failures.push(
|
||||||
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const isolation = runIsolationGate(baseRef);
|
let isolation;
|
||||||
if (isolation.error) {
|
try {
|
||||||
console.log(" FAIL (could not start)");
|
isolation = runIsolationGate(baseRef, eventName);
|
||||||
failures.push(`G13: could not start: ${isolation.error.message}`);
|
} catch (error) {
|
||||||
} else {
|
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
|
||||||
const output = `${isolation.stdout ?? ""}${isolation.stderr ?? ""}`.trim();
|
console.log(" FAIL (could not resolve isolation diffs)");
|
||||||
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (isolation.status !== 0) {
|
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
|
||||||
failures.push("G13: do not mix deployable application files with terraform/");
|
}
|
||||||
}
|
if (isolation) {
|
||||||
|
logIsolationGate(baseRef, isolation);
|
||||||
|
recordIsolationFailures(isolation, failures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
73
scripts/test-g13-live-isolation.mjs
Normal file
73
scripts/test-g13-live-isolation.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -56,7 +56,8 @@ bash scripts/test-verify-cloudfront-release.sh
|
||||||
|
|
||||||
PRs cannot mix `terraform/` with deployable application files. Workflow, docs,
|
PRs cannot mix `terraform/` with deployable application files. Workflow, docs,
|
||||||
and gate-script changes may travel with either side. G13 is
|
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, 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
|
`npm run test:terraform` and `npm run verify` wrap the same gates. They never
|
||||||
create an HCP run or touch AWS.
|
create an HCP run or touch AWS.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue