feat(release): add incremental gate and auto CI status for 103 publish

When the runtime artifact is unchanged, carry forward prior gate results and
re-run only REL scenarios; resolve REL-02 from Gitea commit status and allow
stable promotion when canary matches the same artifact bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-27 08:32:12 +08:00
parent 9e2aa4f71d
commit a6a9bb4eab
11 changed files with 533 additions and 10 deletions
+98 -3
View File
@@ -6,11 +6,18 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a
import { loadScenarioCatalog } from './catalog.mjs';
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
import { loadActiveRegressionCorpus } from './regression-corpus.mjs';
import { createGateReport, writeGateReport } from './report.mjs';
import {
buildIncrementalReport,
findCarryForwardBaseline,
isReleaseScenario,
mergeIncrementalScenarios,
} from './incremental.mjs';
import { createGateReport, summarizeScenarios, writeGateReport } from './report.mjs';
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']);
const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all', 'incremental']);
async function git(...args) {
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
@@ -26,6 +33,8 @@ export function parseRunnerArgs(argv) {
reportRoot: path.join(ROOT, '.release-gate'),
timeoutMs: 15 * 60 * 1000,
suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4),
baselineCommit: null,
deployedCommit: null,
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
@@ -35,6 +44,8 @@ export function parseRunnerArgs(argv) {
else if (arg === '--report-root') options.reportRoot = path.resolve(argv[++index] ?? '');
else if (arg === '--timeout-ms') options.timeoutMs = Number(argv[++index]);
else if (arg === '--suite-concurrency') options.suiteConcurrency = Number(argv[++index]);
else if (arg === '--baseline-commit') options.baselineCommit = argv[++index] ?? '';
else if (arg === '--deployed-commit') options.deployedCommit = argv[++index] ?? '';
else if (arg === '-h' || arg === '--help') options.help = true;
else throw new Error(`Unknown argument: ${arg}`);
}
@@ -191,7 +202,9 @@ async function applyRepositoryChecks(results, artifactPath) {
`ignored_generated_paths=${status !== relevantStatus}`,
);
const ciStatus = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase();
const ciStatus = remoteSha === headSha
? await resolveReleaseCiStatus(headSha)
: 'missing';
const rel02 = byId.get('REL-02');
rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed';
rel02.reason = rel02.status === 'passed'
@@ -296,3 +309,85 @@ export async function executeReleaseGate(options) {
await writeGateReport(report, outputDir);
return { report, outputDir };
}
export async function executeIncrementalReleaseGate(options) {
const startedAt = new Date();
assertSafeGateEnvironment({ targets: [options.portalBase] });
assertSafePortalBase(options.portalBase);
if (!options.artifact) {
throw new Error('incremental release gate requires --artifact');
}
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
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 artifact = await hashArtifact(options.artifact);
const baseline = await findCarryForwardBaseline({
reportRoot: options.reportRoot,
artifactSha256: artifact.sha256,
preferredCommitSha: options.baselineCommit ?? null,
deployedCommitSha: options.deployedCommit ?? null,
});
if (!baseline) {
throw new Error(
'No valid baseline Gate report found for the current artifact; run the full release gate first.',
);
}
const reexecuted = catalog
.filter((scenario) => isReleaseScenario(scenario.id))
.map(scenarioResult);
const reexecutedById = new Map(reexecuted.map((result) => [result.id, result]));
await applyRepositoryChecks(reexecuted, options.artifact);
const upgradeSuites = AUTOMATION_SUITES.filter((suite) => suite.mode === 'upgrade');
const executions = await runSuitesWithConcurrency(
upgradeSuites,
options.suiteConcurrency,
(suite) => runSuite(suite, outputDir, options.timeoutMs),
);
for (let index = 0; index < upgradeSuites.length; index += 1) {
const suite = upgradeSuites[index];
const execution = executions[index];
for (const scenarioId of suite.scenarios) {
if (!isReleaseScenario(scenarioId)) continue;
const scenario = reexecutedById.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 mergedScenarios = mergeIncrementalScenarios({
baselineScenarios: baseline.report.scenarios,
reexecutedScenarios: reexecuted,
});
const completedAt = new Date();
const report = buildIncrementalReport({
baselineReport: baseline.report,
baselineReportPath: path.relative(ROOT, baseline.reportPath),
commitSha,
branch,
artifactSha256: artifact.sha256,
artifact: {
path: path.relative(ROOT, options.artifact),
kind: artifact.kind,
files: artifact.files,
bytes: artifact.bytes,
},
scenarios: mergedScenarios,
startedAt,
completedAt,
});
report.summary = summarizeScenarios(mergedScenarios);
await writeGateReport(report, outputDir);
return { report, outputDir, baseline };
}