fix: harden release gate and page delivery
Memind CI / Test, build, and release guards (push) Failing after 12m3s
Memind CI / Test, build, and release guards (push) Failing after 12m3s
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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('<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`),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user