ci: add automatic backend quality gate

This commit is contained in:
Jesse_Chen
2026-07-21 00:20:09 +08:00
parent 4232c893e8
commit 0a87f7756e
3 changed files with 211 additions and 0 deletions
+108
View File
@@ -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
+1
View File
@@ -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",
@@ -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);
});