From c5e218d37ab4229ebcb7450bea3c98d19a248336 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:39:04 +0900 Subject: [PATCH] feat: orchestrate blocking frontend quality gates --- .gitea/workflows/quality-gates.yml | 189 ++++++++++ README.md | 77 +++- config/ci/gates.json | 335 ++++++++++++++++++ docs/architecture/overview.md | 19 + docs/architecture/review-ledger.json | 17 + docs/architecture/static-delivery.md | 21 ++ docs/operations/ci-quality-gates.md | 40 +++ package.json | 5 +- scripts/check-ci-contract.mjs | 111 ++++++ scripts/collect-web-vitals-evidence.mjs | 2 +- scripts/run-ci-gate.mjs | 86 +++++ scripts/verify-documentation-readiness.mjs | 51 +++ .../policies/promotion-readiness.js | 58 +++ tests/unit/promotion-readiness.test.js | 49 +++ 14 files changed, 1057 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/quality-gates.yml create mode 100644 config/ci/gates.json create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/review-ledger.json create mode 100644 docs/architecture/static-delivery.md create mode 100644 docs/operations/ci-quality-gates.md create mode 100644 scripts/check-ci-contract.mjs create mode 100644 scripts/run-ci-gate.mjs create mode 100644 scripts/verify-documentation-readiness.mjs create mode 100644 src/application/policies/promotion-readiness.js create mode 100644 tests/unit/promotion-readiness.test.js diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml new file mode 100644 index 0000000..efede9e --- /dev/null +++ b/.gitea/workflows/quality-gates.yml @@ -0,0 +1,189 @@ +name: frontend-quality-gates + +on: + push: + branches: [develop] + tags: ["v*"] + pull_request: + workflow_dispatch: + inputs: + stage: + description: Highest promotion tier to evaluate + required: true + default: merge + type: choice + options: + - merge + - release + - production + - field + - documentation + +env: + NODE_VERSION: "24" + +jobs: + merge_gate: + name: ${{ matrix.gate }} / ${{ matrix.name }} + if: ${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-001, name: manifest-lockfile, browser: false } + - { gate: FE-GATE-002, name: lint, browser: false } + - { gate: FE-GATE-003, name: typecheck, browser: false } + - { gate: FE-GATE-004, name: runtime-schema, browser: false } + - { gate: FE-GATE-005, name: unit, browser: false } + - { gate: FE-GATE-006, name: component, browser: false } + - { gate: FE-GATE-007, name: integration, browser: false } + - { gate: FE-GATE-008, name: e2e, browser: true } + - { gate: FE-GATE-009, name: accessibility, browser: true } + - { gate: FE-GATE-010, name: architecture, browser: false } + - { gate: FE-GATE-011, name: build, browser: false } + - { gate: FE-GATE-013, name: security, browser: false } + - { gate: FE-GATE-020, name: sample-removal, browser: false } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Install Chromium + if: ${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.gate }}-${{ gitea.run_id }} + path: artifacts/ + if-no-files-found: warn + + release_gate: + name: ${{ matrix.gate }} / ${{ matrix.name }} + needs: merge_gate + if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }} + runs-on: ubuntu-latest + env: + HOSTING_BASE_URL: ${{ vars.HOSTING_BASE_URL }} + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-012, name: bundle, browser: false } + - { gate: FE-GATE-014, name: config-compatibility, browser: false } + - { gate: FE-GATE-015, name: release-coherence, browser: false } + - { gate: FE-GATE-019, name: hosting-header, browser: false } + - { gate: FE-GATE-026, name: lab-performance, browser: true } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Install Chromium + if: ${{ matrix.browser }} + run: corepack pnpm exec playwright install --with-deps chromium + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.gate }}-${{ gitea.run_id }} + path: artifacts/ + if-no-files-found: warn + + production_gate: + name: ${{ matrix.gate }} / ${{ matrix.name }} + needs: release_gate + if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { gate: FE-GATE-016, name: rollback-drill } + - { gate: FE-GATE-021, name: runbook-boot-config } + - { gate: FE-GATE-022, name: runbook-chunk-mismatch } + - { gate: FE-GATE-023, name: runbook-api-degradation } + - { gate: FE-GATE-024, name: runbook-telemetry } + - { gate: FE-GATE-025, name: runbook-release-rollback } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run blocking gate + run: corepack pnpm ci:gate -- ${{ matrix.gate }} + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.gate }}-${{ gitea.run_id }} + path: artifacts/ + if-no-files-found: warn + + field_gate: + name: FE-GATE-018 / field-web-vitals + needs: production_gate + if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }} + runs-on: ubuntu-latest + env: + FIELD_WEB_VITALS_INPUT: ${{ vars.FIELD_WEB_VITALS_INPUT }} + MIN_ELIGIBLE_SAMPLES: ${{ vars.MIN_ELIGIBLE_SAMPLES }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run blocking gate + run: corepack pnpm ci:gate -- FE-GATE-018 + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: FE-GATE-018-${{ gitea.run_id }} + path: artifacts/ + if-no-files-found: warn + + documentation_gate: + name: FE-GATE-017 / diagram-review + if: ${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Frozen install + run: | + corepack enable + corepack pnpm install --frozen-lockfile + - name: Run documentation gate + run: corepack pnpm ci:gate -- FE-GATE-017 + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: FE-GATE-017-${{ gitea.run_id }} + path: artifacts/ + if-no-files-found: warn diff --git a/README.md b/README.md index 06220a8..2d4dac6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ -# clean-architecture-frontend-template +# Clean Architecture Frontend Template +A React/Vite reference implementation where architecture boundaries, +integration behavior, release coherence, accessibility, performance, and +operations are executable contracts rather than conventions. + +## Start locally + +Requirements: Node 24 and Corepack. The repository pins pnpm in `package.json`. + +```bash +corepack pnpm install --frozen-lockfile +corepack pnpm dev +``` + +Runtime-public settings live in `public/config.json` and are validated before +the product tree mounts. Client secrets are forbidden. + +## Architecture + +Dependencies point inward: + +```text +presentation -> application -> domain +adapters -----^ +bootstrap composes concrete adapters +contracts own cross-cutting registries +``` + +See `docs/architecture/overview.md` and `docs/architecture/layers.md`. The +removable sample slice is under `src/sample/contract-fixture`; product code is +not allowed to import it. + +## Verification + +Common local checks: + +```bash +corepack pnpm lint +corepack pnpm check:types +corepack pnpm check:architecture +corepack pnpm test:all +corepack pnpm test:e2e +corepack pnpm test:a11y +corepack pnpm build +corepack pnpm check:bundle +corepack pnpm test:performance +corepack pnpm verify:compatibility +corepack pnpm verify:release +corepack pnpm check:registries +corepack pnpm drill:runbooks +corepack pnpm check:ci +``` + +Two gates intentionally need external evidence: + +- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review. +- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum + eligible-sample threshold and 28 days of production data exist. + +Live release verification additionally requires `HOSTING_BASE_URL`. + +## CI and evidence + +The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is +`.gitea/workflows/quality-gates.yml`. It follows: + +```text +MERGE_READY -> RELEASE_READY -> PROD_PROMOTION_READY -> FIELD_SLO_READY +``` + +`DOCUMENTATION_READY` is independent. No gate is downgraded to a warning. +Machine-readable evidence is written below `artifacts/`; generated evidence is +ignored by Git while `.gitkeep` files preserve the taxonomy. + +Operational details are in `docs/operations/`, with incident procedures in +`docs/runbooks/`. diff --git a/config/ci/gates.json b/config/ci/gates.json new file mode 100644 index 0000000..ee1a5d5 --- /dev/null +++ b/config/ci/gates.json @@ -0,0 +1,335 @@ +{ + "schemaVersion": 1, + "providerAdapter": ".gitea/workflows/quality-gates.yml", + "stages": { + "merge": { + "readiness": "MERGE_READY", + "needs": null, + "gates": [ + "FE-GATE-001", + "FE-GATE-002", + "FE-GATE-003", + "FE-GATE-004", + "FE-GATE-005", + "FE-GATE-006", + "FE-GATE-007", + "FE-GATE-008", + "FE-GATE-009", + "FE-GATE-010", + "FE-GATE-011", + "FE-GATE-013", + "FE-GATE-020" + ] + }, + "release": { + "readiness": "RELEASE_READY", + "needs": "merge", + "gates": [ + "FE-GATE-012", + "FE-GATE-014", + "FE-GATE-015", + "FE-GATE-019", + "FE-GATE-026" + ] + }, + "production": { + "readiness": "PROD_PROMOTION_READY", + "needs": "release", + "gates": [ + "FE-GATE-016", + "FE-GATE-021", + "FE-GATE-022", + "FE-GATE-023", + "FE-GATE-024", + "FE-GATE-025" + ] + }, + "field": { + "readiness": "FIELD_SLO_READY", + "needs": "production", + "gates": ["FE-GATE-018"] + }, + "documentation": { + "readiness": "DOCUMENTATION_READY", + "needs": null, + "gates": ["FE-GATE-017"] + } + }, + "gates": { + "FE-GATE-001": { + "name": "manifest-lockfile", + "steps": [{ "script": "verify:lockfile", "expect": "pass" }], + "logPath": "artifacts/quality/install.txt", + "evidence": ["artifacts/quality/install.txt"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-002": { + "name": "lint", + "steps": [{ "script": "lint", "expect": "pass" }], + "logPath": "artifacts/quality/lint.txt", + "evidence": ["artifacts/quality/lint.txt"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-003": { + "name": "typecheck", + "steps": [ + { "script": "check:types", "expect": "pass" }, + { "script": "check:types:fixture", "expect": "fail" } + ], + "logPath": "artifacts/quality/check-types.txt", + "evidence": ["artifacts/quality/check-types.txt"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-004": { + "name": "runtime-schema", + "steps": [{ "script": "test:runtime-schema", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-004.txt", + "evidence": ["artifacts/tests/runtime-schema.xml"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-005": { + "name": "unit", + "steps": [{ "script": "test:unit", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-005.txt", + "evidence": ["artifacts/tests/unit.xml"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-006": { + "name": "component", + "steps": [{ "script": "test:component", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-006.txt", + "evidence": ["artifacts/tests/component.xml"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-007": { + "name": "integration", + "steps": [{ "script": "test:integration", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-007.txt", + "evidence": ["artifacts/tests/integration.xml"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-008": { + "name": "e2e", + "steps": [{ "script": "test:e2e", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-008.txt", + "evidence": ["artifacts/tests/e2e/report/index.html"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-009": { + "name": "accessibility", + "steps": [ + { "script": "test:a11y", "expect": "pass" }, + { "script": "review:a11y-manual", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-009.txt", + "evidence": [ + "artifacts/tests/a11y.json", + "artifacts/tests/a11y-manual/APP_HOME.md" + ], + "retentionClass": "merge-cycle" + }, + "FE-GATE-010": { + "name": "architecture", + "steps": [{ "script": "check:architecture", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-010.txt", + "evidence": ["artifacts/quality/dependency-report.json"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-011": { + "name": "build", + "steps": [{ "script": "build", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-011.txt", + "evidence": ["artifacts/release/build-manifest.json"], + "retentionClass": "release-coherence" + }, + "FE-GATE-012": { + "name": "bundle", + "steps": [ + { "script": "build", "expect": "pass" }, + { "script": "check:bundle", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-012.txt", + "evidence": ["artifacts/performance/bundle.json"], + "retentionClass": "release-coherence" + }, + "FE-GATE-013": { + "name": "security", + "steps": [ + { "script": "build:release", "expect": "pass" }, + { "script": "check:browser-security", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-013.txt", + "evidence": [ + "artifacts/security/scan.sarif", + "artifacts/release/dependency-inventory.json", + "artifacts/security/dependency-diff.json" + ], + "retentionClass": "release-coherence" + }, + "FE-GATE-014": { + "name": "config-compatibility", + "steps": [{ "script": "verify:compatibility", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-014.txt", + "evidence": ["artifacts/release/compatibility.json"], + "retentionClass": "release-coherence" + }, + "FE-GATE-015": { + "name": "release-coherence", + "steps": [ + { "script": "build", "expect": "pass" }, + { "script": "verify:release", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-015.txt", + "evidence": ["artifacts/release/verification.json"], + "retentionClass": "release-coherence" + }, + "FE-GATE-016": { + "name": "rollback-drill", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-005"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-016.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-005/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-017": { + "name": "diagram-review", + "steps": [{ "script": "verify:documentation", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-017.txt", + "evidence": ["artifacts/quality/documentation-review.json"], + "retentionClass": "documentation" + }, + "FE-GATE-018": { + "name": "field-web-vitals", + "steps": [ + { "script": "collect:web-vitals-evidence", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-018.txt", + "evidence": ["artifacts/performance/field-web-vitals.json"], + "retentionClass": "field" + }, + "FE-GATE-019": { + "name": "hosting-header", + "requiresEnvironment": ["HOSTING_BASE_URL"], + "steps": [ + { "script": "build", "expect": "pass" }, + { "script": "verify:hosting-headers", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-019.txt", + "evidence": ["artifacts/release/hosting-headers.json"], + "retentionClass": "release-coherence" + }, + "FE-GATE-020": { + "name": "sample-removal", + "steps": [{ "script": "test:sample-removal", "expect": "pass" }], + "logPath": "artifacts/quality/gates/FE-GATE-020.txt", + "evidence": ["artifacts/tests/sample-removal.xml"], + "retentionClass": "merge-cycle" + }, + "FE-GATE-021": { + "name": "runbook-boot-config", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-001"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-021.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-001/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-022": { + "name": "runbook-chunk-mismatch", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-002"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-022.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-002/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-023": { + "name": "runbook-api-degradation", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-003"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-023.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-003/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-024": { + "name": "runbook-telemetry", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-004"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-024.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-004/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-025": { + "name": "runbook-release-rollback", + "steps": [ + { "script": "build", "expect": "pass" }, + { + "script": "drill:runbook", + "args": ["--", "FE-RB-005"], + "expect": "pass" + } + ], + "logPath": "artifacts/quality/gates/FE-GATE-025.txt", + "evidence": [ + "artifacts/runbooks/FE-RB-005/local-release/record.json" + ], + "retentionClass": "prod-drill" + }, + "FE-GATE-026": { + "name": "lab-performance", + "steps": [ + { "script": "build", "expect": "pass" }, + { "script": "test:performance", "expect": "pass" } + ], + "logPath": "artifacts/quality/gates/FE-GATE-026.txt", + "evidence": ["artifacts/performance/lab.json"], + "retentionClass": "release-coherence" + } + }, + "retention": { + "durationStatus": "UNSUPPORTED_PENDING_ORGANIZATION_POLICY", + "merge-cycle": "at least through pull-request readiness decision", + "release-coherence": "at least until the next release is promoted", + "prod-drill": "at least until the next production promotion decision", + "field": "through the 28-day window and aggregation", + "documentation": "through documentation readiness review" + } +} diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..a3aef97 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,19 @@ +# Architecture overview + +```mermaid +flowchart LR + Bootstrap[bootstrap / composition root] --> Presentation[presentation] + Bootstrap --> Adapters[adapters] + Presentation --> Application[application] + Adapters --> Application + Application --> Domain[domain] + Contracts[contract registries] --> Bootstrap + Contracts --> Adapters + Contracts --> Presentation +``` + +Dependencies point inward. Presentation calls application use cases, adapters +implement application ports, and only the composition root selects concrete +adapters. Contract registries are the single named source for routes, API +operations, environment values, storage keys, errors, queries, telemetry, and +release tokens. diff --git a/docs/architecture/review-ledger.json b/docs/architecture/review-ledger.json new file mode 100644 index 0000000..8991ae4 --- /dev/null +++ b/docs/architecture/review-ledger.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "status": "pending-review", + "reviewerThreshold": null, + "reviews": { + "overview": { + "path": "docs/architecture/overview.md", + "reviewer": null, + "score": null + }, + "staticDelivery": { + "path": "docs/architecture/static-delivery.md", + "reviewer": null, + "score": null + } + } +} diff --git a/docs/architecture/static-delivery.md b/docs/architecture/static-delivery.md new file mode 100644 index 0000000..fd69a60 --- /dev/null +++ b/docs/architecture/static-delivery.md @@ -0,0 +1,21 @@ +# Static asset and runtime-config delivery + +```mermaid +sequenceDiagram + participant CI + participant ImmutableRelease + participant ActivePointer + participant Browser + CI->>ImmutableRelease: upload hashed assets + CI->>ImmutableRelease: upload release manifest + CI->>ImmutableRelease: upload runtime config + CI->>ImmutableRelease: probe asset reachability + CI->>ActivePointer: atomically switch HTML + Browser->>ActivePointer: fetch revalidated HTML + Browser->>ImmutableRelease: fetch no-store config and manifest + Browser->>ImmutableRelease: fetch immutable hashed assets + CI->>Browser: boot, route, API, and reload-loop smoke +``` + +Rollback changes the active pointer only after confirming that the prior +immutable release has a coherent HTML/assets/config/API/manifest tuple. diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md new file mode 100644 index 0000000..f4a2d25 --- /dev/null +++ b/docs/operations/ci-quality-gates.md @@ -0,0 +1,40 @@ +# CI quality-gate orchestration + +`config/ci/gates.json` is the executable registry for all 26 gates. The Gitea +adapter runs each gate as an independent matrix check with full fan-out and no +soft-fail wiring. + +The dependency graph is: + +```text +MERGE_READY + -> RELEASE_READY + -> PROD_PROMOTION_READY + -> FIELD_SLO_READY + +DOCUMENTATION_READY (off-chain) +``` + +Pull requests and `develop` pushes evaluate merge readiness. Version tags +evaluate merge then release readiness. Production and field evaluation require +an explicit workflow dispatch. The field tier cannot pass until the 28-day +sample threshold decision is recorded. Documentation readiness cannot pass +until both scoped diagrams have a recorded reviewer threshold and signed +scores. + +All jobs upload the shared `artifacts/` tree even after failure. Numeric +retention remains an organization/provider decision; the workflow intentionally +does not invent `retention-days`. The relative minimums are recorded in the +registry: merge evidence through the PR decision, coherent release evidence +through the next release promotion, drill evidence through the next production +promotion, and field evidence through aggregation. + +Repository variables required by higher tiers: + +- `HOSTING_BASE_URL` for live header verification +- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document +- `MIN_ELIGIBLE_SAMPLES` after the baseline decision + +Branch protection must mark each `FE-GATE-* / ` check required for its +declared tier. This repository cannot configure server-side protection by +committing a file. diff --git a/package.json b/package.json index 0b49394..b2d329a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,10 @@ "test:performance": "node scripts/test-performance.mjs", "collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs", "drill:runbook": "node scripts/drill-runbook.mjs", - "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005" + "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005", + "ci:gate": "node scripts/run-ci-gate.mjs", + "check:ci": "node scripts/check-ci-contract.mjs", + "verify:documentation": "node scripts/verify-documentation-readiness.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/scripts/check-ci-contract.mjs b/scripts/check-ci-contract.mjs new file mode 100644 index 0000000..98fdd68 --- /dev/null +++ b/scripts/check-ci-contract.mjs @@ -0,0 +1,111 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { + evaluatePromotionReadiness, + PROMOTION_FORMULA, +} from "../src/application/policies/promotion-readiness.js"; + +const document = JSON.parse(await readFile("config/ci/gates.json", "utf8")); +const workflow = await readFile(document.providerAdapter, "utf8"); +const failures = []; +const stageFormula = { + merge: PROMOTION_FORMULA.MERGE_READY, + release: PROMOTION_FORMULA.RELEASE_READY, + production: PROMOTION_FORMULA.PROD_PROMOTION_READY, + field: PROMOTION_FORMULA.FIELD_SLO_READY, + documentation: PROMOTION_FORMULA.DOCUMENTATION_READY, +}; + +for (const [stage, expectedGates] of Object.entries(stageFormula)) { + const actual = document.stages[stage]?.gates; + if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) { + failures.push(`${stage} gate formula drift`); + } +} + +const configuredGateIds = Object.keys(document.gates).sort(); +const expectedGateIds = Array.from( + { length: 26 }, + (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, +); +if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) { + failures.push("gate registry must contain FE-GATE-001..026 exactly once"); +} + +for (const [gateId, gate] of Object.entries(document.gates)) { + if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) { + failures.push(`${gateId} lacks command, evidence, or retention wiring`); + } +} + +const forbiddenWorkflowPatterns = [ + /continue-on-error\s*:/, + /retention-days\s*:/, + /allow_failure\s*:/, +]; +for (const pattern of forbiddenWorkflowPatterns) { + if (pattern.test(workflow)) { + failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`); + } +} +for (const requiredToken of [ + "merge_gate:", + "release_gate:", + "production_gate:", + "field_gate:", + "documentation_gate:", + "needs: merge_gate", + "needs: release_gate", + "needs: production_gate", + "actions/upload-artifact@v4", + "if: always()", +]) { + if (!workflow.includes(requiredToken)) { + failures.push(`workflow missing ${requiredToken}`); + } +} + +const passingResults = Object.fromEntries( + expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]), +); +const allPass = evaluatePromotionReadiness(passingResults); +const negativeFixtures = []; +for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) { + const failedGate = gateIds[0]; + const result = evaluatePromotionReadiness({ + ...passingResults, + [failedGate]: "FAIL", + }); + const passed = + /** @type {Readonly>} */ (result)[readiness] === + false; + negativeFixtures.push({ readiness, failedGate, passed }); + if (!passed) failures.push(`${readiness} did not fail closed`); +} +if (!Object.values(allPass).every(Boolean)) { + failures.push("all-PASS formula did not produce every readiness state"); +} + +const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + providerAdapter: document.providerAdapter, + gateCount: configuredGateIds.length, + noDowngrade: failures.every( + (failure) => !failure.includes("downgrade"), + ), + durationStatus: document.retention.durationStatus, + negativeFixtures, + failures, + passed: failures.length === 0, +}; +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/ci-contract.json", + `${JSON.stringify(report, null, 2)}\n`, +); +if (failures.length > 0) { + process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); + process.exit(1); +} +process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n"); diff --git a/scripts/collect-web-vitals-evidence.mjs b/scripts/collect-web-vitals-evidence.mjs index 4eac58c..56da0d1 100644 --- a/scripts/collect-web-vitals-evidence.mjs +++ b/scripts/collect-web-vitals-evidence.mjs @@ -6,7 +6,7 @@ import { } from "../src/application/policies/performance-budgets.js"; const inputPath = - process.env.FIELD_WEB_VITALS_INPUT ?? + process.env.FIELD_WEB_VITALS_INPUT || "config/performance/field-input.example.json"; const input = /** @type {{ diff --git a/scripts/run-ci-gate.mjs b/scripts/run-ci-gate.mjs new file mode 100644 index 0000000..b73f511 --- /dev/null +++ b/scripts/run-ci-gate.mjs @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const gateId = process.argv + .slice(2) + .find((argument) => /^FE-GATE-\d{3}$/.test(argument)); +const document = + /** @type {{ + * gates: Record, + * logPath: string, + * evidence: string[], + * retentionClass: string, + * requiresEnvironment?: string[] + * }> + * }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8"))); +const gate = gateId ? document.gates[gateId] : undefined; +if (!gateId || !gate) { + process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n"); + process.exit(2); +} + +const output = []; +let passed = true; +for (const variable of gate.requiresEnvironment ?? []) { + if (!process.env[variable]) { + output.push(`missing required environment: ${variable}`); + passed = false; + } +} + +if (passed) { + for (const step of gate.steps) { + const result = spawnSync( + "corepack", + ["pnpm", step.script, ...(step.args ?? [])], + { encoding: "utf8", env: process.env }, + ); + output.push( + `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(), + result.stdout, + result.stderr, + ); + const exitedSuccessfully = result.status === 0; + const expectationMet = + step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully; + if (!expectationMet) { + output.push( + `expectation failed: expected ${step.expect}, exit=${result.status}`, + ); + passed = false; + break; + } + } +} + +await mkdir(path.dirname(gate.logPath), { recursive: true }); +await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); + +if (passed) { + for (const evidencePath of gate.evidence) { + try { + await access(evidencePath); + } catch { + output.push(`missing evidence: ${evidencePath}`); + passed = false; + } + } + if (!passed) { + await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); + } +} + +if (!passed) { + process.stderr.write(`${gateId} ${gate.name}: FAIL\n`); + process.exit(1); +} +process.stdout.write( + `${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`, +); diff --git a/scripts/verify-documentation-readiness.mjs b/scripts/verify-documentation-readiness.mjs new file mode 100644 index 0000000..6534e19 --- /dev/null +++ b/scripts/verify-documentation-readiness.mjs @@ -0,0 +1,51 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +const ledger = JSON.parse( + await readFile("docs/architecture/review-ledger.json", "utf8"), +); +const results = []; +for (const [diagram, review] of Object.entries(ledger.reviews)) { + const content = await readFile(review.path, "utf8"); + const hasDiagram = /```mermaid[\s\S]+```/.test(content); + const scorePass = + typeof ledger.reviewerThreshold === "number" && + typeof review.score === "number" && + review.score >= ledger.reviewerThreshold; + results.push({ + diagram, + path: review.path, + hasDiagram, + reviewer: review.reviewer, + score: review.score, + scorePass, + passed: + hasDiagram && + Boolean(review.reviewer) && + scorePass && + ledger.status === "PASS_SCOPED", + }); +} +const passed = results.every((result) => result.passed); +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/documentation-review.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + status: ledger.status, + reviewerThreshold: ledger.reviewerThreshold, + results, + passed, + }, + null, + 2, + )}\n`, +); +if (!passed) { + process.stderr.write( + "Documentation readiness: FAIL_UNVERIFIED (reviewer threshold and signed reviews required)\n", + ); + process.exit(1); +} +process.stdout.write("Documentation readiness: PASS_SCOPED\n"); diff --git a/src/application/policies/promotion-readiness.js b/src/application/policies/promotion-readiness.js new file mode 100644 index 0000000..2a676a2 --- /dev/null +++ b/src/application/policies/promotion-readiness.js @@ -0,0 +1,58 @@ +export const PROMOTION_FORMULA = Object.freeze({ + MERGE_READY: Object.freeze([ + "FE-GATE-001", + "FE-GATE-002", + "FE-GATE-003", + "FE-GATE-004", + "FE-GATE-005", + "FE-GATE-006", + "FE-GATE-007", + "FE-GATE-008", + "FE-GATE-009", + "FE-GATE-010", + "FE-GATE-011", + "FE-GATE-013", + "FE-GATE-020", + ]), + RELEASE_READY: Object.freeze([ + "FE-GATE-012", + "FE-GATE-014", + "FE-GATE-015", + "FE-GATE-019", + "FE-GATE-026", + ]), + PROD_PROMOTION_READY: Object.freeze([ + "FE-GATE-016", + "FE-GATE-021", + "FE-GATE-022", + "FE-GATE-023", + "FE-GATE-024", + "FE-GATE-025", + ]), + FIELD_SLO_READY: Object.freeze(["FE-GATE-018"]), + DOCUMENTATION_READY: Object.freeze(["FE-GATE-017"]), +}); + +/** @param {Record} gateResults */ +export function evaluatePromotionReadiness(gateResults) { + /** @param {readonly string[]} gateIds */ + const allPass = (gateIds) => + gateIds.every((gateId) => gateResults[gateId] === "PASS"); + + const mergeReady = allPass(PROMOTION_FORMULA.MERGE_READY); + const releaseReady = + mergeReady && allPass(PROMOTION_FORMULA.RELEASE_READY); + const productionReady = + releaseReady && allPass(PROMOTION_FORMULA.PROD_PROMOTION_READY); + const fieldReady = + productionReady && allPass(PROMOTION_FORMULA.FIELD_SLO_READY); + const documentationReady = allPass(PROMOTION_FORMULA.DOCUMENTATION_READY); + + return Object.freeze({ + MERGE_READY: mergeReady, + RELEASE_READY: releaseReady, + PROD_PROMOTION_READY: productionReady, + FIELD_SLO_READY: fieldReady, + DOCUMENTATION_READY: documentationReady, + }); +} diff --git a/tests/unit/promotion-readiness.test.js b/tests/unit/promotion-readiness.test.js new file mode 100644 index 0000000..dbf5ac2 --- /dev/null +++ b/tests/unit/promotion-readiness.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluatePromotionReadiness, + PROMOTION_FORMULA, +} from "../../src/application/policies/promotion-readiness.js"; + +const allGateIds = Object.values(PROMOTION_FORMULA).flat(); +const passing = Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"])); + +describe("promotion readiness formula", () => { + it("requires every upstream tier before downstream readiness", () => { + expect(evaluatePromotionReadiness(passing)).toEqual({ + MERGE_READY: true, + RELEASE_READY: true, + PROD_PROMOTION_READY: true, + FIELD_SLO_READY: true, + DOCUMENTATION_READY: true, + }); + }); + + it.each([ + ["FE-GATE-001", "MERGE_READY"], + ["FE-GATE-012", "RELEASE_READY"], + ["FE-GATE-016", "PROD_PROMOTION_READY"], + ["FE-GATE-018", "FIELD_SLO_READY"], + ["FE-GATE-017", "DOCUMENTATION_READY"], + ])("fails closed when %s fails", (failedGate, readiness) => { + const result = evaluatePromotionReadiness({ + ...passing, + [failedGate]: "FAIL", + }); + expect(result[readiness]).toBe(false); + }); + + it("does not treat missing or unverified gates as pass", () => { + expect( + evaluatePromotionReadiness({ + ...passing, + "FE-GATE-009": "UNVERIFIED", + }), + ).toMatchObject({ + MERGE_READY: false, + RELEASE_READY: false, + PROD_PROMOTION_READY: false, + FIELD_SLO_READY: false, + }); + }); +});