import fs from 'node:fs/promises'; import path from 'node:path'; import { hashArtifact } from './artifact.mjs'; import { validateGateReport } from './report.mjs'; const RELEASE_ONLY_PATH_PREFIXES = [ 'release-gate/', 'scripts/release-', 'scripts/verify-canary-', 'scripts/verify-release-gate', 'scripts/check-release', 'scripts/run-release-gate', 'scripts/resolve-release-ci-status.mjs', 'docs/production-release-guardian.md', 'docs/release-gate-automation.md', ]; export function isReleaseOnlyPath(relativePath) { return RELEASE_ONLY_PATH_PREFIXES.some((prefix) => relativePath.startsWith(prefix)); } export function isReleaseScenario(scenarioId) { return scenarioId.startsWith('REL-'); } export function diffTouchesRuntimePaths(changedPaths) { return changedPaths.some((relativePath) => !isReleaseOnlyPath(relativePath)); } export async function loadGateReport(reportPath) { const raw = await fs.readFile(reportPath, 'utf8'); return JSON.parse(raw); } export async function findCarryForwardBaseline({ reportRoot, artifactSha256, preferredCommitSha = null, deployedCommitSha = null, now = new Date(), }) { let entries = []; try { entries = await fs.readdir(reportRoot, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } const candidates = []; for (const entry of entries) { if (!entry.isDirectory() || entry.name === 'local') continue; const reportPath = path.join(reportRoot, entry.name, 'report.json'); try { const report = await loadGateReport(reportPath); if (report.artifact_sha256 !== artifactSha256) continue; const validation = validateGateReport(report, { expectedArtifactSha256: artifactSha256, now, requireFullCatalog: true, allowExpired: true, }); if (!validation.valid) continue; candidates.push({ report, reportPath, commitSha: report.commit_sha }); } catch { // ignore unreadable or invalid reports } } if (candidates.length === 0) return null; const rank = (candidate) => { let score = 0; if (deployedCommitSha && candidate.commitSha === deployedCommitSha) score += 100; if (preferredCommitSha && candidate.commitSha === preferredCommitSha) score += 50; score += Date.parse(candidate.report.completed_at ?? '') / 1_000_000_000_000; return score; }; candidates.sort((left, right) => rank(right) - rank(left)); return candidates[0]; } export function mergeIncrementalScenarios({ baselineScenarios, reexecutedScenarios, }) { const reexecutedById = new Map(reexecutedScenarios.map((scenario) => [scenario.id, scenario])); return baselineScenarios.map((baselineScenario) => { const reexecuted = reexecutedById.get(baselineScenario.id); if (!reexecuted) { return { ...baselineScenario, evidence: [ ...(baselineScenario.evidence ?? []), 'carried_forward=true', ], }; } return { ...reexecuted, evidence: [ ...(reexecuted.evidence ?? []), 'carried_forward=false', ], }; }); } export function buildIncrementalReport({ baselineReport, baselineReportPath, commitSha, branch, artifactSha256, artifact, scenarios, startedAt, completedAt, }) { const carriedForward = scenarios.filter( (scenario) => (scenario.evidence ?? []).includes('carried_forward=true'), ).length; const reexecuted = scenarios.length - carriedForward; return { schema_version: 1, mode: 'incremental', commit_sha: commitSha, branch, artifact_sha256: artifactSha256, artifact, started_at: startedAt.toISOString(), completed_at: completedAt.toISOString(), expires_at: new Date(completedAt.getTime() + 4 * 60 * 60 * 1000).toISOString(), environment: baselineReport.environment, environment_fingerprint: baselineReport.environment_fingerprint, baseline: { commit_sha: baselineReport.commit_sha, report_path: baselineReportPath, carried_forward: carriedForward, reexecuted, }, summary: null, scenarios, exemptions: scenarios .filter((scenario) => scenario.status === 'not_applicable') .map((scenario) => scenario.exemption) .filter(Boolean), approved_for_release: false, }; } export async function hashArtifactFromOptions(artifactPath) { return hashArtifact(artifactPath); }