a6a9bb4eab
When the runtime artifact is unchanged, carry forward prior gate results and re-run only REL scenarios; resolve REL-02 from Gitea commit status and allow stable promotion when canary matches the same artifact bundle. Co-authored-by: Cursor <cursoragent@cursor.com>
294 lines
10 KiB
JavaScript
294 lines
10 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';
|
|
|
|
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,
|
|
allowExpired = false,
|
|
} = {}) {
|
|
const errors = [];
|
|
if (report?.schema_version !== 1) errors.push('schema_version must be 1');
|
|
if (report?.mode !== 'all' && report?.mode !== 'incremental' && requireFullCatalog) {
|
|
errors.push('release report mode must be all or incremental');
|
|
}
|
|
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);
|
|
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('<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('"', '"');
|
|
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`),
|
|
]);
|
|
}
|