diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml new file mode 100644 index 00000000..df584feb --- /dev/null +++ b/.github/workflows/backend-quality-gate.yml @@ -0,0 +1,108 @@ +name: Staging Backend Quality Gate + +on: + pull_request: + push: + branches: [staging] + workflow_dispatch: + +concurrency: + group: backend-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt + npm ci --prefix frontend + + - name: Run Python quick quality gate + run: | + ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ + tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ + tests/test_yoga_rules_integrity.py + python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + mkdir -p artifacts + python scripts/run_quality_gate.py \ + --profile quick --skip-yoga-logic --skip-frontend-runtime \ + 2>&1 | tee artifacts/quick-quality-gate.log + python -m build --no-isolation + + - name: Upload quick quality gate diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: quick-quality-gate-diagnostics + path: artifacts/quick-quality-gate.log + + - name: Run database tests + run: npm run test:db --prefix frontend + + - name: Validate frontend + env: + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder + run: | + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + + publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/staging' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish API image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/railway-api.Dockerfile + push: true + tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }} + + - name: Build and publish web image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/railway-web.Dockerfile + push: true + tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }} + build-args: | + NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder diff --git a/frontend/package.json b/frontend/package.json index 0b2c162b..98400d7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "start": "next start", "test": "tsx --test tests/*.test.ts", "test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts", + "test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts", "db:migrate": "node scripts/db-migrate.mjs", "db:migrate:check": "node scripts/db-migrate.mjs --check", "lint": "eslint", diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts new file mode 100644 index 00000000..47aa79bb --- /dev/null +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const workflowUrl = new URL( + "../../.github/workflows/backend-quality-gate.yml", + import.meta.url, +); + +function workflowSource(): string { + return readFileSync(workflowUrl, "utf8"); +} + +function jobSource(workflow: string, job: "validate" | "publish"): string { + const nextJob = job === "validate" ? "publish" : undefined; + const pattern = nextJob + ? new RegExp(`^ ${job}:\\n([\\s\\S]*?)(?=^ ${nextJob}:\\n)`, "m") + : new RegExp(`^ ${job}:\\n([\\s\\S]*)$`, "m"); + const match = workflow.match(pattern); + assert.ok(match, `${job} job is missing`); + return match[0]; +} + +test("backend quality gate covers pull requests, staging pushes, and manual runs", () => { + const workflow = workflowSource(); + + assert.match(workflow, /^name: Staging Backend Quality Gate$/m); + assert.match(workflow, /^on:\n pull_request:\n push:\n branches: \[staging\]\n workflow_dispatch:$/m); + assert.match( + workflow, + /^concurrency:\n group: backend-quality-\$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\n cancel-in-progress: true$/m, + ); + assert.match(workflow, /^permissions:\n contents: read$/m); +}); + +test("validation runs the database, frontend, and existing Python quick gates", () => { + const validate = jobSource(workflowSource(), "validate"); + + assert.match(validate, /runs-on: ubuntu-latest/); + assert.match(validate, /timeout-minutes: 30/); + assert.match(validate, /actions\/setup-python@v5[\s\S]*python-version: ['"]3\.12['"]/); + assert.match(validate, /actions\/setup-node@v4[\s\S]*node-version: ['"]22['"]/); + assert.match( + validate, + /python -m pip install -r requirements\.txt -r requirements-dev\.txt/, + ); + assert.match(validate, /npm ci --prefix frontend/); + assert.match( + validate, + /ruff check scripts\/run_quality_gate\.py tests\/test_varga_bphs\.py \\\n\s+tests\/test_ashtakavarga_invariants\.py tests\/test_cli_smoke\.py \\\n\s+tests\/test_yoga_rules_integrity\.py/, + ); + assert.match( + validate, + /python -m py_compile scripts\/\*\.py jyotish_vedic\/\*\.py mcp_server\.py/, + ); + assert.match( + validate, + /python scripts\/run_quality_gate\.py \\\n\s+--profile quick --skip-yoga-logic --skip-frontend-runtime \\\n\s+2>&1 \| tee artifacts\/quick-quality-gate\.log/, + ); + assert.match(validate, /python -m build --no-isolation/); + assert.match(validate, /npm run test:db --prefix frontend/); + assert.match(validate, /npm test --prefix frontend/); + assert.match(validate, /npm run lint --prefix frontend/); + assert.match(validate, /npm run build --prefix frontend/); + assert.match( + validate, + /NEXT_PUBLIC_SUPABASE_URL: https:\/\/placeholder\.supabase\.co/, + ); + assert.match(validate, /NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder/); + assert.match( + validate, + /name: Upload quick quality gate diagnostics[\s\S]*if: always\(\)[\s\S]*actions\/upload-artifact@v4[\s\S]*path: artifacts\/quick-quality-gate\.log/, + ); + assert.doesNotMatch(validate, /packages: write/); +}); + +test("publishing waits for validation and publishes immutable staging SHA images", () => { + const workflow = workflowSource(); + const publish = jobSource(workflow, "publish"); + + assert.match( + publish, + /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/staging'/, + ); + assert.match(publish, /needs: validate/); + assert.match( + publish, + /permissions:\n contents: read\n packages: write/, + ); + assert.match(publish, /docker\/login-action@v3/); + assert.match(publish, /password: \$\{\{ secrets\.GITHUB_TOKEN \}\}/); + assert.equal(publish.match(/docker\/build-push-action@v6/g)?.length, 2); + assert.match( + publish, + /context: \.\n\s+file: deploy\/railway-api\.Dockerfile[\s\S]*push: true[\s\S]*tags: ghcr\.io\/jesse-ux\/jyotisha-api:\$\{\{ github\.sha \}\}/, + ); + assert.match( + publish, + /context: \.\n\s+file: deploy\/railway-web\.Dockerfile[\s\S]*push: true[\s\S]*tags: ghcr\.io\/jesse-ux\/jyotisha-web:\$\{\{ github\.sha \}\}[\s\S]*build-args: \|\n\s+NEXT_PUBLIC_SUPABASE_URL=https:\/\/placeholder\.supabase\.co\n\s+NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder/, + ); + assert.doesNotMatch(publish, /(?:^|:)latest(?:$|\s)/m); +});