From 82b74d71ad825f7cedbf65bfedf329238fdf9c36 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 27 Jul 2026 22:26:01 +0800 Subject: [PATCH] fix: relax REL release gate checks for normal Portal publishes Ignore local .runtime build output in REL-01, accept missing Gitea CI when origin/main matches HEAD, and compare two clean runtime rebuilds for REL-03. Co-authored-by: Cursor --- docs/production-release-guardian.md | 4 +-- docs/release-gate-automation.md | 8 +++-- release-gate/ci-status.mjs | 10 ++++++ release-gate/ci-status.test.mjs | 37 +++++++++++++++++++++ release-gate/coverage.mjs | 2 +- release-gate/runner.mjs | 12 ++++--- release-gate/runner.test.mjs | 8 ++--- scripts/run-release-gate-artifact-repro.mjs | 10 +++--- 8 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 release-gate/ci-status.test.mjs diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md index b32897b..1a5c7b7 100644 --- a/docs/production-release-guardian.md +++ b/docs/production-release-guardian.md @@ -222,7 +222,7 @@ LLM 场景不得只断言固定文案,应优先断言: ### 7.1 发布与运行时(11) - **REL-01**:候选来源必须是干净、非 detached 的完整 `main`;记录完整 SHA。 -- **REL-02**:候选不落后 `origin/main`,已进入远端且 CI 通过,没有未合并关键变更。 +- **REL-02**:候选不落后 `origin/main` 且已推送;Gitea commit status 为 `success`,或未接入 commit status 时候选 SHA 等于 `origin/main`。 - **REL-03**:同一 commit 可重复生成可追踪的 manifest 和 runtime artifact SHA256。 - **REL-04**:artifact 必需文件完整,不包含开发垃圾、运行态数据、源码备份或密钥。 - **REL-05**:前端和 runtime 不包含 localhost、错误内网地址或错误公共域名。 @@ -532,7 +532,7 @@ npm run verify:release-gate-report -- --artifact .runtime/portal 个场景提供逐项业务断言,仓库/候选检查另直接判定 `REL-01`、`REL-02` 和 `REL-04`,共 187 个场景都有自动判定能力。未执行 mode 仍为 `unknown`; 缺少 active 脱敏回归 fixture 时 `COMP-09` 明确失败;候选不等于 `origin/main` -或缺少成功 CI 证据时 `REL-02` 明确失败。任何不完整证据都会让完整闸门返回非零, +或缺少成功 CI 证据时 `REL-02` 仅在候选未推送、CI 明确失败或仍为 pending 时失败。任何不完整证据都会让完整闸门返回非零, 这是发布保护而不是测试故障。精确分组覆盖、命令和扩展方式见 [自动发布闸门实现说明](release-gate-automation.md)。 diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md index 00b175d..a6b0a91 100644 --- a/docs/release-gate-automation.md +++ b/docs/release-gate-automation.md @@ -45,7 +45,7 @@ | COMP | 9 | 9 | COMP-01 至 COMP-09 | | **合计** | **187** | **187** | 184 个 suite 映射,加 REL-01/REL-02/REL-04 三项直接检查 | -`REL-03` 会比较候选和两次干净重建的排序目录树 SHA256;`REL-06`、`REL-07` +`REL-03` 会连续两次干净重建 runtime,并比较两次排序目录树 SHA256 是否一致;`REL-06`、`REL-07` 分别执行生产配置冷启动和脱敏旧库升级,并断言运行前后 artifact 哈希不变;`REL-11` 在无网络 Linux ARM64 容器中实际加载 Portal、WeChat、worker 和每个 MCP 模块。 @@ -53,8 +53,10 @@ Gate 只为自动选中的场景生成结果,所有选中项都必须执行并通过。UI-01 至 UI-08 在隔离本地 Portal、390×844 移动视口和公开页 fixture 上运行;COMP-09 由独立执行器 要求 active 脱敏回归 manifest 并逐条回放。没有 manifest 时 COMP-09 明确 `failed`, -不会因“没有用例”而通过。`REL-02` 在候选等于 `origin/main` 且 Gitea commit status 为 -`success` 时通过;也可显式注入 `MEMIND_RELEASE_CI_STATUS=success`。 +不会因“没有用例”而通过。`REL-02` 在候选不落后 `origin/main` 且 Gitea commit status 为 +`success` 时通过;若仓库未接入 commit status,候选 SHA 已等于 `origin/main` 时也视为通过。 +仍可用 `MEMIND_RELEASE_CI_STATUS=success` 显式注入。`REL-01` 会忽略 `.release-gate/` +与 `.runtime/` 下的本地构建产物,避免 Gate 前构建 runtime 误报工作区脏。 常规发布使用风险分层 Gate: diff --git a/release-gate/ci-status.mjs b/release-gate/ci-status.mjs index a314cad..d5674a8 100644 --- a/release-gate/ci-status.mjs +++ b/release-gate/ci-status.mjs @@ -55,3 +55,13 @@ export async function resolveReleaseCiStatus(commitSha) { return 'missing'; } } + +export function isReleaseCiAcceptable(ciStatus, { headSha, remoteSha } = {}) { + const normalized = String(ciStatus ?? 'missing').trim().toLowerCase(); + if (normalized === 'success') return true; + if (normalized === 'failure' || normalized === 'error') return false; + // Self-hosted releases often have no Gitea commit status integration. Once the + // candidate is fully pushed to origin/main, treat missing CI as acceptable. + if (normalized === 'missing' && remoteSha && headSha === remoteSha) return true; + return false; +} diff --git a/release-gate/ci-status.test.mjs b/release-gate/ci-status.test.mjs new file mode 100644 index 0000000..1202038 --- /dev/null +++ b/release-gate/ci-status.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isReleaseCiAcceptable } from './ci-status.mjs'; + +test('release CI accepts explicit success and rejects explicit failure', () => { + assert.equal( + isReleaseCiAcceptable('success', { + headSha: 'abc', + remoteSha: 'abc', + }), + true, + ); + assert.equal( + isReleaseCiAcceptable('failure', { + headSha: 'abc', + remoteSha: 'abc', + }), + false, + ); +}); + +test('release CI accepts missing status when candidate equals origin/main', () => { + const sha = 'c7718c683b216f08865e04edc67f4217f6abc3c1'; + assert.equal( + isReleaseCiAcceptable('missing', { headSha: sha, remoteSha: sha }), + true, + ); + assert.equal( + isReleaseCiAcceptable('missing', { headSha: sha, remoteSha: 'other' }), + false, + ); + assert.equal( + isReleaseCiAcceptable('pending', { headSha: sha, remoteSha: sha }), + false, + ); +}); diff --git a/release-gate/coverage.mjs b/release-gate/coverage.mjs index 4abdb68..7ed2931 100644 --- a/release-gate/coverage.mjs +++ b/release-gate/coverage.mjs @@ -7,7 +7,7 @@ export const AUTOMATION_SUITES = Object.freeze([ exclusive: true, scenarios: ['REL-03'], cases: { - 'REL-03': ['candidate plus two clean rebuilds produce the same sorted runtime tree SHA256'], + 'REL-03': ['two consecutive clean rebuilds produce the same sorted runtime tree SHA256'], }, command: [process.execPath, 'scripts/run-release-gate-artifact-repro.mjs'], }, diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs index 18d202f..c9a15e2 100644 --- a/release-gate/runner.mjs +++ b/release-gate/runner.mjs @@ -15,7 +15,7 @@ import { } from './incremental.mjs'; import { createGateReport, summarizeScenarios, writeGateReport } from './report.mjs'; import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs'; -import { resolveReleaseCiStatus } from './ci-status.mjs'; +import { isReleaseCiAcceptable, resolveReleaseCiStatus } from './ci-status.mjs'; const ROOT = path.resolve(new URL('..', import.meta.url).pathname); const MODES = new Set([ @@ -122,8 +122,9 @@ export function filterGeneratedWorktreeStatus(status) { .split('\n') .filter((line) => { if (!line) return false; - const path = line.slice(3).replace(/^"|"$/g, ''); - return !path.startsWith('.release-gate/'); + const filePath = line.slice(3).replace(/^"|"$/g, ''); + return !filePath.startsWith('.release-gate/') + && !filePath.startsWith('.runtime/'); }) .join('\n'); } @@ -228,10 +229,11 @@ async function applyRepositoryChecks(results, artifactPath) { : 'missing'; const rel02 = byId.get('REL-02'); const mainlineReady = Boolean(remoteSha) && behind === '0' && (remoteSha === headSha || Number(ahead) > 0); - rel02.status = mainlineReady && ciStatus === 'success' ? 'passed' : 'failed'; + const ciAcceptable = isReleaseCiAcceptable(ciStatus, { headSha, remoteSha }); + rel02.status = mainlineReady && ciAcceptable ? 'passed' : 'failed'; rel02.reason = rel02.status === 'passed' ? null - : 'candidate_must_equal_origin_main_with_successful_ci'; + : 'candidate_must_equal_origin_main_with_acceptable_ci'; rel02.evidence.push( `head=${headSha}`, `origin_main=${remoteSha ?? 'missing'}`, diff --git a/release-gate/runner.test.mjs b/release-gate/runner.test.mjs index 40579ae..ffee0e8 100644 --- a/release-gate/runner.test.mjs +++ b/release-gate/runner.test.mjs @@ -58,17 +58,15 @@ test('exclusive suites finish before parallel suites start', async () => { assert.ok(events.indexOf('end:mutating') < events.indexOf('start:parallel-b')); }); -test('worktree cleanliness ignores release-gate output but keeps runtime changes', () => { +test('worktree cleanliness ignores release-gate and runtime build output', () => { const status = [ '?? .release-gate/ea07f6f/report.json', + ' M .runtime/portal/server.mjs', ' D .runtime/portal/public/plaza-covers/cover.jpg', ' M release-gate/runner.mjs', ].join('\n'); assert.equal( filterGeneratedWorktreeStatus(status), - [ - ' D .runtime/portal/public/plaza-covers/cover.jpg', - ' M release-gate/runner.mjs', - ].join('\n'), + ' M release-gate/runner.mjs', ); }); diff --git a/scripts/run-release-gate-artifact-repro.mjs b/scripts/run-release-gate-artifact-repro.mjs index 8187a31..405e3fa 100644 --- a/scripts/run-release-gate-artifact-repro.mjs +++ b/scripts/run-release-gate-artifact-repro.mjs @@ -20,14 +20,14 @@ async function buildRuntime() { if (code !== 0) throw new Error(`runtime rebuild failed with code ${code}`); } -const candidate = await hashArtifact(runtime); await buildRuntime(); const firstRebuild = await hashArtifact(runtime); await buildRuntime(); const secondRebuild = await hashArtifact(runtime); -const hashes = [candidate.sha256, firstRebuild.sha256, secondRebuild.sha256]; -if (new Set(hashes).size !== 1) { - throw new Error(`runtime is not reproducible: ${hashes.join(' != ')}`); +if (firstRebuild.sha256 !== secondRebuild.sha256) { + throw new Error( + `runtime is not reproducible: ${firstRebuild.sha256} != ${secondRebuild.sha256}`, + ); } -console.log(`PASS REL-03 reproducible runtime sha256=${candidate.sha256}`); +console.log(`PASS REL-03 reproducible runtime sha256=${firstRebuild.sha256}`);