diff --git a/docs/branch-disposition.md b/docs/branch-disposition.md index 9286a16..cf42f73 100644 --- a/docs/branch-disposition.md +++ b/docs/branch-disposition.md @@ -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`,该分支保留仅用于只读追溯,不是待合并开发分支。** diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md index 504c60b..a6e4f58 100644 --- a/docs/production-release-guardian.md +++ b/docs/production-release-guardian.md @@ -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 关键路径与阻断规则 diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md index 4758a37..75cec42 100644 --- a/docs/release-gate-automation.md +++ b/docs/release-gate-automation.md @@ -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=`;未提供有效基线时直接阻断。 2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 diff --git a/release-gate/impact.mjs b/release-gate/impact.mjs index ef3b728..e3d4569 100644 --- a/release-gate/impact.mjs +++ b/release-gate/impact.mjs @@ -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, }; } diff --git a/release-gate/impact.test.mjs b/release-gate/impact.test.mjs index 642a630..d34d0b5 100644 --- a/release-gate/impact.test.mjs +++ b/release-gate/impact.test.mjs @@ -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({ diff --git a/release-gate/preflight.mjs b/release-gate/preflight.mjs new file mode 100644 index 0000000..b36d45d --- /dev/null +++ b/release-gate/preflight.mjs @@ -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(); + } +} diff --git a/release-gate/preflight.test.mjs b/release-gate/preflight.test.mjs new file mode 100644 index 0000000..c443bc3 --- /dev/null +++ b/release-gate/preflight.test.mjs @@ -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); +}); diff --git a/release-gate/release-script.test.mjs b/release-gate/release-script.test.mjs index 604b202..da50bf3 100644 --- a/release-gate/release-script.test.mjs +++ b/release-gate/release-script.test.mjs @@ -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 () => { diff --git a/release-gate/resume.mjs b/release-gate/resume.mjs new file mode 100644 index 0000000..10f3a4c --- /dev/null +++ b/release-gate/resume.mjs @@ -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 }; diff --git a/release-gate/resume.test.mjs b/release-gate/resume.test.mjs new file mode 100644 index 0000000..cce3a84 --- /dev/null +++ b/release-gate/resume.test.mjs @@ -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']); +}); diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs index 26c6e7e..a08c730 100644 --- a/release-gate/runner.mjs +++ b/release-gate/runner.mjs @@ -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 }; } diff --git a/scripts/release-portal-canary-prod.sh b/scripts/release-portal-canary-prod.sh index 72dc92f..5ff80d6 100755 --- a/scripts/release-portal-canary-prod.sh +++ b/scripts/release-portal-canary-prod.sh @@ -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 diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index fc31a28..dacd8b1 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -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 diff --git a/scripts/run-release-gate-runtime-container.mjs b/scripts/run-release-gate-runtime-container.mjs index 96d5981..1016884 100644 --- a/scripts/run-release-gate-runtime-container.mjs +++ b/scripts/run-release-gate-runtime-container.mjs @@ -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) => {