Files
john a6a9bb4eab feat(release): add incremental gate and auto CI status for 103 publish
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>
2026-07-27 08:32:12 +08:00

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('"', '&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`),
]);
}