From e645bcea56e270cac5c1d015bf8dd82e479f38b2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 00:42:10 +0800 Subject: [PATCH] fix: preserve backend quality gate failures --- .github/workflows/backend-quality-gate.yml | 2 + .../tests/staging-backend-workflows.test.ts | 442 +++++++++++++++--- 2 files changed, 380 insertions(+), 64 deletions(-) diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml index df584feb..cd931460 100644 --- a/.github/workflows/backend-quality-gate.yml +++ b/.github/workflows/backend-quality-gate.yml @@ -40,7 +40,9 @@ jobs: npm ci --prefix frontend - name: Run Python quick quality gate + shell: bash run: | + set -o pipefail 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 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 47aa79bb..024e4a9d 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -2,101 +2,415 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +type YamlNode = { + key: string; + value: string; + indent: number; + start: number; + end: number; +}; + +type WorkflowDocument = { + lines: string[]; + root: YamlNode[]; +}; + +type WorkflowStep = { + node: YamlNode; + name: string; +}; + const workflowUrl = new URL( "../../.github/workflows/backend-quality-gate.yml", import.meta.url, ); -function workflowSource(): string { - return readFileSync(workflowUrl, "utf8"); +function indentation(line: string): number { + return line.match(/^ */)?.[0].length ?? 0; } -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]; +function mappingNodes( + lines: string[], + indent: number, + start = 0, + end = lines.length, +): YamlNode[] { + const nodes: YamlNode[] = []; + for (let index = start; index < end; index += 1) { + const line = lines[index]; + if (!line.trim() || indentation(line) !== indent) continue; + const match = line.slice(indent).match(/^([^:#][^:]*):(?:\s+(.*))?$/); + if (!match) continue; + let nodeEnd = end; + for (let cursor = index + 1; cursor < end; cursor += 1) { + if (!lines[cursor].trim()) continue; + if (indentation(lines[cursor]) <= indent) { + nodeEnd = cursor; + break; + } + } + nodes.push({ + key: match[1], + value: match[2] ?? "", + indent, + start: index, + end: nodeEnd, + }); + } + return nodes; } -test("backend quality gate covers pull requests, staging pushes, and manual runs", () => { - const workflow = workflowSource(); +function parseWorkflow(): WorkflowDocument { + const lines = readFileSync(workflowUrl, "utf8").split("\n"); + return { lines, root: mappingNodes(lines, 0) }; +} - 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, +function requiredNode(nodes: YamlNode[], key: string): YamlNode { + const matches = nodes.filter((node) => node.key === key); + assert.equal(matches.length, 1, `expected exactly one YAML key: ${key}`); + return matches[0]; +} + +function children(document: WorkflowDocument, parent: YamlNode): YamlNode[] { + return mappingNodes( + document.lines, + parent.indent + 2, + parent.start + 1, + parent.end, + ); +} + +function child( + document: WorkflowDocument, + parent: YamlNode, + key: string, +): YamlNode { + return requiredNode(children(document, parent), key); +} + +function blockScalar(document: WorkflowDocument, node: YamlNode): string { + assert.equal(node.value, "|", `${node.key} must be a literal block scalar`); + const contentIndent = node.indent + 2; + return document.lines + .slice(node.start + 1, node.end) + .filter((line) => line.trim()) + .map((line) => { + assert.ok( + indentation(line) >= contentIndent, + `${node.key} contains an outdented block-scalar line`, + ); + return line.slice(contentIndent); + }) + .join("\n"); +} + +function job( + document: WorkflowDocument, + name: "validate" | "publish", +): YamlNode { + return child(document, requiredNode(document.root, "jobs"), name); +} + +function steps(document: WorkflowDocument, jobNode: YamlNode): WorkflowStep[] { + const stepsNode = child(document, jobNode, "steps"); + const stepIndent = stepsNode.indent + 2; + const result: WorkflowStep[] = []; + for ( + let index = stepsNode.start + 1; + index < stepsNode.end; + index += 1 + ) { + const line = document.lines[index]; + if (indentation(line) !== stepIndent) continue; + const match = line.slice(stepIndent).match(/^- name:\s+(.+)$/); + if (!match) continue; + let stepEnd = stepsNode.end; + for (let cursor = index + 1; cursor < stepsNode.end; cursor += 1) { + if ( + indentation(document.lines[cursor]) === stepIndent && + document.lines[cursor].slice(stepIndent).startsWith("- ") + ) { + stepEnd = cursor; + break; + } + } + result.push({ + name: match[1], + node: { + key: match[1], + value: "", + indent: stepIndent, + start: index, + end: stepEnd, + }, + }); + } + return result; +} + +function requiredStep( + document: WorkflowDocument, + jobNode: YamlNode, + name: string, +): WorkflowStep { + const matches = steps(document, jobNode).filter((step) => step.name === name); + assert.equal(matches.length, 1, `expected exactly one workflow step: ${name}`); + return matches[0]; +} + +function stepField( + document: WorkflowDocument, + step: WorkflowStep, + key: string, +): YamlNode { + return requiredNode( + mappingNodes( + document.lines, + step.node.indent + 2, + step.node.start + 1, + step.node.end, + ), + key, + ); +} + +test("backend quality gate has structured staging triggers and concurrency", () => { + const document = parseWorkflow(); + + assert.equal(requiredNode(document.root, "name").value, "Staging Backend Quality Gate"); + const triggers = requiredNode(document.root, "on"); + assert.equal(child(document, triggers, "pull_request").value, ""); + const push = child(document, triggers, "push"); + assert.equal(child(document, push, "branches").value, "[staging]"); + assert.equal(child(document, triggers, "workflow_dispatch").value, ""); + + const concurrency = requiredNode(document.root, "concurrency"); + assert.equal( + child(document, concurrency, "group").value, + "backend-quality-${{ github.workflow }}-${{ github.ref }}", + ); + assert.equal(child(document, concurrency, "cancel-in-progress").value, "true"); + const permissions = requiredNode(document.root, "permissions"); + assert.deepEqual( + children(document, permissions).map(({ key, value }) => [key, value]), + [["contents", "read"]], + ); + assert.deepEqual( + children(document, requiredNode(document.root, "jobs")).map( + ({ key }) => key, + ), + ["validate", "publish"], ); - assert.match(workflow, /^permissions:\n contents: read$/m); }); -test("validation runs the database, frontend, and existing Python quick gates", () => { - const validate = jobSource(workflowSource(), "validate"); +test("validation locates every command in its intended job and step", () => { + const document = parseWorkflow(); + const validate = job(document, "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.equal(child(document, validate, "runs-on").value, "ubuntu-latest"); + assert.equal(child(document, validate, "timeout-minutes").value, "30"); + assert.equal( + stepField( + document, + requiredStep(document, validate, "Set up Python"), + "uses", + ).value, + "actions/setup-python@v5", + ); + assert.equal( + stepField( + document, + requiredStep(document, validate, "Set up Node"), + "uses", + ).value, + "actions/setup-node@v4", + ); + assert.equal( + child( + document, + stepField( + document, + requiredStep(document, validate, "Set up Python"), + "with", + ), + "python-version", + ).value, + "'3.12'", + ); + assert.equal( + child( + document, + stepField( + document, + requiredStep(document, validate, "Set up Node"), + "with", + ), + "node-version", + ).value, + "'22'", + ); + + const install = blockScalar( + document, + stepField( + document, + requiredStep(document, validate, "Install dependencies"), + "run", + ), + ); assert.match( - validate, + install, /python -m pip install -r requirements\.txt -r requirements-dev\.txt/, ); - assert.match(validate, /npm ci --prefix frontend/); - assert.match( + assert.match(install, /npm ci --prefix frontend/); + + const pythonStep = requiredStep( + document, validate, + "Run Python quick quality gate", + ); + assert.equal(stepField(document, pythonStep, "shell").value, "bash"); + const pythonGate = blockScalar( + document, + stepField(document, pythonStep, "run"), + ); + assert.match(pythonGate, /^set -o pipefail\n/); + assert.match( + pythonGate, /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, + pythonGate, /python -m py_compile scripts\/\*\.py jyotish_vedic\/\*\.py mcp_server\.py/, ); assert.match( - validate, + pythonGate, /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(pythonGate, /python -m build --no-isolation/); + + assert.equal( + stepField( + document, + requiredStep(document, validate, "Run database tests"), + "run", + ).value, + "npm run test:db --prefix frontend", ); - 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/, + const frontend = requiredStep(document, validate, "Validate frontend"); + const frontendEnv = stepField(document, frontend, "env"); + assert.equal( + child(document, frontendEnv, "NEXT_PUBLIC_SUPABASE_URL").value, + "https://placeholder.supabase.co", + ); + assert.equal( + child(document, frontendEnv, "NEXT_PUBLIC_SUPABASE_ANON_KEY").value, + "placeholder", + ); + assert.equal( + blockScalar(document, stepField(document, frontend, "run")), + [ + "npm test --prefix frontend", + "npm run lint --prefix frontend", + "npm run build --prefix frontend", + ].join("\n"), + ); + assert.equal( + children(document, validate).some((node) => node.key === "permissions"), + false, ); - assert.doesNotMatch(validate, /packages: write/); }); -test("publishing waits for validation and publishes immutable staging SHA images", () => { - const workflow = workflowSource(); - const publish = jobSource(workflow, "publish"); +test("diagnostic artifact upload is always executed in validation", () => { + const document = parseWorkflow(); + const upload = requiredStep( + document, + job(document, "validate"), + "Upload quick quality gate diagnostics", + ); - assert.match( - publish, - /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/staging'/, + assert.equal(stepField(document, upload, "if").value, "always()"); + assert.equal( + stepField(document, upload, "uses").value, + "actions/upload-artifact@v4", ); - assert.match(publish, /needs: validate/); - assert.match( - publish, - /permissions:\n contents: read\n packages: write/, + assert.equal( + child(document, stepField(document, upload, "with"), "path").value, + "artifacts/quick-quality-gate.log", + ); +}); + +test("publishing has job-local permissions and immutable staging SHA images", () => { + const document = parseWorkflow(); + const publish = job(document, "publish"); + + assert.equal( + child(document, publish, "if").value, + "github.event_name == 'push' && github.ref == 'refs/heads/staging'", + ); + assert.equal(child(document, publish, "needs").value, "validate"); + assert.deepEqual( + children(document, child(document, publish, "permissions")).map( + ({ key, value }) => [key, value], + ), + [ + ["contents", "read"], + ["packages", "write"], + ], + ); + + const login = requiredStep(document, publish, "Log in to GHCR"); + assert.equal(stepField(document, login, "uses").value, "docker/login-action@v3"); + assert.equal( + child(document, stepField(document, login, "with"), "password").value, + "${{ secrets.GITHUB_TOKEN }}", + ); + + for (const [name, dockerfile, tag] of [ + [ + "Build and publish API image", + "deploy/railway-api.Dockerfile", + "ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }}", + ], + [ + "Build and publish web image", + "deploy/railway-web.Dockerfile", + "ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }}", + ], + ]) { + const build = requiredStep(document, publish, name); + assert.equal( + stepField(document, build, "uses").value, + "docker/build-push-action@v6", + ); + const options = stepField(document, build, "with"); + assert.equal(child(document, options, "context").value, "."); + assert.equal(child(document, options, "file").value, dockerfile); + assert.equal(child(document, options, "push").value, "true"); + assert.equal(child(document, options, "tags").value, tag); + assert.doesNotMatch(tag, /(?:^|:)latest$/); + } + + const webOptions = stepField( + document, + requiredStep(document, publish, "Build and publish web image"), + "with", + ); + assert.equal( + blockScalar(document, child(document, webOptions, "build-args")), + [ + "NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co", + "NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder", + ].join("\n"), + ); +}); + +test("deployment test script covers health and backend workflow contracts", () => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { scripts: Record }; + assert.equal( + packageJson.scripts["test:deployment"], + "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts", ); - 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); });