feat(workflow): add risk-based release gates
This commit is contained in:
+138
-3
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './artifact.mjs';
|
||||
import { loadScenarioCatalog } from './catalog.mjs';
|
||||
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
|
||||
import { selectImpactScenarios } from './impact.mjs';
|
||||
import { loadActiveRegressionCorpus } from './regression-corpus.mjs';
|
||||
import {
|
||||
buildIncrementalReport,
|
||||
@@ -17,7 +18,16 @@ import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs';
|
||||
import { resolveReleaseCiStatus } from './ci-status.mjs';
|
||||
|
||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all', 'incremental']);
|
||||
const MODES = new Set([
|
||||
'deterministic',
|
||||
'scenarios',
|
||||
'browser',
|
||||
'providers',
|
||||
'upgrade',
|
||||
'all',
|
||||
'impact',
|
||||
'incremental',
|
||||
]);
|
||||
|
||||
async function git(...args) {
|
||||
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
|
||||
@@ -233,8 +243,10 @@ async function applyRepositoryChecks(results, artifactPath) {
|
||||
if (!artifactPath) return null;
|
||||
const artifact = await hashArtifact(artifactPath);
|
||||
const rel03 = byId.get('REL-03');
|
||||
rel03.reason = 'repeat_build_comparison_not_implemented';
|
||||
rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`);
|
||||
if (rel03) {
|
||||
rel03.reason = 'repeat_build_comparison_not_implemented';
|
||||
rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`);
|
||||
}
|
||||
|
||||
const inspection = await inspectPortalRuntime(artifactPath);
|
||||
const rel04 = byId.get('REL-04');
|
||||
@@ -324,6 +336,129 @@ export async function executeReleaseGate(options) {
|
||||
return { report, outputDir };
|
||||
}
|
||||
|
||||
export async function listChangedPathsBetween(baseCommit, candidateCommit = 'HEAD') {
|
||||
if (!/^[0-9a-f]{40}$/i.test(baseCommit ?? '')) {
|
||||
throw new Error('deployed commit must be a full SHA');
|
||||
}
|
||||
const exists = await runCommand(
|
||||
'git',
|
||||
['cat-file', '-e', `${baseCommit}^{commit}`],
|
||||
{ cwd: ROOT, timeoutMs: 30_000 },
|
||||
);
|
||||
if (exists.code !== 0) {
|
||||
throw new Error(`deployed commit is not available locally: ${baseCommit}`);
|
||||
}
|
||||
const diff = await runCommand(
|
||||
'git',
|
||||
['diff', '--name-only', '-z', `${baseCommit}..${candidateCommit}`],
|
||||
{ cwd: ROOT, timeoutMs: 30_000 },
|
||||
);
|
||||
if (diff.code !== 0) {
|
||||
throw new Error(`cannot calculate release impact from ${baseCommit} to ${candidateCommit}`);
|
||||
}
|
||||
return diff.stdout
|
||||
.split('\0')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function executeImpactReleaseGate(options) {
|
||||
const startedAt = new Date();
|
||||
assertSafeGateEnvironment({ targets: [options.portalBase] });
|
||||
assertSafePortalBase(options.portalBase);
|
||||
if (!options.artifact) {
|
||||
throw new Error('impact release gate requires --artifact');
|
||||
}
|
||||
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
|
||||
if (!options.deployedCommit) {
|
||||
return executeReleaseGate({ ...options, mode: 'all' });
|
||||
}
|
||||
|
||||
const catalog = await loadScenarioCatalog({ root: ROOT });
|
||||
validateAutomationSuites(catalog);
|
||||
const commitSha = await git('rev-parse', 'HEAD');
|
||||
const branch = await git('branch', '--show-current');
|
||||
const outputDir = path.join(options.reportRoot, commitSha);
|
||||
const changedPaths = await listChangedPathsBetween(options.deployedCommit, commitSha);
|
||||
const ancestry = await runCommand(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', options.deployedCommit, commitSha],
|
||||
{ cwd: ROOT, timeoutMs: 30_000 },
|
||||
);
|
||||
const selection = {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths,
|
||||
forceFullReasons: ancestry.code === 0
|
||||
? []
|
||||
: [`deployed_commit_not_ancestor:${options.deployedCommit}`],
|
||||
}),
|
||||
base_commit: options.deployedCommit,
|
||||
};
|
||||
const selectedIds = new Set(selection.selected_ids);
|
||||
const results = catalog
|
||||
.filter((scenario) => selectedIds.has(scenario.id))
|
||||
.map(scenarioResult);
|
||||
const byId = new Map(results.map((result) => [result.id, result]));
|
||||
const artifact = await applyRepositoryChecks(results, options.artifact);
|
||||
|
||||
if (selectedIds.has('COMP-09')) {
|
||||
const regressionCorpus = await loadActiveRegressionCorpus({ root: ROOT, catalog });
|
||||
const comp09 = byId.get('COMP-09');
|
||||
comp09.reason = regressionCorpus.status === 'ready'
|
||||
? 'production_regression_replay_not_implemented'
|
||||
: `production_regression_corpus_${regressionCorpus.status}`;
|
||||
comp09.evidence.push(
|
||||
`manifest=${path.relative(ROOT, regressionCorpus.manifestPath)}`,
|
||||
`fixtures=${regressionCorpus.fixtures.length}`,
|
||||
...regressionCorpus.errors.map((error) => `error=${error}`),
|
||||
);
|
||||
}
|
||||
|
||||
const suites = AUTOMATION_SUITES.filter(
|
||||
(suite) => suite.scenarios.some((scenarioId) => selectedIds.has(scenarioId)),
|
||||
);
|
||||
const executions = await runSuitesWithConcurrency(
|
||||
suites,
|
||||
options.suiteConcurrency,
|
||||
(suite) => runSuite(suite, outputDir, options.timeoutMs),
|
||||
);
|
||||
for (let index = 0; index < suites.length; index += 1) {
|
||||
const suite = suites[index];
|
||||
const execution = executions[index];
|
||||
for (const scenarioId of suite.scenarios) {
|
||||
if (!selectedIds.has(scenarioId)) continue;
|
||||
const scenario = byId.get(scenarioId);
|
||||
scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed';
|
||||
scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed';
|
||||
scenario.evidence.push(
|
||||
`suite=${suite.id}`,
|
||||
`log=${execution.logPath}`,
|
||||
...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
const report = createGateReport({
|
||||
commitSha,
|
||||
branch,
|
||||
artifactSha256: artifact.sha256,
|
||||
artifact: {
|
||||
path: path.relative(ROOT, options.artifact),
|
||||
kind: artifact.kind,
|
||||
files: artifact.files,
|
||||
bytes: artifact.bytes,
|
||||
},
|
||||
scenarios: results,
|
||||
mode: 'impact',
|
||||
selection,
|
||||
startedAt,
|
||||
completedAt,
|
||||
});
|
||||
await writeGateReport(report, outputDir);
|
||||
return { report, outputDir, selection };
|
||||
}
|
||||
|
||||
export async function executeIncrementalReleaseGate(options) {
|
||||
const startedAt = new Date();
|
||||
assertSafeGateEnvironment({ targets: [options.portalBase] });
|
||||
|
||||
Reference in New Issue
Block a user