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'; 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(), }) { const summary = summarizeScenarios(scenarios); return { 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, }; } 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`); } } export function validateGateReport(report, { expectedCommit, expectedArtifactSha256, expectedBranch = 'main', now = new Date(), requireFullCatalog = true, } = {}) { const errors = []; if (report?.schema_version !== 1) errors.push('schema_version must be 1'); if (report?.mode !== 'all' && requireFullCatalog) errors.push('release report mode must be all'); 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 (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); if (requireFullCatalog) { 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(',')}`); } 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') { 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}`, '', '| 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('
') ?? ''} |`, ), '', ]; 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('"', '"'); if (scenario.status === 'passed') { return ` `; } if (scenario.status === 'not_applicable') { return ` `; } return ` `; }); return [ '', ``, ...cases, '', '', ].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`), ]); }