feat(workflow): add risk-based release gates
This commit is contained in:
@@ -141,12 +141,28 @@ required_runtime_paths=(
|
||||
)
|
||||
for relative in "${required_runtime_paths[@]}"; do
|
||||
[[ -e "${RUNTIME_ROOT}/${relative}" ]] || {
|
||||
echo "Candidate runtime is missing ${relative}; rebuild and rerun the complete Gate." >&2
|
||||
echo "Candidate runtime is missing ${relative}; rebuild and rerun the risk-based Gate." >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
say "Verify the complete Gate report against the exact candidate artifact"
|
||||
say "Verify the risk-based Gate report against the exact candidate artifact"
|
||||
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
|
||||
say "Run the core + changed-impact Gate"
|
||||
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
|
||||
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
|
||||
DEPLOYED_SHA="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"grep -E '^git_head=' '${STABLE_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}")
|
||||
if [[ -n "${DEPLOYED_SHA}" ]]; then
|
||||
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
|
||||
fi
|
||||
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
|
||||
fi
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
|
||||
say "Package the verified candidate artifact"
|
||||
|
||||
@@ -337,17 +337,20 @@ REMOTE
|
||||
|
||||
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
|
||||
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
|
||||
say "尝试基于相同 artifact 的增量 Gate"
|
||||
DEPLOYED_SHA="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"grep -E '^git_head=' '${APP_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
INCREMENTAL_ARGS=(--artifact "${RUNTIME_ROOT}")
|
||||
if [[ -n "${DEPLOYED_SHA}" ]]; then
|
||||
INCREMENTAL_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
|
||||
say "执行核心场景 + 变更影响域 Gate"
|
||||
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
|
||||
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
|
||||
DEPLOYED_SHA="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"grep -E '^git_head=' '${APP_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
node "${ROOT}/scripts/run-release-gate-incremental.mjs" "${INCREMENTAL_ARGS[@]}"
|
||||
IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}")
|
||||
if [[ -n "${DEPLOYED_SHA}" ]]; then
|
||||
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
|
||||
fi
|
||||
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
|
||||
fi
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
import { executeImpactReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs';
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit <sha>
|
||||
|
||||
Runs the compact production core Gate plus scenarios selected from the Git diff.
|
||||
Critical, shared, or unmapped runtime changes automatically expand to the full catalog.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseRunnerArgs(process.argv);
|
||||
if (options.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
options.mode = 'impact';
|
||||
const { report, outputDir, selection } = await executeImpactReleaseGate(options);
|
||||
const summary = report.summary;
|
||||
console.log(`Risk-based release gate report: ${outputDir}`);
|
||||
console.log(`mode=${report.mode}`);
|
||||
if (selection) {
|
||||
console.log(`strategy=${selection.strategy}`);
|
||||
console.log(`selected=${selection.selected_total}/${selection.catalog_total}`);
|
||||
console.log(`impact_groups=${selection.impact_groups.join(',') || 'none'}`);
|
||||
console.log(`changed_paths=${selection.changed_paths.length}`);
|
||||
} else {
|
||||
console.log('strategy=full');
|
||||
console.log('reason=deployed_commit_unavailable');
|
||||
}
|
||||
console.log(JSON.stringify(summary));
|
||||
const passed = summary.failed === 0
|
||||
&& summary.skipped === 0
|
||||
&& summary.blocked === 0
|
||||
&& summary.unknown === 0
|
||||
&& summary.cleanup_failed === 0
|
||||
&& summary.passed === summary.required;
|
||||
process.exit(passed ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error(`Risk-based release gate failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
|
||||
import { loadScenarioCatalog } from '../release-gate/catalog.mjs';
|
||||
import { selectImpactScenarios } from '../release-gate/impact.mjs';
|
||||
import { validateGateReport } from '../release-gate/report.mjs';
|
||||
import { runCommand } from '../release-gate/runner.mjs';
|
||||
import { listChangedPathsBetween, runCommand } from '../release-gate/runner.mjs';
|
||||
|
||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
@@ -28,6 +30,22 @@ async function git(...args) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function comparableSelection(selection) {
|
||||
return {
|
||||
policy_version: selection.policy_version,
|
||||
strategy: selection.strategy,
|
||||
catalog_total: selection.catalog_total,
|
||||
core_ids: selection.core_ids,
|
||||
impact_groups: selection.impact_groups,
|
||||
changed_paths: selection.changed_paths,
|
||||
unmapped_paths: selection.unmapped_paths,
|
||||
full_gate_reasons: selection.full_gate_reasons,
|
||||
selected_ids: selection.selected_ids,
|
||||
selected_total: selection.selected_total,
|
||||
base_commit: selection.base_commit,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
|
||||
@@ -43,6 +61,39 @@ try {
|
||||
expectedArtifactSha256: artifact.sha256,
|
||||
expectedBranch: 'main',
|
||||
});
|
||||
if (report.mode === 'impact' && /^[0-9a-f]{40}$/i.test(report?.selection?.base_commit ?? '')) {
|
||||
try {
|
||||
const [catalog, changedPaths, ancestry] = await Promise.all([
|
||||
loadScenarioCatalog({ root: ROOT }),
|
||||
listChangedPathsBetween(report.selection.base_commit, commitSha),
|
||||
runCommand(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', report.selection.base_commit, commitSha],
|
||||
{ cwd: ROOT, timeoutMs: 30_000 },
|
||||
),
|
||||
]);
|
||||
const expectedSelection = {
|
||||
...selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths,
|
||||
forceFullReasons: ancestry.code === 0
|
||||
? []
|
||||
: [`deployed_commit_not_ancestor:${report.selection.base_commit}`],
|
||||
}),
|
||||
base_commit: report.selection.base_commit,
|
||||
};
|
||||
if (
|
||||
JSON.stringify(comparableSelection(report.selection))
|
||||
!== JSON.stringify(comparableSelection(expectedSelection))
|
||||
) {
|
||||
validation.errors.push('impact selection does not match the candidate Git diff');
|
||||
validation.valid = false;
|
||||
}
|
||||
} catch (error) {
|
||||
validation.errors.push(`impact selection cannot be reproduced: ${error.message}`);
|
||||
validation.valid = false;
|
||||
}
|
||||
}
|
||||
if (!validation.valid) {
|
||||
const blocking = report.scenarios.filter(
|
||||
(scenario) => !['passed', 'not_applicable'].includes(scenario.status),
|
||||
|
||||
Reference in New Issue
Block a user