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>
157 lines
4.4 KiB
JavaScript
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);
|
|
}
|