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:
john
2026-08-14 22:15:16 +08:00
parent 293ac69a76
commit 2f240e7500
14 changed files with 487 additions and 26 deletions
+17
View File
@@ -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`,该分支保留仅用于只读追溯,不是待合并开发分支。**
+9
View File
@@ -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` 不等于 IMGPGIMGPG 只匹配
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 关键路径与阻断规则
+5 -3
View File
@@ -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
View File
@@ -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,
};
}
+47
View File
@@ -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({
+79
View File
@@ -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();
}
}
+23
View File
@@ -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);
});
+4
View File
@@ -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 () => {
+91
View File
@@ -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 };
+99
View File
@@ -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
View File
@@ -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 };
}
+13 -9
View File
@@ -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
+11 -7
View File
@@ -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) => {