refactor: make production gate impact only
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
This commit is contained in:
+57
-37
@@ -17,24 +17,44 @@ export const CORE_SCENARIO_IDS = Object.freeze([
|
||||
'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 CRITICAL_IMPACT_RULES = Object.freeze([
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'UI'],
|
||||
pattern: /^(?:(?:package|npm-shrinkwrap).*\.json|pnpm-lock\.yaml|yarn\.lock)$/i,
|
||||
},
|
||||
{
|
||||
groups: ['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA'],
|
||||
pattern: /^(?:server\.mjs|schema\.sql|(?:migrations?|database)\/)/i,
|
||||
},
|
||||
{
|
||||
groups: ['CFG', 'REL'],
|
||||
pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|run-release-gate|verify-release-gate|verify-canary-))/i,
|
||||
},
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'REL'],
|
||||
pattern: /^scripts\/(?:run-memind-portal-prod|run-memind-portal-candidate|goosed.*compose)/i,
|
||||
},
|
||||
{
|
||||
groups: ['CFG', 'REL'],
|
||||
pattern: /^(?:docs\/(?:production-release-guardian|release-gate-automation|release-canary-103)\.md|(?:PRODUCTION_RELEASE_RULES|ENGINEERING_WORKFLOW_RULES)\.md|\.github\/workflows\/)/i,
|
||||
},
|
||||
{
|
||||
groups: ['AGENT', 'AUTH', 'CFG', 'CHAT', 'WX'],
|
||||
pattern: /^server\/portal-/i,
|
||||
},
|
||||
{
|
||||
groups: ['AUTH', 'CFG', 'UI'],
|
||||
pattern: /^src\/(?:api\/core|config)\.[cm]?[jt]sx?$/i,
|
||||
},
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'CHAT'],
|
||||
pattern: /^(?:tkmind-proxy|session-stream|session-stream-store|deepseek-no-think-proxy)\.mjs$/i,
|
||||
},
|
||||
]);
|
||||
|
||||
const NON_RUNTIME_PATHS = Object.freeze([
|
||||
/^(?:AGENTS|README|CHANGELOG)\.md$/i,
|
||||
/^\.runtime\//i,
|
||||
/^docs\//i,
|
||||
/^\.cursor\//i,
|
||||
/^\.codex\//i,
|
||||
@@ -57,11 +77,12 @@ const IMPACT_RULES = Object.freeze([
|
||||
{ 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: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
|
||||
{ groups: ['AGENT'], pattern: /(?:agent|capabilit|goosed|worker|aider|mcp)/i },
|
||||
{ groups: ['CHAT'], pattern: /(?:chat|conversation|message|reply|session|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: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i },
|
||||
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
|
||||
]);
|
||||
|
||||
@@ -105,7 +126,7 @@ function closeGroupDependencies(initialGroups) {
|
||||
export function selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths,
|
||||
forceFullReasons = [],
|
||||
blockingReasons = [],
|
||||
}) {
|
||||
const normalizedPaths = normalizePaths(changedPaths);
|
||||
const catalogIds = new Set(catalog.map((scenario) => scenario.id));
|
||||
@@ -114,18 +135,21 @@ export function selectImpactScenarios({
|
||||
throw new Error(`Core release scenarios are missing from the catalog: ${missingCore.join(',')}`);
|
||||
}
|
||||
|
||||
const fullGateReasons = [...forceFullReasons];
|
||||
if (blockingReasons.length > 0) {
|
||||
throw new Error(`Impact selection is blocked: ${blockingReasons.join(',')}`);
|
||||
}
|
||||
const directGroups = new Set();
|
||||
const unmappedPaths = [];
|
||||
|
||||
for (const relativePath of normalizedPaths) {
|
||||
if (matchesAny(FULL_GATE_PATHS, relativePath)) {
|
||||
fullGateReasons.push(`critical_path:${relativePath}`);
|
||||
continue;
|
||||
let matched = false;
|
||||
for (const rule of CRITICAL_IMPACT_RULES) {
|
||||
if (!rule.pattern.test(relativePath)) continue;
|
||||
matched = true;
|
||||
for (const group of rule.groups) directGroups.add(group);
|
||||
}
|
||||
if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue;
|
||||
|
||||
let matched = false;
|
||||
for (const rule of IMPACT_RULES) {
|
||||
if (!rule.pattern.test(relativePath)) continue;
|
||||
matched = true;
|
||||
@@ -134,23 +158,19 @@ export function selectImpactScenarios({
|
||||
if (matched) continue;
|
||||
|
||||
unmappedPaths.push(relativePath);
|
||||
fullGateReasons.push(`unmapped_runtime_path:${relativePath}`);
|
||||
}
|
||||
|
||||
if (unmappedPaths.length > 0) {
|
||||
throw new Error(
|
||||
`Impact selection has unmapped runtime paths: ${unmappedPaths.join(',')}`,
|
||||
);
|
||||
}
|
||||
const impactGroups = closeGroupDependencies(directGroups);
|
||||
const strategy = fullGateReasons.length > 0
|
||||
? 'full'
|
||||
: impactGroups.length > 0
|
||||
? 'impact'
|
||||
: 'core';
|
||||
const strategy = 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);
|
||||
}
|
||||
for (const scenario of catalog) {
|
||||
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
|
||||
}
|
||||
|
||||
const selectedIds = catalog
|
||||
@@ -158,14 +178,14 @@ export function selectImpactScenarios({
|
||||
.filter((id) => selected.has(id));
|
||||
|
||||
return {
|
||||
policy_version: 1,
|
||||
policy_version: 2,
|
||||
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(),
|
||||
full_gate_reasons: [],
|
||||
selected_ids: selectedIds,
|
||||
selected_total: selectedIds.length,
|
||||
};
|
||||
|
||||
@@ -30,21 +30,51 @@ test('domain changes select the domain and dependency closure', async () => {
|
||||
assert.equal(selection.selected_ids.includes('BILL-07'), false);
|
||||
});
|
||||
|
||||
test('critical and unmapped runtime paths fail closed to the full gate', async () => {
|
||||
test('critical paths expand mapped domains and unmapped paths block release', 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']);
|
||||
assert.equal(critical.policy_version, 2);
|
||||
assert.equal(critical.strategy, 'impact');
|
||||
assert.ok(critical.selected_total < catalog.length);
|
||||
assert.deepEqual(
|
||||
critical.impact_groups,
|
||||
['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA', 'FILE', 'MS', 'PAGE'],
|
||||
);
|
||||
assert.deepEqual(critical.full_gate_reasons, []);
|
||||
|
||||
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);
|
||||
assert.throws(
|
||||
() => selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['new-runtime-kernel.mjs'],
|
||||
}),
|
||||
/unmapped runtime paths: new-runtime-kernel\.mjs/,
|
||||
);
|
||||
assert.throws(
|
||||
() => selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: [],
|
||||
blockingReasons: ['deployed_commit_not_ancestor:abc'],
|
||||
}),
|
||||
/Impact selection is blocked/,
|
||||
);
|
||||
});
|
||||
|
||||
test('release policy changes use mapped REL and CFG domains without selecting the catalog', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: [
|
||||
'release-gate/impact.mjs',
|
||||
'scripts/release-portal-canary-prod.sh',
|
||||
'.runtime/portal/server.mjs',
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(selection.strategy, 'impact');
|
||||
assert.deepEqual(selection.impact_groups, ['CFG', 'REL']);
|
||||
assert.ok(selection.selected_total < catalog.length);
|
||||
assert.equal(selection.changed_paths.includes('.runtime/portal/server.mjs'), true);
|
||||
});
|
||||
|
||||
+18
-5
@@ -137,9 +137,18 @@ function validateImpactSelection(report, scenarioIds, errors) {
|
||||
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 (![1, 2].includes(selection.policy_version)) {
|
||||
errors.push('impact policy_version must be 1 or 2');
|
||||
}
|
||||
const allowedStrategies = selection.policy_version === 2
|
||||
? ['core', 'impact']
|
||||
: ['core', 'impact', 'full'];
|
||||
if (!allowedStrategies.includes(selection.strategy)) {
|
||||
errors.push(
|
||||
selection.policy_version === 2
|
||||
? 'impact strategy must be core or impact'
|
||||
: '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');
|
||||
@@ -177,8 +186,12 @@ function validateImpactSelection(report, scenarioIds, errors) {
|
||||
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.unmapped_paths?.length > 0) {
|
||||
errors.push(
|
||||
selection.policy_version === 2
|
||||
? 'unmapped runtime paths block the impact Gate'
|
||||
: 'unmapped runtime paths require a full Gate',
|
||||
);
|
||||
}
|
||||
if (selection.strategy === 'full') {
|
||||
if (!sameValues(scenarioIds, expectedScenarioIds())) {
|
||||
|
||||
@@ -187,5 +187,44 @@ test('impact report rejects missing core coverage and unmapped non-full paths',
|
||||
});
|
||||
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/);
|
||||
assert.match(result.errors.join('\n'), /unmapped runtime paths block the impact Gate/);
|
||||
});
|
||||
|
||||
test('policy version 2 rejects a full production impact strategy', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['memory-v2-lifecycle.mjs'],
|
||||
}),
|
||||
strategy: 'full',
|
||||
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.equal(result.valid, false);
|
||||
assert.match(result.errors.join('\n'), /impact strategy must be core or impact/);
|
||||
});
|
||||
|
||||
@@ -372,7 +372,7 @@ export async function executeImpactReleaseGate(options) {
|
||||
}
|
||||
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
|
||||
if (!options.deployedCommit) {
|
||||
return executeReleaseGate({ ...options, mode: 'all' });
|
||||
throw new Error('impact release gate requires --deployed-commit');
|
||||
}
|
||||
|
||||
const catalog = await loadScenarioCatalog({ root: ROOT });
|
||||
@@ -390,7 +390,7 @@ export async function executeImpactReleaseGate(options) {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths,
|
||||
forceFullReasons: ancestry.code === 0
|
||||
blockingReasons: ancestry.code === 0
|
||||
? []
|
||||
: [`deployed_commit_not_ancestor:${options.deployedCommit}`],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user