feat(workflow): add risk-based release gates
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
export const CORE_SCENARIO_IDS = Object.freeze([
|
||||
'REL-01',
|
||||
'REL-02',
|
||||
'REL-04',
|
||||
'REL-05',
|
||||
'REL-06',
|
||||
'REL-09',
|
||||
'REL-11',
|
||||
'AUTH-01',
|
||||
'AUTH-05',
|
||||
'CHAT-01',
|
||||
'CHAT-06',
|
||||
'CHAT-07',
|
||||
'AGENT-01',
|
||||
'AGENT-03',
|
||||
'MS-01',
|
||||
'COMP-09',
|
||||
]);
|
||||
|
||||
const FULL_GATE_PATHS = Object.freeze([
|
||||
/^(?:package|npm-shrinkwrap).*\.json$/i,
|
||||
/^(?:pnpm-lock\.yaml|yarn\.lock)$/i,
|
||||
/^(?:server\.mjs|schema\.sql)$/i,
|
||||
/^(?:migrations?|database)\//i,
|
||||
/^release-gate\//i,
|
||||
/^scripts\/(?:build-portal-runtime|release-|run-release-gate|verify-release-gate|verify-canary-)/i,
|
||||
/^scripts\/(?:run-memind-portal-prod|run-memind-portal-candidate|goosed.*compose)/i,
|
||||
/^docs\/(?:production-release-guardian|release-gate-automation|release-canary-103)\.md$/i,
|
||||
/^(?:PRODUCTION_RELEASE_RULES|ENGINEERING_WORKFLOW_RULES)\.md$/i,
|
||||
/^\.github\/workflows\//i,
|
||||
/^server\/portal-(?:access-policy|auth-services-bootstrap|auth-session-helpers|session-coordinator|gateway-services-bootstrap|integration-services-bootstrap)/i,
|
||||
/^src\/(?:api\/core|config)\.[cm]?[jt]sx?$/i,
|
||||
/^(?:tkmind-proxy|session-stream|session-stream-store|deepseek-no-think-proxy)\.mjs$/i,
|
||||
]);
|
||||
|
||||
const NON_RUNTIME_PATHS = Object.freeze([
|
||||
/^(?:AGENTS|README|CHANGELOG)\.md$/i,
|
||||
/^docs\//i,
|
||||
/^\.cursor\//i,
|
||||
/^\.codex\//i,
|
||||
/^scripts\/dev(?:-|\.|\/)/i,
|
||||
/^scripts\/.*(?:test|fixture|mock).*\.[cm]?[jt]s$/i,
|
||||
/\.(?:test|spec)\.[cm]?[jt]sx?$/i,
|
||||
/^(?:eslint|prettier|tsconfig|components)\..*$/i,
|
||||
/^openapi\.json$/i,
|
||||
]);
|
||||
|
||||
const IMPACT_RULES = Object.freeze([
|
||||
{ groups: ['MEM'], pattern: /(?:^|\/)(?:memory|episodic)[^/]*|memory-v2/i },
|
||||
{ groups: ['DATA'], pattern: /(?:page-data|dataset|page-policy)/i },
|
||||
{ groups: ['WX'], pattern: /(?:wechat|weixin|wx-)/i },
|
||||
{ groups: ['BILL'], pattern: /(?:billing|payment|charge|balance|subscription)/i },
|
||||
{ groups: ['PLAZA'], pattern: /(?:^|\/)plaza/i },
|
||||
{ groups: ['SCHED'], pattern: /(?:schedule|scheduler|reminder|cron)/i },
|
||||
{ groups: ['SEARCH'], pattern: /(?:search|weather|market|news-provider)/i },
|
||||
{ groups: ['XLS'], pattern: /(?:excel|xlsx|spreadsheet)/i },
|
||||
{ groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i },
|
||||
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
|
||||
{ groups: ['MS'], pattern: /mindspace/i },
|
||||
{ groups: ['PAGE'], pattern: /(?:public-page|published-page|publication|page-delivery|mindspace-public)/i },
|
||||
{ groups: ['AGENT'], pattern: /(?:agent|goosed|worker|aider|mcp)/i },
|
||||
{ groups: ['CHAT'], pattern: /(?:chat|conversation|message|sse|routing|intent)/i },
|
||||
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
|
||||
{ groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i },
|
||||
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
|
||||
]);
|
||||
|
||||
const GROUP_DEPENDENCIES = Object.freeze({
|
||||
MEM: ['CHAT'],
|
||||
DATA: ['AUTH', 'PAGE', 'MS'],
|
||||
WX: ['AUTH', 'CHAT'],
|
||||
BILL: ['AUTH', 'CHAT'],
|
||||
PLAZA: ['AUTH'],
|
||||
SEARCH: ['CHAT'],
|
||||
XLS: ['FILE'],
|
||||
IMGPG: ['FILE', 'PAGE'],
|
||||
FILE: ['AUTH', 'CHAT'],
|
||||
MS: ['PAGE'],
|
||||
PAGE: ['FILE', 'MS'],
|
||||
AGENT: ['CHAT', 'CFG'],
|
||||
});
|
||||
|
||||
function normalizePaths(paths) {
|
||||
return [...new Set(paths.map((item) => String(item).replaceAll('\\', '/')).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function matchesAny(patterns, relativePath) {
|
||||
return patterns.some((pattern) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
function closeGroupDependencies(initialGroups) {
|
||||
const groups = new Set(initialGroups);
|
||||
const pending = [...groups];
|
||||
while (pending.length > 0) {
|
||||
const group = pending.shift();
|
||||
for (const dependency of GROUP_DEPENDENCIES[group] ?? []) {
|
||||
if (groups.has(dependency)) continue;
|
||||
groups.add(dependency);
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
return [...groups].sort();
|
||||
}
|
||||
|
||||
export function selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths,
|
||||
forceFullReasons = [],
|
||||
}) {
|
||||
const normalizedPaths = normalizePaths(changedPaths);
|
||||
const catalogIds = new Set(catalog.map((scenario) => scenario.id));
|
||||
const missingCore = CORE_SCENARIO_IDS.filter((id) => !catalogIds.has(id));
|
||||
if (missingCore.length > 0) {
|
||||
throw new Error(`Core release scenarios are missing from the catalog: ${missingCore.join(',')}`);
|
||||
}
|
||||
|
||||
const fullGateReasons = [...forceFullReasons];
|
||||
const directGroups = new Set();
|
||||
const unmappedPaths = [];
|
||||
|
||||
for (const relativePath of normalizedPaths) {
|
||||
if (matchesAny(FULL_GATE_PATHS, relativePath)) {
|
||||
fullGateReasons.push(`critical_path:${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue;
|
||||
|
||||
let matched = false;
|
||||
for (const rule of IMPACT_RULES) {
|
||||
if (!rule.pattern.test(relativePath)) continue;
|
||||
matched = true;
|
||||
for (const group of rule.groups) directGroups.add(group);
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
unmappedPaths.push(relativePath);
|
||||
fullGateReasons.push(`unmapped_runtime_path:${relativePath}`);
|
||||
}
|
||||
|
||||
const impactGroups = closeGroupDependencies(directGroups);
|
||||
const strategy = fullGateReasons.length > 0
|
||||
? 'full'
|
||||
: impactGroups.length > 0
|
||||
? 'impact'
|
||||
: 'core';
|
||||
const selected = new Set(CORE_SCENARIO_IDS);
|
||||
|
||||
if (strategy === 'full') {
|
||||
for (const scenario of catalog) selected.add(scenario.id);
|
||||
} else {
|
||||
for (const scenario of catalog) {
|
||||
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedIds = catalog
|
||||
.map((scenario) => scenario.id)
|
||||
.filter((id) => selected.has(id));
|
||||
|
||||
return {
|
||||
policy_version: 1,
|
||||
strategy,
|
||||
catalog_total: catalog.length,
|
||||
core_ids: [...CORE_SCENARIO_IDS],
|
||||
impact_groups: impactGroups,
|
||||
changed_paths: normalizedPaths,
|
||||
unmapped_paths: unmappedPaths,
|
||||
full_gate_reasons: [...new Set(fullGateReasons)].sort(),
|
||||
selected_ids: selectedIds,
|
||||
selected_total: selectedIds.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { loadScenarioCatalog } from './catalog.mjs';
|
||||
import { CORE_SCENARIO_IDS, selectImpactScenarios } from './impact.mjs';
|
||||
|
||||
test('docs-only changes use the compact core gate', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['AGENTS.md', 'docs/local-dev.md'],
|
||||
});
|
||||
assert.equal(selection.strategy, 'core');
|
||||
assert.deepEqual(selection.selected_ids, catalog
|
||||
.map((scenario) => scenario.id)
|
||||
.filter((id) => CORE_SCENARIO_IDS.includes(id)));
|
||||
assert.equal(selection.unmapped_paths.length, 0);
|
||||
});
|
||||
|
||||
test('domain changes select the domain and dependency closure', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['memory-v2-lifecycle.mjs'],
|
||||
});
|
||||
assert.equal(selection.strategy, 'impact');
|
||||
assert.deepEqual(selection.impact_groups, ['CHAT', 'MEM']);
|
||||
assert.equal(selection.selected_ids.includes('MEM-16'), true);
|
||||
assert.equal(selection.selected_ids.includes('CHAT-15'), true);
|
||||
assert.equal(selection.selected_ids.includes('BILL-07'), false);
|
||||
});
|
||||
|
||||
test('critical and unmapped runtime paths fail closed to the full gate', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const critical = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['server.mjs'],
|
||||
});
|
||||
assert.equal(critical.strategy, 'full');
|
||||
assert.equal(critical.selected_total, catalog.length);
|
||||
assert.deepEqual(critical.full_gate_reasons, ['critical_path:server.mjs']);
|
||||
|
||||
const unknown = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['new-runtime-kernel.mjs'],
|
||||
});
|
||||
assert.equal(unknown.strategy, 'full');
|
||||
assert.deepEqual(unknown.unmapped_paths, ['new-runtime-kernel.mjs']);
|
||||
assert.equal(unknown.selected_total, catalog.length);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function assertShellParses(source, label) {
|
||||
assert.equal(result.status, 0, `${label} failed bash -n:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
test('production release verifies gate report before any 103 connection', async () => {
|
||||
test('production release verifies gate report before 103 preflight and upload', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'utf8',
|
||||
@@ -49,10 +49,10 @@ test('production stable release verifies canary promotion evidence before gate c
|
||||
);
|
||||
const promotionIndex = source.indexOf('verify-canary-promotion-evidence.mjs');
|
||||
const gateIndex = source.indexOf('verify-release-gate-report.mjs');
|
||||
const incrementalIndex = source.indexOf('run-release-gate-incremental.mjs');
|
||||
const impactIndex = source.indexOf('run-release-gate-impact.mjs');
|
||||
assert.ok(promotionIndex > 0, 'missing canary promotion evidence verifier');
|
||||
assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence');
|
||||
assert.ok(incrementalIndex > 0, 'missing incremental gate fallback');
|
||||
assert.ok(impactIndex > 0, 'missing risk-based impact gate fallback');
|
||||
assert.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/);
|
||||
assert.match(source, /read_agent_run_status_json/);
|
||||
assert.match(source, /sed -n '\/\^\{/);
|
||||
@@ -140,6 +140,9 @@ test('production canary verifies the exact Gate artifact before any 103 prefligh
|
||||
assert.match(source, /branch.*!= "main"/);
|
||||
assert.match(source, /rev-parse origin\/main/);
|
||||
assert.match(source, /Production canary release forbids/);
|
||||
assert.match(source, /run-release-gate-impact\.mjs/);
|
||||
assert.match(source, /MEMIND_RELEASE_BASE_COMMIT/);
|
||||
assert.match(source, /-z "\$\{DEPLOYED_SHA\}".*"\$\{DRY_RUN\}" -ne 1/);
|
||||
});
|
||||
|
||||
test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => {
|
||||
|
||||
+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 |',
|
||||
'|---|---:|',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createGateReport,
|
||||
validateGateReport,
|
||||
} from './report.mjs';
|
||||
import { selectImpactScenarios } from './impact.mjs';
|
||||
|
||||
const COMMIT = 'a'.repeat(40);
|
||||
const ARTIFACT = 'b'.repeat(64);
|
||||
@@ -110,3 +111,81 @@ test('expired report or artifact mismatch is rejected', async () => {
|
||||
assert.match(result.errors.join('\n'), /artifact_sha256 does not match/);
|
||||
assert.match(result.errors.join('\n'), /report has expired/);
|
||||
});
|
||||
|
||||
test('impact report accepts only the selected core and affected scenarios', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['memory-v2-lifecycle.mjs'],
|
||||
}),
|
||||
base_commit: 'c'.repeat(40),
|
||||
};
|
||||
const selected = new Set(selection.selected_ids);
|
||||
const report = createGateReport({
|
||||
commitSha: COMMIT,
|
||||
branch: 'main',
|
||||
artifactSha256: ARTIFACT,
|
||||
artifact: { path: '.runtime/portal', kind: 'directory-tree' },
|
||||
mode: 'impact',
|
||||
selection,
|
||||
scenarios: catalog
|
||||
.filter((scenario) => selected.has(scenario.id))
|
||||
.map((scenario) => ({
|
||||
id: scenario.id,
|
||||
name: scenario.name,
|
||||
status: 'passed',
|
||||
cleanup_status: 'not_required',
|
||||
evidence: ['fixture'],
|
||||
})),
|
||||
completedAt: new Date('2026-07-26T10:00:00.000Z'),
|
||||
});
|
||||
const result = validateGateReport(report, {
|
||||
expectedCommit: COMMIT,
|
||||
expectedArtifactSha256: ARTIFACT,
|
||||
now: new Date('2026-07-26T11:00:00.000Z'),
|
||||
});
|
||||
assert.deepEqual(result, { valid: true, errors: [] });
|
||||
assert.ok(report.summary.required < catalog.length);
|
||||
});
|
||||
|
||||
test('impact report rejects missing core coverage and unmapped non-full paths', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['docs/local-dev.md'],
|
||||
}),
|
||||
base_commit: 'c'.repeat(40),
|
||||
};
|
||||
selection.unmapped_paths = ['unknown-runtime.mjs'];
|
||||
selection.selected_ids = selection.selected_ids.filter((id) => id !== 'AUTH-05');
|
||||
selection.selected_total = selection.selected_ids.length;
|
||||
const selected = new Set(selection.selected_ids);
|
||||
const report = createGateReport({
|
||||
commitSha: COMMIT,
|
||||
branch: 'main',
|
||||
artifactSha256: ARTIFACT,
|
||||
artifact: { path: '.runtime/portal', kind: 'directory-tree' },
|
||||
mode: 'impact',
|
||||
selection,
|
||||
scenarios: catalog
|
||||
.filter((scenario) => selected.has(scenario.id))
|
||||
.map((scenario) => ({
|
||||
id: scenario.id,
|
||||
name: scenario.name,
|
||||
status: 'passed',
|
||||
cleanup_status: 'not_required',
|
||||
evidence: ['fixture'],
|
||||
})),
|
||||
completedAt: new Date('2026-07-26T10:00:00.000Z'),
|
||||
});
|
||||
const result = validateGateReport(report, {
|
||||
expectedCommit: COMMIT,
|
||||
expectedArtifactSha256: ARTIFACT,
|
||||
now: new Date('2026-07-26T11:00:00.000Z'),
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.errors.join('\n'), /missing core scenarios: AUTH-05/);
|
||||
assert.match(result.errors.join('\n'), /unmapped runtime paths require a full Gate/);
|
||||
});
|
||||
|
||||
+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