fix: preserve backend quality gate failures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, string> };
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user