feat(workflow): add risk-based release gates
This commit is contained in:
+93
-5
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expectedScenarioIds, isScenarioExemptable } from './catalog.mjs';
|
||||
import { CORE_SCENARIO_IDS } from './impact.mjs';
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
'passed',
|
||||
@@ -67,9 +68,10 @@ export function createGateReport({
|
||||
completedAt = new Date(),
|
||||
maxAgeMs = 4 * 60 * 60 * 1000,
|
||||
environment = createEnvironmentFingerprint(),
|
||||
selection = null,
|
||||
}) {
|
||||
const summary = summarizeScenarios(scenarios);
|
||||
return {
|
||||
const report = {
|
||||
schema_version: 1,
|
||||
mode,
|
||||
commit_sha: commitSha,
|
||||
@@ -88,6 +90,8 @@ export function createGateReport({
|
||||
.map((scenario) => scenario.exemption),
|
||||
approved_for_release: false,
|
||||
};
|
||||
if (selection) report.selection = selection;
|
||||
return report;
|
||||
}
|
||||
|
||||
function validateExemption(scenario, commitSha, errors) {
|
||||
@@ -118,6 +122,76 @@ function validateExemption(scenario, commitSha, errors) {
|
||||
}
|
||||
}
|
||||
|
||||
function sameValues(left, right) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
if (left.length !== right.length) return false;
|
||||
const leftSorted = [...left].sort();
|
||||
const rightSorted = [...right].sort();
|
||||
return leftSorted.every((value, index) => value === rightSorted[index]);
|
||||
}
|
||||
|
||||
function validateImpactSelection(report, scenarioIds, errors) {
|
||||
const selection = report?.selection;
|
||||
if (!selection || typeof selection !== 'object') {
|
||||
errors.push('impact report is missing selection metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.policy_version !== 1) errors.push('impact policy_version must be 1');
|
||||
if (!['core', 'impact', 'full'].includes(selection.strategy)) {
|
||||
errors.push('impact strategy must be core, impact, or full');
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) {
|
||||
errors.push('impact base_commit must be a full SHA');
|
||||
}
|
||||
if (selection.catalog_total !== expectedScenarioIds().length) {
|
||||
errors.push(`impact catalog_total must be ${expectedScenarioIds().length}`);
|
||||
}
|
||||
for (const field of [
|
||||
'core_ids',
|
||||
'impact_groups',
|
||||
'changed_paths',
|
||||
'unmapped_paths',
|
||||
'full_gate_reasons',
|
||||
'selected_ids',
|
||||
]) {
|
||||
if (!Array.isArray(selection[field])) errors.push(`impact ${field} must be an array`);
|
||||
}
|
||||
if (!sameValues(selection.core_ids, CORE_SCENARIO_IDS)) {
|
||||
errors.push('impact core_ids do not match the release policy');
|
||||
}
|
||||
if (!sameValues(selection.selected_ids, scenarioIds)) {
|
||||
errors.push('impact selected_ids do not match report scenarios');
|
||||
}
|
||||
if (selection.selected_total !== scenarioIds.length) {
|
||||
errors.push('impact selected_total does not match report scenarios');
|
||||
}
|
||||
const missingCore = CORE_SCENARIO_IDS.filter((id) => !scenarioIds.includes(id));
|
||||
if (missingCore.length > 0) {
|
||||
errors.push(`impact report is missing core scenarios: ${missingCore.join(',')}`);
|
||||
}
|
||||
|
||||
if (selection.strategy === 'core' && selection.impact_groups?.length > 0) {
|
||||
errors.push('core impact report cannot contain impact groups');
|
||||
}
|
||||
if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) {
|
||||
errors.push('impact report must contain at least one impact group');
|
||||
}
|
||||
if (selection.strategy !== 'full' && selection.unmapped_paths?.length > 0) {
|
||||
errors.push('unmapped runtime paths require a full Gate');
|
||||
}
|
||||
if (selection.strategy === 'full') {
|
||||
if (!sameValues(scenarioIds, expectedScenarioIds())) {
|
||||
errors.push('full impact strategy must execute the complete catalog');
|
||||
}
|
||||
if (selection.full_gate_reasons?.length === 0) {
|
||||
errors.push('full impact strategy is missing its reason');
|
||||
}
|
||||
} else if (selection.full_gate_reasons?.length > 0) {
|
||||
errors.push('non-full impact strategy cannot contain full Gate reasons');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateGateReport(report, {
|
||||
expectedCommit,
|
||||
expectedArtifactSha256,
|
||||
@@ -128,8 +202,8 @@ export function validateGateReport(report, {
|
||||
} = {}) {
|
||||
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 (!['all', 'incremental', 'impact'].includes(report?.mode) && requireFullCatalog) {
|
||||
errors.push('release report mode must be all, incremental, or impact');
|
||||
}
|
||||
if (report?.mode === 'incremental') {
|
||||
const baseline = report?.baseline;
|
||||
@@ -174,7 +248,8 @@ export function validateGateReport(report, {
|
||||
} else {
|
||||
const ids = report.scenarios.map((scenario) => scenario.id);
|
||||
const unique = new Set(ids);
|
||||
if (requireFullCatalog) {
|
||||
const fullCatalogRequired = requireFullCatalog && report.mode !== 'impact';
|
||||
if (fullCatalogRequired) {
|
||||
const expected = expectedScenarioIds();
|
||||
const expectedSet = new Set(expected);
|
||||
if (ids.length !== expected.length || unique.size !== expected.length) {
|
||||
@@ -185,6 +260,7 @@ export function validateGateReport(report, {
|
||||
if (missing.length) errors.push(`report is missing scenarios: ${missing.join(',')}`);
|
||||
if (unexpected.length) errors.push(`report has unexpected scenarios: ${unexpected.join(',')}`);
|
||||
}
|
||||
if (report.mode === 'impact') validateImpactSelection(report, ids, errors);
|
||||
|
||||
for (const scenario of report.scenarios) {
|
||||
if (!TERMINAL_STATUSES.has(scenario.status)) {
|
||||
@@ -192,7 +268,11 @@ export function validateGateReport(report, {
|
||||
continue;
|
||||
}
|
||||
if (scenario.status === 'not_applicable') {
|
||||
validateExemption(scenario, report.commit_sha, errors);
|
||||
if (report.mode === 'impact') {
|
||||
errors.push(`${scenario.id} must execute when selected by the impact Gate`);
|
||||
} else {
|
||||
validateExemption(scenario, report.commit_sha, errors);
|
||||
}
|
||||
} else if (scenario.status !== 'passed') {
|
||||
errors.push(`${scenario.id} is ${scenario.status}`);
|
||||
}
|
||||
@@ -232,6 +312,14 @@ function renderMarkdown(report) {
|
||||
`- Artifact SHA256: \`${report.artifact_sha256}\``,
|
||||
`- Completed: ${report.completed_at}`,
|
||||
`- Expires: ${report.expires_at}`,
|
||||
...(report.selection
|
||||
? [
|
||||
`- Strategy: ${report.selection.strategy}`,
|
||||
`- Base commit: \`${report.selection.base_commit}\``,
|
||||
`- Selected: ${report.selection.selected_total}/${report.selection.catalog_total}`,
|
||||
`- Impact groups: ${report.selection.impact_groups.join(', ') || 'none'}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'| Result | Count |',
|
||||
'|---|---:|',
|
||||
|
||||
Reference in New Issue
Block a user