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
+57
View File
@@ -0,0 +1,57 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
function git(args) {
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed`);
}
return result.stdout.trim();
}
async function resolveRemote() {
let remote = '';
try {
remote = git(['remote', 'get-url', 'origin']);
} catch {
return null;
}
const slugMatch = remote.match(/(?:https?:\/\/[^/]+\/|git@[^:]+:)(.+?)(?:\.git)?$/);
if (!slugMatch) return null;
const slug = slugMatch[1].replace(/\.git$/, '');
const [owner, repo] = slug.split('/');
if (!owner || !repo) return null;
const hostMatch = remote.match(/^git@([^:]+):/) || remote.match(/^https?:\/\/([^/]+)\//);
const host = hostMatch?.[1] ?? 'git.tkmind.cn';
const protocol = remote.startsWith('git@') ? 'https' : remote.split('://')[0];
return `${protocol}://${host}/api/v1/repos/${owner}/${repo}`;
}
async function queryGiteaCommitStatus(apiBase, commitSha) {
const response = await fetch(`${apiBase}/commits/${commitSha}/status`, {
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`Gitea commit status lookup failed: HTTP ${response.status}`);
}
const payload = await response.json();
const state = String(payload?.state ?? '').trim().toLowerCase();
if (state === 'success') return 'success';
if (state === 'pending') return 'pending';
return state || 'missing';
}
export async function resolveReleaseCiStatus(commitSha) {
const explicit = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase();
if (explicit) return explicit;
const apiBase = await resolveRemote();
if (!apiBase) return 'missing';
try {
return await queryGiteaCommitStatus(apiBase, commitSha);
} catch {
return 'missing';
}
}
+156
View File
@@ -0,0 +1,156 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { hashArtifact } from './artifact.mjs';
import { validateGateReport } from './report.mjs';
const RELEASE_ONLY_PATH_PREFIXES = [
'release-gate/',
'scripts/release-',
'scripts/verify-canary-',
'scripts/verify-release-gate',
'scripts/check-release',
'scripts/run-release-gate',
'scripts/resolve-release-ci-status.mjs',
'docs/production-release-guardian.md',
'docs/release-gate-automation.md',
];
export function isReleaseOnlyPath(relativePath) {
return RELEASE_ONLY_PATH_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
}
export function isReleaseScenario(scenarioId) {
return scenarioId.startsWith('REL-');
}
export function diffTouchesRuntimePaths(changedPaths) {
return changedPaths.some((relativePath) => !isReleaseOnlyPath(relativePath));
}
export async function loadGateReport(reportPath) {
const raw = await fs.readFile(reportPath, 'utf8');
return JSON.parse(raw);
}
export async function findCarryForwardBaseline({
reportRoot,
artifactSha256,
preferredCommitSha = null,
deployedCommitSha = null,
now = new Date(),
}) {
let entries = [];
try {
entries = await fs.readdir(reportRoot, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
const candidates = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'local') continue;
const reportPath = path.join(reportRoot, entry.name, 'report.json');
try {
const report = await loadGateReport(reportPath);
if (report.artifact_sha256 !== artifactSha256) continue;
const validation = validateGateReport(report, {
expectedArtifactSha256: artifactSha256,
now,
requireFullCatalog: true,
allowExpired: true,
});
if (!validation.valid) continue;
candidates.push({ report, reportPath, commitSha: report.commit_sha });
} catch {
// ignore unreadable or invalid reports
}
}
if (candidates.length === 0) return null;
const rank = (candidate) => {
let score = 0;
if (deployedCommitSha && candidate.commitSha === deployedCommitSha) score += 100;
if (preferredCommitSha && candidate.commitSha === preferredCommitSha) score += 50;
score += Date.parse(candidate.report.completed_at ?? '') / 1_000_000_000_000;
return score;
};
candidates.sort((left, right) => rank(right) - rank(left));
return candidates[0];
}
export function mergeIncrementalScenarios({
baselineScenarios,
reexecutedScenarios,
}) {
const reexecutedById = new Map(reexecutedScenarios.map((scenario) => [scenario.id, scenario]));
return baselineScenarios.map((baselineScenario) => {
const reexecuted = reexecutedById.get(baselineScenario.id);
if (!reexecuted) {
return {
...baselineScenario,
evidence: [
...(baselineScenario.evidence ?? []),
'carried_forward=true',
],
};
}
return {
...reexecuted,
evidence: [
...(reexecuted.evidence ?? []),
'carried_forward=false',
],
};
});
}
export function buildIncrementalReport({
baselineReport,
baselineReportPath,
commitSha,
branch,
artifactSha256,
artifact,
scenarios,
startedAt,
completedAt,
}) {
const carriedForward = scenarios.filter(
(scenario) => (scenario.evidence ?? []).includes('carried_forward=true'),
).length;
const reexecuted = scenarios.length - carriedForward;
return {
schema_version: 1,
mode: 'incremental',
commit_sha: commitSha,
branch,
artifact_sha256: artifactSha256,
artifact,
started_at: startedAt.toISOString(),
completed_at: completedAt.toISOString(),
expires_at: new Date(completedAt.getTime() + 4 * 60 * 60 * 1000).toISOString(),
environment: baselineReport.environment,
environment_fingerprint: baselineReport.environment_fingerprint,
baseline: {
commit_sha: baselineReport.commit_sha,
report_path: baselineReportPath,
carried_forward: carriedForward,
reexecuted,
},
summary: null,
scenarios,
exemptions: scenarios
.filter((scenario) => scenario.status === 'not_applicable')
.map((scenario) => scenario.exemption)
.filter(Boolean),
approved_for_release: false,
};
}
export async function hashArtifactFromOptions(artifactPath) {
return hashArtifact(artifactPath);
}
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
diffTouchesRuntimePaths,
isReleaseOnlyPath,
isReleaseScenario,
mergeIncrementalScenarios,
} from './incremental.mjs';
test('isReleaseScenario identifies REL family only', () => {
assert.equal(isReleaseScenario('REL-02'), true);
assert.equal(isReleaseScenario('CHAT-01'), false);
});
test('isReleaseOnlyPath accepts release workflow files only', () => {
assert.equal(isReleaseOnlyPath('scripts/release-portal-runtime-prod.sh'), true);
assert.equal(isReleaseOnlyPath('release-gate/runner.mjs'), true);
assert.equal(isReleaseOnlyPath('server.mjs'), false);
});
test('diffTouchesRuntimePaths flags runtime-impacting changes', () => {
assert.equal(
diffTouchesRuntimePaths(['scripts/release-portal-runtime-prod.sh']),
false,
);
assert.equal(
diffTouchesRuntimePaths(['scripts/release-portal-runtime-prod.sh', 'server.mjs']),
true,
);
});
test('mergeIncrementalScenarios carries forward unchanged scenarios', () => {
const merged = mergeIncrementalScenarios({
baselineScenarios: [
{ id: 'CHAT-01', status: 'passed', evidence: ['suite=chat'] },
{ id: 'REL-02', status: 'passed', evidence: ['old'] },
],
reexecutedScenarios: [
{ id: 'REL-02', status: 'passed', evidence: ['new'] },
],
});
assert.equal(merged[0].evidence.includes('carried_forward=true'), true);
assert.equal(merged[1].evidence.includes('carried_forward=false'), true);
assert.equal(merged[1].evidence.includes('new'), true);
});
+2
View File
@@ -49,8 +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');
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.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/);
assert.match(source, /read_agent_run_status_json/);
assert.match(source, /sed -n '\/\^\{/);
+20 -2
View File
@@ -124,10 +124,28 @@ export function validateGateReport(report, {
expectedBranch = 'main',
now = new Date(),
requireFullCatalog = true,
allowExpired = false,
} = {}) {
const errors = [];
if (report?.schema_version !== 1) errors.push('schema_version must be 1');
if (report?.mode !== 'all' && requireFullCatalog) errors.push('release report mode must be all');
if (report?.mode !== 'all' && report?.mode !== 'incremental' && requireFullCatalog) {
errors.push('release report mode must be all or incremental');
}
if (report?.mode === 'incremental') {
const baseline = report?.baseline;
if (!baseline || typeof baseline !== 'object') {
errors.push('incremental report is missing baseline metadata');
} else {
for (const field of ['commit_sha', 'report_path', 'carried_forward', 'reexecuted']) {
if (baseline[field] === undefined || baseline[field] === null || baseline[field] === '') {
errors.push(`incremental baseline is missing ${field}`);
}
}
if (Number(baseline.carried_forward) + Number(baseline.reexecuted) !== report?.scenarios?.length) {
errors.push('incremental baseline counts do not match scenario total');
}
}
}
if (!/^[0-9a-f]{40}$/i.test(report?.commit_sha ?? '')) errors.push('commit_sha must be a full SHA');
if (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate');
if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`);
@@ -148,7 +166,7 @@ export function validateGateReport(report, {
} else {
if (expiresAt <= completedAt) errors.push('report expiry must be after completion');
if (expiresAt - completedAt > 4 * 60 * 60 * 1000) errors.push('report validity exceeds four hours');
if (new Date(now).getTime() > expiresAt) errors.push('report has expired');
if (!allowExpired && new Date(now).getTime() > expiresAt) errors.push('report has expired');
}
if (!Array.isArray(report?.scenarios)) {
+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 };
}