112 lines
3.5 KiB
JavaScript
112 lines
3.5 KiB
JavaScript
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<Record<string, boolean>>} */ (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");
|