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:
@@ -48,8 +48,17 @@
|
|||||||
全部 187 项都已有自动判定路径,未执行的 mode 仍保持 `unknown`。UI-01 至 UI-08
|
全部 187 项都已有自动判定路径,未执行的 mode 仍保持 `unknown`。UI-01 至 UI-08
|
||||||
在隔离本地 Portal、390×844 移动视口和公开页 fixture 上运行;COMP-09 由独立执行器
|
在隔离本地 Portal、390×844 移动视口和公开页 fixture 上运行;COMP-09 由独立执行器
|
||||||
要求 active 脱敏回归 manifest 并逐条回放。没有 manifest 时 COMP-09 明确 `failed`,
|
要求 active 脱敏回归 manifest 并逐条回放。没有 manifest 时 COMP-09 明确 `failed`,
|
||||||
不会因“没有用例”而通过。`REL-02` 只在候选等于 `origin/main` 且 CI 注入
|
不会因“没有用例”而通过。`REL-02` 在候选等于 `origin/main` 且 Gitea commit status 为
|
||||||
`MEMIND_RELEASE_CI_STATUS=success` 时通过。
|
`success` 时通过;也可显式注入 `MEMIND_RELEASE_CI_STATUS=success`。
|
||||||
|
|
||||||
|
当 runtime artifact SHA256 与已发布版本一致时,可使用增量 Gate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal --deployed-commit <103-stable-sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
增量 Gate 会复用基线 report 中的非 `REL-*` 场景,仅重跑发布相关场景。
|
||||||
|
稳定发布脚本在完整 report 缺失或过期时会自动尝试增量 Gate。
|
||||||
|
|
||||||
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
|
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
|
||||||
分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台
|
分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 promotionIndex = source.indexOf('verify-canary-promotion-evidence.mjs');
|
||||||
const gateIndex = source.indexOf('verify-release-gate-report.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(promotionIndex > 0, 'missing canary promotion evidence verifier');
|
||||||
assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence');
|
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.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/);
|
||||||
assert.match(source, /read_agent_run_status_json/);
|
assert.match(source, /read_agent_run_status_json/);
|
||||||
assert.match(source, /sed -n '\/\^\{/);
|
assert.match(source, /sed -n '\/\^\{/);
|
||||||
|
|||||||
+20
-2
@@ -124,10 +124,28 @@ export function validateGateReport(report, {
|
|||||||
expectedBranch = 'main',
|
expectedBranch = 'main',
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
requireFullCatalog = true,
|
requireFullCatalog = true,
|
||||||
|
allowExpired = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
if (report?.schema_version !== 1) errors.push('schema_version must be 1');
|
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 (!/^[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 (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate');
|
||||||
if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`);
|
if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`);
|
||||||
@@ -148,7 +166,7 @@ export function validateGateReport(report, {
|
|||||||
} else {
|
} else {
|
||||||
if (expiresAt <= completedAt) errors.push('report expiry must be after completion');
|
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 (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)) {
|
if (!Array.isArray(report?.scenarios)) {
|
||||||
|
|||||||
+98
-3
@@ -6,11 +6,18 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a
|
|||||||
import { loadScenarioCatalog } from './catalog.mjs';
|
import { loadScenarioCatalog } from './catalog.mjs';
|
||||||
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
|
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
|
||||||
import { loadActiveRegressionCorpus } from './regression-corpus.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 { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs';
|
||||||
|
import { resolveReleaseCiStatus } from './ci-status.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
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) {
|
async function git(...args) {
|
||||||
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
|
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'),
|
reportRoot: path.join(ROOT, '.release-gate'),
|
||||||
timeoutMs: 15 * 60 * 1000,
|
timeoutMs: 15 * 60 * 1000,
|
||||||
suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4),
|
suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4),
|
||||||
|
baselineCommit: null,
|
||||||
|
deployedCommit: null,
|
||||||
};
|
};
|
||||||
for (let index = 2; index < argv.length; index += 1) {
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
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 === '--report-root') options.reportRoot = path.resolve(argv[++index] ?? '');
|
||||||
else if (arg === '--timeout-ms') options.timeoutMs = Number(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 === '--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 if (arg === '-h' || arg === '--help') options.help = true;
|
||||||
else throw new Error(`Unknown argument: ${arg}`);
|
else throw new Error(`Unknown argument: ${arg}`);
|
||||||
}
|
}
|
||||||
@@ -191,7 +202,9 @@ async function applyRepositoryChecks(results, artifactPath) {
|
|||||||
`ignored_generated_paths=${status !== relevantStatus}`,
|
`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');
|
const rel02 = byId.get('REL-02');
|
||||||
rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed';
|
rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed';
|
||||||
rel02.reason = rel02.status === 'passed'
|
rel02.reason = rel02.status === 'passed'
|
||||||
@@ -296,3 +309,85 @@ export async function executeReleaseGate(options) {
|
|||||||
await writeGateReport(report, outputDir);
|
await writeGateReport(report, outputDir);
|
||||||
return { 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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -336,6 +336,19 @@ REMOTE
|
|||||||
}
|
}
|
||||||
|
|
||||||
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
|
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}")
|
||||||
|
fi
|
||||||
|
node "${ROOT}/scripts/run-release-gate-incremental.mjs" "${INCREMENTAL_ARGS[@]}"
|
||||||
|
fi
|
||||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||||
|
|
||||||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { runCommand } from '../release-gate/runner.mjs';
|
||||||
|
import { resolveReleaseCiStatus } from '../release-gate/ci-status.mjs';
|
||||||
|
|
||||||
|
const ROOT = new URL('..', import.meta.url).pathname;
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = { commit: null };
|
||||||
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === '--commit') options.commit = argv[++index] ?? '';
|
||||||
|
else throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function git(...args) {
|
||||||
|
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
|
||||||
|
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = parseArgs(process.argv);
|
||||||
|
const commitSha = options.commit ?? await git('rev-parse', 'HEAD');
|
||||||
|
const status = await resolveReleaseCiStatus(commitSha);
|
||||||
|
process.stdout.write(`${status}\n`);
|
||||||
|
process.exit(status === 'success' ? 0 : 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { executeIncrementalReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs';
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Usage:
|
||||||
|
node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal [--baseline-commit <sha>] [--deployed-commit <sha>]
|
||||||
|
|
||||||
|
Reuses a prior full Gate report when the runtime artifact SHA256 is unchanged,
|
||||||
|
then re-executes only REL-* release scenarios.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = parseRunnerArgs(process.argv);
|
||||||
|
if (options.help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
options.mode = 'incremental';
|
||||||
|
const { report, outputDir, baseline } = await executeIncrementalReleaseGate(options);
|
||||||
|
const summary = report.summary;
|
||||||
|
console.log(`Incremental release gate report: ${outputDir}`);
|
||||||
|
console.log(`baseline_commit=${baseline.report.commit_sha}`);
|
||||||
|
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.not_applicable === summary.required;
|
||||||
|
process.exit(passed ? 0 : 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Incremental release gate failed: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { hashArtifact } from '../release-gate/artifact.mjs';
|
||||||
|
import {
|
||||||
|
diffTouchesRuntimePaths,
|
||||||
|
isReleaseOnlyPath,
|
||||||
|
} from '../release-gate/incremental.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||||
const REMOTE_ROOT = process.env.STUDIO_REMOTE_ROOT ?? '/Users/john/Project';
|
const REMOTE_ROOT = process.env.STUDIO_REMOTE_ROOT ?? '/Users/john/Project';
|
||||||
const CANDIDATE_BASE = `${REMOTE_ROOT}/Memind-candidates`;
|
const CANDIDATE_BASE = `${REMOTE_ROOT}/Memind-candidates`;
|
||||||
@@ -35,11 +41,13 @@ function parseArgs(argv) {
|
|||||||
const options = {
|
const options = {
|
||||||
host: process.env.STUDIO_HOST ?? '58.38.22.103',
|
host: process.env.STUDIO_HOST ?? '58.38.22.103',
|
||||||
commit: null,
|
commit: null,
|
||||||
|
artifact: path.join(ROOT, '.runtime', 'portal'),
|
||||||
};
|
};
|
||||||
for (let index = 2; index < argv.length; index += 1) {
|
for (let index = 2; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (arg === '--host') options.host = argv[++index] ?? '';
|
if (arg === '--host') options.host = argv[++index] ?? '';
|
||||||
else if (arg === '--commit') options.commit = argv[++index] ?? '';
|
else if (arg === '--commit') options.commit = argv[++index] ?? '';
|
||||||
|
else if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? '');
|
||||||
else throw new Error(`Unknown argument: ${arg}`);
|
else throw new Error(`Unknown argument: ${arg}`);
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
@@ -74,10 +82,54 @@ function parseManifest(content) {
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listReleaseOnlyDiff(baseCommit, headCommit) {
|
||||||
|
const result = spawnSync(
|
||||||
|
'git',
|
||||||
|
['diff', '--name-only', `${baseCommit}..${headCommit}`],
|
||||||
|
{ cwd: ROOT, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`git diff failed: ${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArtifactIdenticalPromotion({
|
||||||
|
candidateCommit,
|
||||||
|
expectedCommit,
|
||||||
|
candidateArtifactSha,
|
||||||
|
localArtifactSha,
|
||||||
|
}) {
|
||||||
|
if (candidateArtifactSha !== localArtifactSha) {
|
||||||
|
throw new Error(
|
||||||
|
`Canary candidate artifact mismatch: candidate=${candidateArtifactSha ?? 'missing'} expected=${localArtifactSha}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const changedPaths = listReleaseOnlyDiff(candidateCommit, expectedCommit);
|
||||||
|
if (changedPaths.length === 0) {
|
||||||
|
return { changedPaths, promotion: 'artifact_identical_no_diff' };
|
||||||
|
}
|
||||||
|
if (diffTouchesRuntimePaths(changedPaths)) {
|
||||||
|
throw new Error(
|
||||||
|
`Canary candidate commit mismatch and diff is not release-only: candidate=${candidateCommit} expected=${expectedCommit} changed=${changedPaths.join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!changedPaths.every(isReleaseOnlyPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Canary promotion diff contains non-release paths: ${changedPaths.join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { changedPaths, promotion: 'artifact_identical_release_only_diff' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const options = parseArgs(process.argv);
|
const options = parseArgs(process.argv);
|
||||||
const commit = options.commit ?? git('rev-parse', 'HEAD');
|
const commit = options.commit ?? git('rev-parse', 'HEAD');
|
||||||
const remoteUserHost = options.host.includes('@') ? options.host : `john@${options.host}`;
|
const remoteUserHost = options.host.includes('@') ? options.host : `john@${options.host}`;
|
||||||
|
const localArtifact = await hashArtifact(options.artifact);
|
||||||
|
|
||||||
const remoteOutput = ssh(
|
const remoteOutput = ssh(
|
||||||
remoteUserHost,
|
remoteUserHost,
|
||||||
@@ -107,11 +159,18 @@ try {
|
|||||||
if (!candidateDir) {
|
if (!candidateDir) {
|
||||||
throw new Error('103 has no canary candidate directory');
|
throw new Error('103 has no canary candidate directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let promotionMode = 'commit_match';
|
||||||
if (manifest.git_head !== commit) {
|
if (manifest.git_head !== commit) {
|
||||||
throw new Error(
|
const promotion = assertArtifactIdenticalPromotion({
|
||||||
`Canary candidate commit mismatch: candidate=${manifest.git_head ?? 'missing'} expected=${commit}`,
|
candidateCommit: manifest.git_head,
|
||||||
);
|
expectedCommit: commit,
|
||||||
|
candidateArtifactSha: manifest.artifact_bundle_sha256,
|
||||||
|
localArtifactSha: localArtifact.sha256,
|
||||||
|
});
|
||||||
|
promotionMode = promotion.promotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^HTTP\/1\.1 200/m.test(healthText)) {
|
if (!/^HTTP\/1\.1 200/m.test(healthText)) {
|
||||||
throw new Error('Canary candidate /api/status is not healthy on 18081');
|
throw new Error('Canary candidate /api/status is not healthy on 18081');
|
||||||
}
|
}
|
||||||
@@ -123,6 +182,8 @@ try {
|
|||||||
console.log(`candidate_dir=${candidateDir}`);
|
console.log(`candidate_dir=${candidateDir}`);
|
||||||
console.log(`release_id=${manifest.release_id ?? 'unknown'}`);
|
console.log(`release_id=${manifest.release_id ?? 'unknown'}`);
|
||||||
console.log(`git_head=${manifest.git_head}`);
|
console.log(`git_head=${manifest.git_head}`);
|
||||||
|
console.log(`promotion_mode=${promotionMode}`);
|
||||||
|
console.log(`artifact_sha256=${localArtifact.sha256}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user