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>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user