fix(release-gate): skip duplicate CI work and stop live LLM blowups
Shared paths like db.mjs were pulling PAGE/DATA live agent suites into every hotfix. Keep those cases for actual page-data changes, resume passed suites on the same artifact, and fail fast on Docker/port issues instead of rerunning 100+ scenarios. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,23 @@
|
||||
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
|
||||
它是分支复用、合并、cherry-pick 和清理前的必查清单。
|
||||
|
||||
## `feature/gate-data01-delivery-keywords`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-14
|
||||
分支 HEAD:`293ac69a`
|
||||
`origin/main` 对应提交:`293ac69a`
|
||||
|
||||
### 原始用途
|
||||
|
||||
DATA-01 提示要求不要停在方案确认,却仍断言回复包含「方案/确认」。改为断言「问卷/后台」交付,并登记微信 image_url 分支处置。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
|
||||
## `feature/wechat-image-url-session-rotate`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
@@ -63,7 +63,16 @@
|
||||
|
||||
Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base commit,比较候选
|
||||
commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。
|
||||
`PAGE-01`、`PAGE-02`、`DATA-01` 至 `DATA-04` 是真 LLM 交付场景:只在 Page Data / 页面交付
|
||||
产品代码变更时选中。`db.mjs`、`server.mjs` 等共享路径仍展开 DATA/PAGE 的确定性套件,
|
||||
但不自动展开上述真 LLM 场景。文件名含 `image` 不等于 IMGPG;IMGPG 只匹配
|
||||
image-generation / imgproxy / thumbnail / user-image-url 等产品路径。
|
||||
正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`。
|
||||
同一 artifact SHA 下允许续跑:已通过的 suite 可携带证据,失败项、命令文件变更的 suite、
|
||||
以及新 commit 上的真 LLM suite 必须重跑。端口占用或 Docker 未就绪必须在 suite 开始前预检失败,
|
||||
不得把环境问题记成业务场景失败后再整轮重来。
|
||||
Gitea CI 对同一 `origin/main` SHA 为 `success` 时,发布脚本可跳过与 CI 重复的本地
|
||||
`npm test` / verify;禁止 `--skip-tests`,也不得跳过 Gate report。
|
||||
|
||||
### 3.3 关键路径与阻断规则
|
||||
|
||||
|
||||
@@ -67,9 +67,11 @@ node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-c
|
||||
|
||||
它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及
|
||||
依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、
|
||||
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。发布脚本在有效报告
|
||||
缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。
|
||||
离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
|
||||
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。
|
||||
`PAGE-01`/`PAGE-02`/`DATA-01`–`DATA-04` 只在页面交付或 Page Data 产品代码变更时选中。
|
||||
同一 artifact 的 impact 报告可续跑已通过 suite;失败项与真 LLM suite 在新 commit 上重跑。
|
||||
发布脚本在有效报告缺失或过期时自动执行该入口,并在 Gitea CI `success` 时跳过与 CI 重复的
|
||||
本地 npm test/verify。离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
|
||||
`MEMIND_RELEASE_BASE_COMMIT=<known-stable-sha>`;未提供有效基线时直接阻断。
|
||||
|
||||
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
|
||||
|
||||
+33
-2
@@ -17,6 +17,25 @@ export const CORE_SCENARIO_IDS = Object.freeze([
|
||||
'COMP-09',
|
||||
]);
|
||||
|
||||
// Live LLM product scenarios. Dependency closure from db.mjs / shared infra
|
||||
// still selects the DATA/PAGE deterministic suites; these four-to-six cases
|
||||
// only run when page-delivery or Page Data product code actually changed.
|
||||
export const LIVE_LLM_SCENARIO_IDS = Object.freeze([
|
||||
'PAGE-01',
|
||||
'PAGE-02',
|
||||
'DATA-01',
|
||||
'DATA-02',
|
||||
'DATA-03',
|
||||
'DATA-04',
|
||||
]);
|
||||
|
||||
const LIVE_LLM_PATH_PATTERNS = Object.freeze([
|
||||
/(?:^|\/)page-data-[^/]+\.mjs$/i,
|
||||
/(?:^|\/)mindspace-public-finish-sync\.mjs$/i,
|
||||
/(?:^|\/)mindspace-page-data[^/]*\.mjs$/i,
|
||||
/^scripts\/run-release-gate-page(?:-data)?-scenarios\.mjs$/i,
|
||||
]);
|
||||
|
||||
const CRITICAL_IMPACT_RULES = Object.freeze([
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'UI'],
|
||||
@@ -58,6 +77,7 @@ const NON_RUNTIME_PATHS = Object.freeze([
|
||||
/^\.gitea\/workflows\//i,
|
||||
/^\.runtime\//i,
|
||||
/^docs\//i,
|
||||
/^scenarios\//i,
|
||||
/^\.cursor\//i,
|
||||
/^\.codex\//i,
|
||||
/^scripts\/dev(?:-|\.|\/)/i,
|
||||
@@ -79,7 +99,7 @@ const IMPACT_RULES = Object.freeze([
|
||||
{ groups: ['SCHED'], pattern: /(?:schedule|scheduler|reminder|cron)/i },
|
||||
{ groups: ['SEARCH'], pattern: /(?:search|weather|market|news-provider)/i },
|
||||
{ groups: ['XLS'], pattern: /(?:excel|xlsx|spreadsheet)/i },
|
||||
{ groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i },
|
||||
{ groups: ['IMGPG'], pattern: /(?:image-to-page|image-generation|imgproxy|thumbnails?|user-image-url|plaza-cover)/i },
|
||||
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
|
||||
{ groups: ['MS'], pattern: /mindspace/i },
|
||||
{ groups: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
|
||||
@@ -114,6 +134,13 @@ function matchesAny(patterns, relativePath) {
|
||||
return patterns.some((pattern) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
export function pathTriggersLiveLlmScenarios(changedPaths) {
|
||||
return normalizePaths(changedPaths).some((relativePath) => (
|
||||
!matchesAny(NON_RUNTIME_PATHS, relativePath)
|
||||
&& LIVE_LLM_PATH_PATTERNS.some((pattern) => pattern.test(relativePath))
|
||||
));
|
||||
}
|
||||
|
||||
function closeGroupDependencies(initialGroups) {
|
||||
const groups = new Set(initialGroups);
|
||||
const pending = [...groups];
|
||||
@@ -172,10 +199,13 @@ export function selectImpactScenarios({
|
||||
}
|
||||
const impactGroups = closeGroupDependencies(directGroups);
|
||||
const strategy = impactGroups.length > 0 ? 'impact' : 'core';
|
||||
const includeLiveLlm = pathTriggersLiveLlmScenarios(normalizedPaths);
|
||||
const selected = new Set(CORE_SCENARIO_IDS);
|
||||
|
||||
for (const scenario of catalog) {
|
||||
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
|
||||
if (!impactGroups.includes(scenario.group)) continue;
|
||||
if (!includeLiveLlm && LIVE_LLM_SCENARIO_IDS.includes(scenario.id)) continue;
|
||||
selected.add(scenario.id);
|
||||
}
|
||||
|
||||
const selectedIds = catalog
|
||||
@@ -193,5 +223,6 @@ export function selectImpactScenarios({
|
||||
full_gate_reasons: [],
|
||||
selected_ids: selectedIds,
|
||||
selected_total: selectedIds.length,
|
||||
live_llm_selected: includeLiveLlm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ test('critical paths expand mapped domains and unmapped paths block release', as
|
||||
critical.impact_groups,
|
||||
['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA', 'FILE', 'MS', 'PAGE'],
|
||||
);
|
||||
assert.equal(critical.live_llm_selected, false);
|
||||
assert.equal(critical.selected_ids.includes('DATA-01'), false);
|
||||
assert.equal(critical.selected_ids.includes('PAGE-01'), false);
|
||||
assert.equal(critical.selected_ids.includes('DATA-06'), true);
|
||||
assert.deepEqual(critical.full_gate_reasons, []);
|
||||
|
||||
assert.throws(
|
||||
@@ -73,6 +77,49 @@ test('critical paths expand mapped domains and unmapped paths block release', as
|
||||
);
|
||||
});
|
||||
|
||||
test('chat image turn-scope does not expand the image-to-page live domain', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['chat-image-turn-scope.mjs', 'wechat-mp.mjs'],
|
||||
});
|
||||
assert.equal(selection.impact_groups.includes('IMGPG'), false);
|
||||
assert.equal(selection.selected_ids.includes('PAGE-01'), false);
|
||||
assert.equal(selection.selected_ids.includes('DATA-01'), false);
|
||||
assert.equal(selection.selected_ids.includes('WX-01'), true);
|
||||
assert.equal(selection.selected_ids.includes('CHAT-01'), true);
|
||||
});
|
||||
|
||||
test('Page Data product code still selects live LLM DATA scenarios', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['page-data-routes.mjs'],
|
||||
});
|
||||
assert.equal(selection.live_llm_selected, true);
|
||||
assert.equal(selection.selected_ids.includes('DATA-01'), true);
|
||||
assert.equal(selection.selected_ids.includes('DATA-06'), true);
|
||||
});
|
||||
|
||||
test('image-generation files still map to IMGPG', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['mindspace-image-generation.mjs'],
|
||||
});
|
||||
assert.equal(selection.impact_groups.includes('IMGPG'), true);
|
||||
});
|
||||
|
||||
test('scenario fixtures are non-runtime and do not block mapping', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['scenarios/ai-usage-survey.json', 'docs/branch-disposition.md'],
|
||||
});
|
||||
assert.equal(selection.strategy, 'core');
|
||||
assert.equal(selection.live_llm_selected, false);
|
||||
});
|
||||
|
||||
test('release policy changes use mapped REL and CFG domains without selecting the catalog', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const GATE_SUITE_PORTS = Object.freeze({
|
||||
'runtime-sanitized-data-upgrade': 19085,
|
||||
'page-data-product-scenarios': 19086,
|
||||
'page-content-delivery-scenarios': 19087,
|
||||
'runtime-production-homolog-cold-start': 19081,
|
||||
});
|
||||
|
||||
const DOCKER_SOCKET_CANDIDATES = [
|
||||
process.env.DOCKER_HOST?.replace(/^unix:\/\//, ''),
|
||||
`${process.env.HOME ?? ''}/.docker/run/docker.sock`,
|
||||
'/var/run/docker.sock',
|
||||
].filter(Boolean);
|
||||
|
||||
export function dockerSocketExists() {
|
||||
return DOCKER_SOCKET_CANDIDATES.some((socketPath) => {
|
||||
try {
|
||||
return fs.existsSync(socketPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDockerDaemonAvailable() {
|
||||
if (!dockerSocketExists()) {
|
||||
throw new Error(
|
||||
'Release gate REL-11 needs Docker; start Docker Desktop and retry. Missing docker.sock.',
|
||||
);
|
||||
}
|
||||
const result = spawnSync('docker', ['info'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Release gate REL-11 needs a running Docker daemon: ${(result.stderr || result.error?.message || 'docker info failed').trim().slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLoopbackPortAvailable(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', (error) => {
|
||||
if (error?.code === 'EADDRINUSE') {
|
||||
reject(new Error(
|
||||
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) reject(closeError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
export async function preflightImpactSuites(suites) {
|
||||
const ports = [...new Set(
|
||||
suites
|
||||
.map((suite) => GATE_SUITE_PORTS[suite.id])
|
||||
.filter((port) => Number.isInteger(port)),
|
||||
)].sort((left, right) => left - right);
|
||||
for (const port of ports) {
|
||||
await assertLoopbackPortAvailable(port);
|
||||
}
|
||||
if (suites.some((suite) => suite.id === 'runtime-linux-dependency-closure')) {
|
||||
assertDockerDaemonAvailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test from 'node:test';
|
||||
|
||||
import { GATE_SUITE_PORTS, assertLoopbackPortAvailable } from './preflight.mjs';
|
||||
|
||||
test('gate suites pin isolated loopback ports', () => {
|
||||
assert.equal(GATE_SUITE_PORTS['runtime-sanitized-data-upgrade'], 19085);
|
||||
assert.equal(GATE_SUITE_PORTS['page-data-product-scenarios'], 19086);
|
||||
assert.equal(GATE_SUITE_PORTS['page-content-delivery-scenarios'], 19087);
|
||||
});
|
||||
|
||||
test('assertLoopbackPortAvailable rejects an occupied port', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
await assert.rejects(
|
||||
() => assertLoopbackPortAvailable(port),
|
||||
/already in use/,
|
||||
);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await assertLoopbackPortAvailable(port);
|
||||
});
|
||||
@@ -143,6 +143,10 @@ test('production canary verifies the exact Gate artifact before any 103 prefligh
|
||||
assert.match(source, /run-release-gate-impact\.mjs/);
|
||||
assert.match(source, /MEMIND_RELEASE_BASE_COMMIT/);
|
||||
assert.match(source, /-z "\$\{DEPLOYED_SHA\}".*"\$\{DRY_RUN\}" -ne 1/);
|
||||
assert.match(source, /resolve-release-ci-status\.mjs/);
|
||||
assert.match(source, /skipping duplicate local npm test\/verify/);
|
||||
const ciSkipIndex = source.indexOf('resolve-release-ci-status.mjs');
|
||||
assert.ok(ciSkipIndex > 0 && ciSkipIndex < gateIndex, 'CI reuse must happen before Gate verification');
|
||||
});
|
||||
|
||||
test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const REPOSITORY_CHECK_IDS = new Set(['REL-01', 'REL-02', 'REL-04']);
|
||||
|
||||
export function commandFilesForSuite(suite) {
|
||||
return (suite.command ?? [])
|
||||
.slice(1)
|
||||
.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'))
|
||||
.map((arg) => arg.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
export function suiteInvalidatedByChanges(suite, changedPaths) {
|
||||
const normalized = new Set((changedPaths ?? []).map((item) => String(item).replaceAll('\\', '/')));
|
||||
if (normalized.has('release-gate/coverage.mjs')) return true;
|
||||
if ([...normalized].some((relativePath) => relativePath.startsWith('scripts/run-release-gate-'))) {
|
||||
return suite.command?.some((arg) => String(arg).includes('run-release-gate-')) ?? false;
|
||||
}
|
||||
return commandFilesForSuite(suite).some((filePath) => normalized.has(filePath));
|
||||
}
|
||||
|
||||
export function previousScenarioMap(report) {
|
||||
return new Map((report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
|
||||
}
|
||||
|
||||
export function shouldRerunImpactSuite({
|
||||
suite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256,
|
||||
commitSha,
|
||||
changedPathsSincePrevious = [],
|
||||
}) {
|
||||
const selectedInSuite = suite.scenarios.filter((scenarioId) => selectedIds.has(scenarioId));
|
||||
if (selectedInSuite.length === 0) return false;
|
||||
if (!previousReport || previousReport.artifact_sha256 !== artifactSha256) return true;
|
||||
if (suiteInvalidatedByChanges(suite, changedPathsSincePrevious)) return true;
|
||||
if (suite.mode === 'scenarios' && previousReport.commit_sha !== commitSha) return true;
|
||||
|
||||
const previous = previousScenarioMap(previousReport);
|
||||
return selectedInSuite.some((scenarioId) => previous.get(scenarioId)?.status !== 'passed');
|
||||
}
|
||||
|
||||
export function carryForwardScenario(previousScenario) {
|
||||
return {
|
||||
...previousScenario,
|
||||
evidence: [
|
||||
...(previousScenario.evidence ?? []),
|
||||
'carried_forward=true',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function findImpactResumeReport({
|
||||
reportRoot,
|
||||
artifactSha256,
|
||||
commitSha,
|
||||
}) {
|
||||
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 = JSON.parse(await fs.readFile(reportPath, 'utf8'));
|
||||
if (report?.mode !== 'impact') continue;
|
||||
if (report.artifact_sha256 !== artifactSha256) continue;
|
||||
if (!Array.isArray(report.scenarios)) continue;
|
||||
candidates.push({ report, reportPath, commitSha: report.commit_sha });
|
||||
} catch {
|
||||
// ignore unreadable reports
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const sameCommit = candidates.find((candidate) => candidate.commitSha === commitSha);
|
||||
if (sameCommit) return sameCommit;
|
||||
candidates.sort((left, right) => (
|
||||
Date.parse(right.report.completed_at ?? 0) - Date.parse(left.report.completed_at ?? 0)
|
||||
));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
export { REPOSITORY_CHECK_IDS };
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
carryForwardScenario,
|
||||
commandFilesForSuite,
|
||||
shouldRerunImpactSuite,
|
||||
suiteInvalidatedByChanges,
|
||||
} from './resume.mjs';
|
||||
|
||||
const chatSuite = {
|
||||
id: 'chat-routing-contract',
|
||||
mode: 'deterministic',
|
||||
scenarios: ['CHAT-01', 'CHAT-02'],
|
||||
command: [process.execPath, '--test', 'chat-router.test.mjs'],
|
||||
};
|
||||
|
||||
const liveSuite = {
|
||||
id: 'page-data-product-scenarios',
|
||||
mode: 'scenarios',
|
||||
scenarios: ['DATA-01', 'DATA-02'],
|
||||
command: [process.execPath, 'scripts/run-release-gate-page-data-scenarios.mjs'],
|
||||
};
|
||||
|
||||
const previousReport = {
|
||||
mode: 'impact',
|
||||
commit_sha: 'a'.repeat(40),
|
||||
artifact_sha256: 'b'.repeat(64),
|
||||
scenarios: [
|
||||
{ id: 'CHAT-01', status: 'passed', evidence: ['suite=chat-routing-contract'] },
|
||||
{ id: 'CHAT-02', status: 'passed', evidence: ['suite=chat-routing-contract'] },
|
||||
{ id: 'DATA-01', status: 'failed', evidence: ['suite=page-data-product-scenarios'] },
|
||||
{ id: 'DATA-02', status: 'passed', evidence: ['suite=page-data-product-scenarios'] },
|
||||
],
|
||||
};
|
||||
|
||||
test('commandFilesForSuite skips node and flags', () => {
|
||||
assert.deepEqual(
|
||||
commandFilesForSuite({ command: [process.execPath, '--test', 'chat-router.test.mjs'] }),
|
||||
['chat-router.test.mjs'],
|
||||
);
|
||||
});
|
||||
|
||||
test('same commit and artifact skips passed suites and reruns failed live suites', () => {
|
||||
const selectedIds = new Set(['CHAT-01', 'CHAT-02', 'DATA-01', 'DATA-02']);
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: chatSuite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256: previousReport.artifact_sha256,
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), false);
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: liveSuite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256: previousReport.artifact_sha256,
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('artifact mismatch forces every selected suite to rerun', () => {
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: chatSuite,
|
||||
selectedIds: new Set(['CHAT-01', 'CHAT-02']),
|
||||
previousReport,
|
||||
artifactSha256: 'c'.repeat(64),
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('live suites rerun on a new commit even when previous cases passed', () => {
|
||||
const passedLive = {
|
||||
...previousReport,
|
||||
scenarios: [
|
||||
{ id: 'DATA-01', status: 'passed' },
|
||||
{ id: 'DATA-02', status: 'passed' },
|
||||
],
|
||||
};
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: liveSuite,
|
||||
selectedIds: new Set(['DATA-01', 'DATA-02']),
|
||||
previousReport: passedLive,
|
||||
artifactSha256: passedLive.artifact_sha256,
|
||||
commitSha: 'd'.repeat(40),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('changing a suite command file invalidates that suite', () => {
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['chat-router.test.mjs']), true);
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['wechat-mp.mjs']), false);
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['release-gate/coverage.mjs']), true);
|
||||
});
|
||||
|
||||
test('carryForwardScenario keeps prior evidence and marks reuse', () => {
|
||||
const carried = carryForwardScenario({ id: 'CHAT-01', status: 'passed', evidence: ['old'] });
|
||||
assert.equal(carried.status, 'passed');
|
||||
assert.deepEqual(carried.evidence, ['old', 'carried_forward=true']);
|
||||
});
|
||||
+54
-5
@@ -6,7 +6,14 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a
|
||||
import { loadScenarioCatalog } from './catalog.mjs';
|
||||
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
|
||||
import { selectImpactScenarios } from './impact.mjs';
|
||||
import { preflightImpactSuites } from './preflight.mjs';
|
||||
import { loadActiveRegressionCorpus } from './regression-corpus.mjs';
|
||||
import {
|
||||
REPOSITORY_CHECK_IDS,
|
||||
carryForwardScenario,
|
||||
findImpactResumeReport,
|
||||
shouldRerunImpactSuite,
|
||||
} from './resume.mjs';
|
||||
import {
|
||||
buildIncrementalReport,
|
||||
findCarryForwardBaseline,
|
||||
@@ -419,24 +426,61 @@ export async function executeImpactReleaseGate(options) {
|
||||
const suites = AUTOMATION_SUITES.filter(
|
||||
(suite) => suite.scenarios.some((scenarioId) => selectedIds.has(scenarioId)),
|
||||
);
|
||||
const resume = await findImpactResumeReport({
|
||||
reportRoot: options.reportRoot,
|
||||
artifactSha256: artifact.sha256,
|
||||
commitSha,
|
||||
});
|
||||
let changedPathsSincePrevious = [];
|
||||
if (resume?.commitSha && resume.commitSha !== commitSha) {
|
||||
try {
|
||||
changedPathsSincePrevious = await listChangedPathsBetween(resume.commitSha, commitSha);
|
||||
} catch {
|
||||
changedPathsSincePrevious = ['release-gate/coverage.mjs'];
|
||||
}
|
||||
}
|
||||
const suitesToRun = suites.filter((suite) => shouldRerunImpactSuite({
|
||||
suite,
|
||||
selectedIds,
|
||||
previousReport: resume?.report ?? null,
|
||||
artifactSha256: artifact.sha256,
|
||||
commitSha,
|
||||
changedPathsSincePrevious,
|
||||
}));
|
||||
const skippedSuites = suites.filter((suite) => !suitesToRun.includes(suite));
|
||||
const previousById = new Map((resume?.report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
|
||||
for (const suite of skippedSuites) {
|
||||
for (const scenarioId of suite.scenarios) {
|
||||
if (!selectedIds.has(scenarioId) || REPOSITORY_CHECK_IDS.has(scenarioId)) continue;
|
||||
const previous = previousById.get(scenarioId);
|
||||
if (!previous) continue;
|
||||
const current = byId.get(scenarioId);
|
||||
Object.assign(current, carryForwardScenario(previous));
|
||||
}
|
||||
}
|
||||
|
||||
if (suitesToRun.length > 0) {
|
||||
await preflightImpactSuites(suitesToRun);
|
||||
}
|
||||
const executions = await runSuitesWithConcurrency(
|
||||
suites,
|
||||
suitesToRun,
|
||||
options.suiteConcurrency,
|
||||
(suite) => runSuite(suite, outputDir, options.timeoutMs),
|
||||
);
|
||||
for (let index = 0; index < suites.length; index += 1) {
|
||||
const suite = suites[index];
|
||||
for (let index = 0; index < suitesToRun.length; index += 1) {
|
||||
const suite = suitesToRun[index];
|
||||
const execution = executions[index];
|
||||
for (const scenarioId of suite.scenarios) {
|
||||
if (!selectedIds.has(scenarioId)) continue;
|
||||
const scenario = byId.get(scenarioId);
|
||||
scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed';
|
||||
scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed';
|
||||
scenario.evidence.push(
|
||||
scenario.evidence = [
|
||||
`suite=${suite.id}`,
|
||||
`log=${execution.logPath}`,
|
||||
...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`),
|
||||
);
|
||||
'carried_forward=false',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +501,11 @@ export async function executeImpactReleaseGate(options) {
|
||||
startedAt,
|
||||
completedAt,
|
||||
});
|
||||
report.resume = {
|
||||
baseline_commit: resume?.commitSha ?? null,
|
||||
carried_suites: skippedSuites.map((suite) => suite.id),
|
||||
reran_suites: suitesToRun.map((suite) => suite.id),
|
||||
};
|
||||
await writeGateReport(report, outputDir);
|
||||
return { report, outputDir, selection };
|
||||
}
|
||||
|
||||
@@ -116,15 +116,19 @@ fi
|
||||
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh" --skip-fetch
|
||||
|
||||
say "Run release source guards"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:mindspace-page-sync-guards >/dev/null
|
||||
npm run verify:h5-session-patches >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
npm run check:mindspace-public-links >/dev/null
|
||||
)
|
||||
if node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "${FULL_SHA}"; then
|
||||
say "Gitea CI already succeeded for ${FULL_SHA}; skipping duplicate local npm test/verify"
|
||||
else
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:mindspace-page-sync-guards >/dev/null
|
||||
npm run verify:h5-session-patches >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
npm run check:mindspace-public-links >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
required_runtime_paths=(
|
||||
server.mjs
|
||||
|
||||
@@ -153,13 +153,17 @@ say "本地预检查"
|
||||
check_release_scope
|
||||
|
||||
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||||
say "运行最小验证"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
if node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "$(git -C "${ROOT}" rev-parse HEAD)"; then
|
||||
say "Gitea CI already succeeded; skipping duplicate local npm test/verify"
|
||||
else
|
||||
say "运行最小验证"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||||
|
||||
@@ -3,10 +3,12 @@ import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertPortalRuntimePath, inspectPortalRuntime } from '../release-gate/artifact.mjs';
|
||||
import { assertDockerDaemonAvailable } from '../release-gate/preflight.mjs';
|
||||
|
||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root });
|
||||
const image = process.env.RELEASE_GATE_NODE_IMAGE || 'node:24-bookworm';
|
||||
assertDockerDaemonAvailable();
|
||||
|
||||
async function runDocker(commandArgs, { input = '' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user