feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
evaluatePromotionReadiness,
|
||||
PROMOTION_FORMULA,
|
||||
type GateResult,
|
||||
} from "../src/application/policies/promotion-readiness.ts";
|
||||
|
||||
type GateDefinition = Readonly<{
|
||||
steps?: readonly unknown[];
|
||||
evidence?: readonly string[];
|
||||
retentionClass?: string;
|
||||
}>;
|
||||
type CiContractDocument = Readonly<{
|
||||
providerAdapter: string;
|
||||
stages: Readonly<Record<string, Readonly<{ gates?: readonly string[] }>>>;
|
||||
gates: Readonly<Record<string, GateDefinition>>;
|
||||
retention: Readonly<{ durationStatus: unknown }>;
|
||||
}>;
|
||||
|
||||
const document = parseCiContractDocument(
|
||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
||||
);
|
||||
const workflow = await readFile(document.providerAdapter, "utf8");
|
||||
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
||||
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
||||
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
||||
const buildManifestGenerator = await readFile(
|
||||
"scripts/generate-build-manifest.ts",
|
||||
"utf8",
|
||||
);
|
||||
const failures: string[] = [];
|
||||
if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) {
|
||||
failures.push(".nvmrc must contain one exact Node.js semantic version");
|
||||
}
|
||||
const setupNodeCount =
|
||||
workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0;
|
||||
const nodeVersionFileCount =
|
||||
workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0;
|
||||
if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) {
|
||||
failures.push("every setup-node step must use node-version-file: .nvmrc");
|
||||
}
|
||||
if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) {
|
||||
failures.push("workflow must not override the exact .nvmrc Node.js pin");
|
||||
}
|
||||
const stageFormula: Readonly<Record<string, readonly string[]>> = {
|
||||
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 runbookGateEvidence = Object.freeze({
|
||||
"FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json",
|
||||
"FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json",
|
||||
"FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json",
|
||||
"FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json",
|
||||
"FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json",
|
||||
"FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json",
|
||||
});
|
||||
for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) {
|
||||
const evidence = document.gates[gateId]?.evidence;
|
||||
if (
|
||||
!Array.isArray(evidence) ||
|
||||
evidence.length !== 1 ||
|
||||
evidence[0] !== evidencePath
|
||||
) {
|
||||
failures.push(`${gateId} runbook evidence path drift`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!drillRunner.includes(
|
||||
"const artifactDirectory = `artifacts/runbooks/${runbookId}`",
|
||||
) ||
|
||||
drillRunner.includes(
|
||||
"artifacts/runbooks/${runbookId}/${release.releaseId}",
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
"runbook evidence path must be stable while releaseId stays in the record",
|
||||
);
|
||||
}
|
||||
|
||||
const forbiddenWorkflowPatterns = [
|
||||
/continue-on-error\s*:/,
|
||||
/retention-days\s*:/,
|
||||
/timeout-minutes\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()",
|
||||
"permissions:",
|
||||
"contents: read",
|
||||
'CI: "true"',
|
||||
'VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
||||
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
||||
]) {
|
||||
if (!workflow.includes(requiredToken)) {
|
||||
failures.push(`workflow missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"ciCheckoutIdentityFailures",
|
||||
"ciBuildEnvironmentFailures",
|
||||
"SOURCE_DATE_EPOCH",
|
||||
'"--format=%H%n%ct"',
|
||||
"env: gateEnvironment",
|
||||
]) {
|
||||
if (!gateRunner.includes(requiredToken)) {
|
||||
failures.push(`CI gate runner missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"assertCiBuildEnvironment(process.env)",
|
||||
"releaseId",
|
||||
"sourceDateEpoch",
|
||||
]) {
|
||||
if (!buildManifestGenerator.includes(requiredToken)) {
|
||||
failures.push(`build manifest generator missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
const passingResults: Record<string, GateResult> = {};
|
||||
for (const gateId of expectedGateIds) passingResults[gateId] = "PASS";
|
||||
const allPass = evaluatePromotionReadiness(passingResults);
|
||||
const negativeFixtures: Array<{
|
||||
readiness: keyof typeof PROMOTION_FORMULA;
|
||||
failedGate: string;
|
||||
passed: boolean;
|
||||
}> = [];
|
||||
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<
|
||||
keyof typeof PROMOTION_FORMULA
|
||||
>) {
|
||||
const gateIds = PROMOTION_FORMULA[readiness];
|
||||
const failedGate = gateIds[0];
|
||||
if (!failedGate) throw new Error(`${readiness} has no configured gates`);
|
||||
const result = evaluatePromotionReadiness({
|
||||
...passingResults,
|
||||
[failedGate]: "FAIL",
|
||||
});
|
||||
const passed = 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,
|
||||
nodeVersion,
|
||||
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");
|
||||
|
||||
function parseCiContractDocument(value: unknown): CiContractDocument {
|
||||
if (!isRecord(value)) throw new TypeError("CI gate config must be an object");
|
||||
if (
|
||||
typeof value.providerAdapter !== "string" ||
|
||||
!isRecord(value.stages) ||
|
||||
!isRecord(value.gates) ||
|
||||
!isRecord(value.retention)
|
||||
) {
|
||||
throw new TypeError("CI gate config is missing required registries");
|
||||
}
|
||||
const stages: Record<string, { gates?: readonly string[] }> = {};
|
||||
for (const [stage, candidate] of Object.entries(value.stages)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`);
|
||||
if (
|
||||
candidate.gates !== undefined &&
|
||||
(!Array.isArray(candidate.gates) ||
|
||||
!candidate.gates.every((gate) => typeof gate === "string"))
|
||||
) {
|
||||
throw new TypeError(`Invalid gate list for CI stage: ${stage}`);
|
||||
}
|
||||
stages[stage] = {
|
||||
gates: candidate.gates as readonly string[] | undefined,
|
||||
};
|
||||
}
|
||||
const gates: Record<string, GateDefinition> = {};
|
||||
for (const [gateId, candidate] of Object.entries(value.gates)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
|
||||
if (
|
||||
candidate.evidence !== undefined &&
|
||||
(!Array.isArray(candidate.evidence) ||
|
||||
!candidate.evidence.every((path) => typeof path === "string"))
|
||||
) {
|
||||
throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`);
|
||||
}
|
||||
gates[gateId] = {
|
||||
steps: Array.isArray(candidate.steps) ? candidate.steps : undefined,
|
||||
evidence: candidate.evidence as readonly string[] | undefined,
|
||||
retentionClass:
|
||||
typeof candidate.retentionClass === "string"
|
||||
? candidate.retentionClass
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
providerAdapter: value.providerAdapter,
|
||||
stages,
|
||||
gates,
|
||||
retention: { durationStatus: value.retention.durationStatus },
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
Reference in New Issue
Block a user