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

157 lines
4.4 KiB
JavaScript

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);
}