Files

382 lines
14 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { expectedScenarioIds, isScenarioExemptable } from './catalog.mjs';
import { CORE_SCENARIO_IDS } from './impact.mjs';
const TERMINAL_STATUSES = new Set([
'passed',
'failed',
'not_applicable',
'skipped',
'blocked',
'unknown',
]);
function iso(value) {
return new Date(value).toISOString();
}
export function createEnvironmentFingerprint(extra = {}) {
const environment = {
node: process.version,
platform: process.platform,
arch: process.arch,
release: os.release(),
...extra,
};
const fingerprint = crypto
.createHash('sha256')
.update(JSON.stringify(environment))
.digest('hex');
return { environment, fingerprint };
}
export function summarizeScenarios(scenarios) {
const summary = {
required: scenarios.length,
passed: 0,
not_applicable: 0,
failed: 0,
skipped: 0,
blocked: 0,
unknown: 0,
cleanup_failed: 0,
};
for (const scenario of scenarios) {
if (!TERMINAL_STATUSES.has(scenario.status)) {
throw new Error(`Invalid scenario status for ${scenario.id}: ${scenario.status}`);
}
summary[scenario.status] += 1;
if (scenario.cleanup_status === 'failed') {
summary.cleanup_failed += 1;
}
}
return summary;
}
export function createGateReport({
commitSha,
branch,
artifactSha256,
artifact,
scenarios,
mode = 'all',
startedAt = new Date(),
completedAt = new Date(),
maxAgeMs = 4 * 60 * 60 * 1000,
environment = createEnvironmentFingerprint(),
selection = null,
}) {
const summary = summarizeScenarios(scenarios);
const report = {
schema_version: 1,
mode,
commit_sha: commitSha,
branch,
artifact_sha256: artifactSha256,
artifact,
started_at: iso(startedAt),
completed_at: iso(completedAt),
expires_at: iso(new Date(new Date(completedAt).getTime() + maxAgeMs)),
environment: environment.environment,
environment_fingerprint: environment.fingerprint,
summary,
scenarios,
exemptions: scenarios
.filter((scenario) => scenario.status === 'not_applicable')
.map((scenario) => scenario.exemption),
approved_for_release: false,
};
if (selection) report.selection = selection;
return report;
}
function validateExemption(scenario, commitSha, errors) {
if (!isScenarioExemptable(scenario.id)) {
errors.push(`${scenario.id} is never exemptable`);
return;
}
const exemption = scenario.exemption;
if (!exemption || exemption.scenario_id !== scenario.id) {
errors.push(`${scenario.id} exemption is missing or mismatched`);
return;
}
if (exemption.commit_sha !== commitSha) {
errors.push(`${scenario.id} exemption commit does not match report`);
}
for (const field of ['reason', 'reviewed_by', 'reviewed_at']) {
if (!String(exemption[field] ?? '').trim()) {
errors.push(`${scenario.id} exemption is missing ${field}`);
}
}
for (const field of ['changed_paths_checked', 'dependency_evidence']) {
if (!Array.isArray(exemption[field]) || exemption[field].length === 0) {
errors.push(`${scenario.id} exemption is missing ${field}`);
}
}
if (exemption.reviewed_at && Number.isNaN(Date.parse(exemption.reviewed_at))) {
errors.push(`${scenario.id} exemption reviewed_at is invalid`);
}
}
function sameValues(left, right) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
const leftSorted = [...left].sort();
const rightSorted = [...right].sort();
return leftSorted.every((value, index) => value === rightSorted[index]);
}
function validateImpactSelection(report, scenarioIds, errors) {
const selection = report?.selection;
if (!selection || typeof selection !== 'object') {
errors.push('impact report is missing selection metadata');
return;
}
if (selection.policy_version !== 1) errors.push('impact policy_version must be 1');
if (!['core', 'impact', 'full'].includes(selection.strategy)) {
errors.push('impact strategy must be core, impact, or full');
}
if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) {
errors.push('impact base_commit must be a full SHA');
}
if (selection.catalog_total !== expectedScenarioIds().length) {
errors.push(`impact catalog_total must be ${expectedScenarioIds().length}`);
}
for (const field of [
'core_ids',
'impact_groups',
'changed_paths',
'unmapped_paths',
'full_gate_reasons',
'selected_ids',
]) {
if (!Array.isArray(selection[field])) errors.push(`impact ${field} must be an array`);
}
if (!sameValues(selection.core_ids, CORE_SCENARIO_IDS)) {
errors.push('impact core_ids do not match the release policy');
}
if (!sameValues(selection.selected_ids, scenarioIds)) {
errors.push('impact selected_ids do not match report scenarios');
}
if (selection.selected_total !== scenarioIds.length) {
errors.push('impact selected_total does not match report scenarios');
}
const missingCore = CORE_SCENARIO_IDS.filter((id) => !scenarioIds.includes(id));
if (missingCore.length > 0) {
errors.push(`impact report is missing core scenarios: ${missingCore.join(',')}`);
}
if (selection.strategy === 'core' && selection.impact_groups?.length > 0) {
errors.push('core impact report cannot contain impact groups');
}
if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) {
errors.push('impact report must contain at least one impact group');
}
if (selection.strategy !== 'full' && selection.unmapped_paths?.length > 0) {
errors.push('unmapped runtime paths require a full Gate');
}
if (selection.strategy === 'full') {
if (!sameValues(scenarioIds, expectedScenarioIds())) {
errors.push('full impact strategy must execute the complete catalog');
}
if (selection.full_gate_reasons?.length === 0) {
errors.push('full impact strategy is missing its reason');
}
} else if (selection.full_gate_reasons?.length > 0) {
errors.push('non-full impact strategy cannot contain full Gate reasons');
}
}
export function validateGateReport(report, {
expectedCommit,
expectedArtifactSha256,
expectedBranch = 'main',
now = new Date(),
requireFullCatalog = true,
allowExpired = false,
} = {}) {
const errors = [];
if (report?.schema_version !== 1) errors.push('schema_version must be 1');
if (!['all', 'incremental', 'impact'].includes(report?.mode) && requireFullCatalog) {
errors.push('release report mode must be all, incremental, or impact');
}
if (report?.mode === 'incremental') {
const baseline = report?.baseline;
if (!baseline || typeof baseline !== 'object') {
errors.push('incremental report is missing baseline metadata');
} else {
for (const field of ['commit_sha', 'report_path', 'carried_forward', 'reexecuted']) {
if (baseline[field] === undefined || baseline[field] === null || baseline[field] === '') {
errors.push(`incremental baseline is missing ${field}`);
}
}
if (Number(baseline.carried_forward) + Number(baseline.reexecuted) !== report?.scenarios?.length) {
errors.push('incremental baseline counts do not match scenario total');
}
}
}
if (!/^[0-9a-f]{40}$/i.test(report?.commit_sha ?? '')) errors.push('commit_sha must be a full SHA');
if (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate');
if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`);
if (!/^[0-9a-f]{64}$/i.test(report?.artifact_sha256 ?? '')) {
errors.push('artifact_sha256 must be a SHA256');
}
if (expectedArtifactSha256 && report?.artifact_sha256 !== expectedArtifactSha256) {
errors.push('artifact_sha256 does not match candidate artifact');
}
if (!/^[0-9a-f]{64}$/i.test(report?.environment_fingerprint ?? '')) {
errors.push('environment_fingerprint must be a SHA256');
}
const completedAt = Date.parse(report?.completed_at ?? '');
const expiresAt = Date.parse(report?.expires_at ?? '');
if (!Number.isFinite(completedAt) || !Number.isFinite(expiresAt)) {
errors.push('report timestamps are invalid');
} else {
if (expiresAt <= completedAt) errors.push('report expiry must be after completion');
if (expiresAt - completedAt > 4 * 60 * 60 * 1000) errors.push('report validity exceeds four hours');
if (!allowExpired && new Date(now).getTime() > expiresAt) errors.push('report has expired');
}
if (!Array.isArray(report?.scenarios)) {
errors.push('scenarios must be an array');
} else {
const ids = report.scenarios.map((scenario) => scenario.id);
const unique = new Set(ids);
const fullCatalogRequired = requireFullCatalog && report.mode !== 'impact';
if (fullCatalogRequired) {
const expected = expectedScenarioIds();
const expectedSet = new Set(expected);
if (ids.length !== expected.length || unique.size !== expected.length) {
errors.push(`report must contain ${expected.length} unique scenarios`);
}
const missing = expected.filter((id) => !unique.has(id));
const unexpected = ids.filter((id) => !expectedSet.has(id));
if (missing.length) errors.push(`report is missing scenarios: ${missing.join(',')}`);
if (unexpected.length) errors.push(`report has unexpected scenarios: ${unexpected.join(',')}`);
}
if (report.mode === 'impact') validateImpactSelection(report, ids, errors);
for (const scenario of report.scenarios) {
if (!TERMINAL_STATUSES.has(scenario.status)) {
errors.push(`${scenario.id} has invalid status ${scenario.status}`);
continue;
}
if (scenario.status === 'not_applicable') {
if (report.mode === 'impact') {
errors.push(`${scenario.id} must execute when selected by the impact Gate`);
} else {
validateExemption(scenario, report.commit_sha, errors);
}
} else if (scenario.status !== 'passed') {
errors.push(`${scenario.id} is ${scenario.status}`);
}
if (scenario.cleanup_status === 'failed') {
errors.push(`${scenario.id} cleanup failed`);
}
}
try {
const actualSummary = summarizeScenarios(report.scenarios);
if (JSON.stringify(actualSummary) !== JSON.stringify(report.summary)) {
errors.push('summary does not match scenario statuses');
}
if (actualSummary.failed || actualSummary.skipped || actualSummary.blocked
|| actualSummary.unknown || actualSummary.cleanup_failed) {
errors.push('report contains blocking results');
}
if (actualSummary.passed + actualSummary.not_applicable !== actualSummary.required) {
errors.push('passed + not_applicable must equal required');
}
} catch (error) {
errors.push(error.message);
}
}
if (!Array.isArray(report?.exemptions)) errors.push('exemptions must be an array');
return { valid: errors.length === 0, errors };
}
function renderMarkdown(report) {
const lines = [
'# Production Release Gate Report',
'',
`- Mode: ${report.mode}`,
`- Commit: \`${report.commit_sha}\``,
`- Branch: \`${report.branch}\``,
`- Artifact SHA256: \`${report.artifact_sha256}\``,
`- Completed: ${report.completed_at}`,
`- Expires: ${report.expires_at}`,
...(report.selection
? [
`- Strategy: ${report.selection.strategy}`,
`- Base commit: \`${report.selection.base_commit}\``,
`- Selected: ${report.selection.selected_total}/${report.selection.catalog_total}`,
`- Impact groups: ${report.selection.impact_groups.join(', ') || 'none'}`,
]
: []),
'',
'| Result | Count |',
'|---|---:|',
...Object.entries(report.summary).map(([key, value]) => `| ${key} | ${value} |`),
'',
'| Scenario | Status | Evidence |',
'|---|---|---|',
...report.scenarios.map(
(scenario) => `| ${scenario.id} | ${scenario.status} | ${scenario.evidence?.join('<br>') ?? ''} |`,
),
'',
];
return lines.join('\n');
}
function renderJunit(report) {
const failures = report.scenarios.filter(
(scenario) => !['passed', 'not_applicable'].includes(scenario.status),
).length;
const cases = report.scenarios.map((scenario) => {
const name = `${scenario.id} ${scenario.name ?? ''}`.replaceAll('"', '&quot;');
if (scenario.status === 'passed') {
return ` <testcase classname="release-gate" name="${name}" />`;
}
if (scenario.status === 'not_applicable') {
return ` <testcase classname="release-gate" name="${name}"><skipped message="not_applicable" /></testcase>`;
}
return ` <testcase classname="release-gate" name="${name}"><failure message="${scenario.status}" /></testcase>`;
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuite name="production-release-gate" tests="${report.scenarios.length}" failures="${failures}">`,
...cases,
'</testsuite>',
'',
].join('\n');
}
export async function writeGateReport(report, outputDir) {
await fs.mkdir(outputDir, { recursive: true });
await Promise.all([
fs.mkdir(path.join(outputDir, 'logs'), { recursive: true }),
fs.mkdir(path.join(outputDir, 'scenarios'), { recursive: true }),
fs.mkdir(path.join(outputDir, 'screenshots'), { recursive: true }),
]);
await Promise.all([
fs.writeFile(path.join(outputDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`),
fs.writeFile(path.join(outputDir, 'report.md'), renderMarkdown(report)),
fs.writeFile(path.join(outputDir, 'junit.xml'), renderJunit(report)),
fs.writeFile(
path.join(outputDir, 'environment.json'),
`${JSON.stringify({
fingerprint: report.environment_fingerprint,
...report.environment,
}, null, 2)}\n`,
),
fs.writeFile(path.join(outputDir, 'artifact.sha256'), `${report.artifact_sha256}\n`),
]);
}