From dfab78c75ab36068d44302c77260053ac4ca5759 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 27 Jul 2026 10:38:24 +0800 Subject: [PATCH] feat(workflow): add risk-based release gates --- AGENTS.md | 14 +- ENGINEERING_WORKFLOW_RULES.md | 2 +- PRODUCTION_RELEASE_RULES.md | 7 +- docs/production-release-guardian.md | 96 +++++++++++--- docs/release-canary-103.md | 5 +- docs/release-gate-automation.md | 49 ++++--- docs/发包必看.md | 2 +- release-gate/impact.mjs | 172 +++++++++++++++++++++++++ release-gate/impact.test.mjs | 50 +++++++ release-gate/release-script.test.mjs | 9 +- release-gate/report.mjs | 98 +++++++++++++- release-gate/report.test.mjs | 79 ++++++++++++ release-gate/runner.mjs | 141 +++++++++++++++++++- scripts/release-portal-canary-prod.sh | 20 ++- scripts/release-portal-runtime-prod.sh | 23 ++-- scripts/run-release-gate-impact.mjs | 43 +++++++ scripts/verify-release-gate-report.mjs | 53 +++++++- 17 files changed, 794 insertions(+), 69 deletions(-) create mode 100644 release-gate/impact.mjs create mode 100644 release-gate/impact.test.mjs create mode 100644 scripts/run-release-gate-impact.mjs diff --git a/AGENTS.md b/AGENTS.md index 9026d95..9f2694e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,19 @@ 本文件供所有在仓库内工作的 AI 编码助手阅读。仓库级开发、测试、发布规范以 [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md) 为准;生产发布规范以 [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md) 为准。 +## 必读:单一开发分支硬闸门 + +以下规则适用于 Codex、Cursor、Cloud、Claude、人工操作及其它任何自动化工具,不得绕过: + +1. 除 `main` 外,同一时间只允许存在一个尚未闭环的本地开发分支;同时只允许一个开发 worktree。已有开发分支或开发 worktree 时,严禁通过 `git switch -c`、`git checkout -b`、`git worktree add`、IDE、云端任务或脚本再创建第二个分支/worktree。 +2. 当前开发分支未完成全部改动、仍有未提交文件、仍有未推送提交、尚未并入 `main`、其提交尚未进入 `origin/main`,或该分支/worktree 尚未删除时,Codex、Cursor 及其它 Agent 一律不得创建新分支。不得以并行开发、临时修复、试验、续作、PR、冲突处理或“先开分支再说”等理由例外处理。 +3. 一个开发分支必须完整闭环后才能开始下一分支,顺序固定为:完成开发 → 执行对应测试/verify → 提交全部应交付改动 → 取得规则要求的明确批准 → 合并进本地 `main` → 推送并确认远端 `origin/main` 已包含该分支提交 → 删除开发 worktree(如有)→ 删除本地开发分支 → 删除同名远端开发分支(如曾创建)。 +4. 新建分支前必须先执行只读检查,确认工作区干净、当前位于已同步的 `main`、不存在其它本地开发分支、不存在其它开发 worktree,且上一分支的 HEAD 已是 `origin/main` 的祖先。任一检查不满足,必须停止并继续完成或清理上一分支,禁止创建新分支。 +5. 分支闭环中的 `push`、合并 `main`、删除分支等动作仍分别受本文的测试、明确批准和发布闸门约束;本节只增加“未闭环不得开新分支”的硬阻断,不构成对这些动作的预先授权。 + ## 必读:分支与发布闸门 -生产 `103` 发布还必须完整遵守 [docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档定义 160 个场景族、合法 `not_applicable` 条件、Gate report 和发布硬阻断规则;自动守门员尚未完整落地前,不得声称已满足新发布条件。 +生产 `103` 发布还必须完整遵守 [docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档维护 187 个完整回归场景族;常规发布执行 16 项核心场景加自动影响域场景,高风险或无法识别影响范围时自动升级为全量 Gate。不得手工删减选择结果或绕过 Gate report 硬阻断。 1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`。 2. 在“修复 bug / 开发中”阶段,默认只允许本地修改、本地运行、本地测试;**没有用户明确批准,不允许 `git push`、不允许生成或发布任何 `103` 相关 runtime/artifact、不允许合并或并入 `main`、不允许触发任何生产动作。** @@ -22,7 +32,7 @@ bash scripts/check-release-ready.sh ``` -12. `check-release-ready.sh` 只是源码闸门,不代表业务场景闸门通过;生产发布还必须取得与同一 commit、同一 runtime artifact 绑定的完整 Gate report。 +12. `check-release-ready.sh` 只是源码闸门,不代表业务场景闸门通过;生产发布还必须取得与同一 commit、同一 runtime artifact 绑定、且可从 Git diff 重现选择结果的风险分层或全量 Gate report。 ## 必读:历史分支处置登记 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md index 9f541a6..51e6f30 100644 --- a/ENGINEERING_WORKFLOW_RULES.md +++ b/ENGINEERING_WORKFLOW_RULES.md @@ -67,7 +67,7 @@ bash scripts/check-release-ready.sh ``` 10. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。 -11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md);Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact,且所有适用场景成功后仍须取得明确人工批准。 +11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md);Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact。常规发布执行 Core + 自动影响域,高风险或未映射变更自动升级为完整 187 项;所有被选场景成功后仍须取得明确人工批准。 ## 5. 文档约束 diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 4cb18b5..5ee3c54 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -19,6 +19,7 @@ 11. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 12. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 -14. 每次生产发布必须生成与完整 `main` commit 和 runtime artifact SHA256 绑定的 Gate report;结果必须满足 `failed=0`、`skipped=0`、`cleanup_failed=0`。 -15. 确实不受本次变更影响的场景只能按生产发布守门员记录为经过审核的 `not_applicable`;无法确认影响时必须执行,禁止用 `--skip-tests`、环境变量或口头说明绕过。 -16. 当前发布脚本已接入 Gate report 上传前硬校验,但场景自动化尚未覆盖 187 项;任何 `unknown`、未执行或未实现项都会阻断,因此在完整报告通过前仍不得发布。即使 Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 +14. 每次生产发布必须生成与完整 `main` commit 和 runtime artifact SHA256 绑定的 Gate report。常规发布执行 16 项核心场景加 Git diff 自动选择的影响域场景;所有被选择场景必须真实执行并满足 `failed=0`、`skipped=0`、`blocked=0`、`unknown=0`、`cleanup_failed=0`。 +15. 187 项是完整回归目录,不是每次发布的固定执行数量。鉴权、数据库、runtime 构建、依赖、共享入口、发布闸门自身或任何未映射的运行时代码发生变化时,影响选择器必须自动升级为全量 Gate。 +16. 风险分层报告必须记录线上基线 commit、changed paths、核心场景、影响域、最终选择结果和选择策略;发布校验器必须从 Git diff 重新计算并核对,禁止人工删减选择结果。正常风险分层 Gate 不使用逐项 `not_applicable`,被选择的场景必须通过。 +17. 完整 187 项 Gate 用于高风险发布、选择器或发布规则变化、无有效基线、灰度/生产失败后的重新验证,以及定期回归。即使 Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md index e147814..b32897b 100644 --- a/docs/production-release-guardian.md +++ b/docs/production-release-guardian.md @@ -2,16 +2,16 @@ > 适用目标:生产 `103` Portal runtime 及其共享链路。 > 规则状态:已确认为生产发布的强制验收规范。 -> 当前实施状态:187 场景机器目录、fail-closed 执行器、artifact 哈希、机器报告校验和发布脚本上传前硬阻断已经落地;业务场景自动化仍在分批补齐。任何未实现项保持 `unknown` 并阻断发布,在 187 项全部通过或合法豁免前,不得宣称生产发布已经通过本守门员。 +> 当前实施状态:187 场景机器目录保留为完整回归资产;常规生产发布使用 16 项 Core Gate 加 Git diff 自动选择的影响域 Gate。关键共享路径、无法映射的运行时代码或高风险变更自动升级为完整 187 项 Gate。任何被选择但未实现、未执行或证据不完整的场景保持 `unknown` 并阻断发布。 ## 1. 目标 本守门员将生产发布从“运行若干测试后人工判断”升级为绑定同一源码和同一产物的可审计硬闸门: 1. 只有完整、干净、可追溯的 `main` 可以成为候选版本。 -2. 同一个 `main` commit 构建出的同一个 runtime artifact,必须完成本文规定的全部适用场景。 +2. 同一个 `main` commit 构建出的同一个 runtime artifact,必须完成 Core Gate 和影响选择器选出的全部场景;满足全量触发条件时执行完整 187 项。 3. 结果必须满足 `failed=0`、`skipped=0`、`cleanup_failed=0`。 -4. 确实不受影响的场景只能标记为经过审核的 `not_applicable`,不能用 `skip`、口头说明或发布参数绕过。 +4. 常规风险分层报告不要求为未选场景逐项填写 `not_applicable`;选择器必须记录 changed paths、影响域和选择证据,被选中的场景不能豁免。 5. 即使机器闸门全部通过,仍须取得用户针对“发布 103”动作本身的明确批准。 6. 未获得批准时,禁止上传、切换 runtime、重启服务或执行任何生产写操作。 @@ -22,6 +22,9 @@ | Candidate commit | 已进入远端 `main`、CI 通过、准备构建发布包的完整 Git SHA | | Candidate artifact | 由 Candidate commit 构建的 Portal runtime 包及其 SHA256 | | Scenario family | 本文定义的一条业务或运行时场景;可包含多个参数化 case | +| Core Gate | 每次生产候选都必须执行的 16 项最小安全闭包 | +| Impact Gate | 根据线上 commit 到候选 commit 的 Git diff 自动选择的业务域场景 | +| Full Gate | 高风险、共享入口或影响范围不明时执行的完整 187 项回归 | | Deterministic case | 不依赖真实外部模型输出、可以稳定重复的单元或集成测试 | | Live canary | 在隔离或预生产环境调用真实 LLM、图片、搜索等服务的最小验证 | | `not_applicable` | 有完整影响证明并经审核后,确认本次发布不适用的场景状态 | @@ -37,9 +40,10 @@ - Candidate commit 位于完整 `main`,工作区干净,不落后 `origin/main`。 - Candidate commit 已在远端 CI 通过,且没有未合并的关键变更。 - Candidate artifact 的 SHA256 与 Gate report 完全一致。 -- 所有适用场景均为 `passed`。 +- Core Gate 和影响选择器选出的全部场景均为 `passed`。 - 没有 `failed`、`skipped`、`blocked`、`unknown` 或未执行场景。 -- 所有 `not_applicable` 均具有有效的影响评估和审核记录。 +- 风险分层报告的 base commit、changed paths、影响域和 selected IDs 能够从 Git diff 重算且完全一致。 +- 全量报告如果使用 `not_applicable`,所有记录均具有有效的影响评估和审核信息。 - 测试数据和临时资源清理完成。 - Gate report 未过期;默认有效期不超过 4 小时。 - 103 只读预检通过。 @@ -47,7 +51,32 @@ 任何一项不满足,发布脚本必须在上传 artifact 之前退出。 -### 3.2 永远不可豁免的场景 +### 3.2 常规发布的 Core Gate + +以下 16 项每次生产候选都必须执行: + +- `REL-01`、`REL-02`、`REL-04` 至 `REL-06`、`REL-09`、`REL-11`。 +- `AUTH-01`、`AUTH-05`。 +- `CHAT-01`、`CHAT-06`、`CHAT-07`。 +- `AGENT-01`、`AGENT-03`。 +- `MS-01`。 +- `COMP-09` 脱敏生产回归语料回放。 + +Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base commit,比较候选 +commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。 +正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`。 + +### 3.3 自动升级为 Full Gate + +以下情况必须执行完整 187 项: + +- `server.mjs`、鉴权/会话基础设施、数据库 schema/migration、runtime 构建和生产启动脚本变化。 +- 依赖清单或 lockfile、goosed/worker/代理、公共配置和发布闸门自身变化。 +- 影响选择器无法映射任一运行时代码路径。 +- 线上 base commit 不可用、不是候选祖先,或选择结果无法从 Git diff 重现。 +- 上次灰度或生产发布失败,或明确要求完整回归。 + +### 3.4 Full Gate 中永远不可豁免的场景 下列场景不允许标记为 `not_applicable`: @@ -66,7 +95,7 @@ - `CFG-08` 模型配置兼容场景。 - `COMP-08`、`COMP-09`。 -### 3.3 `not_applicable` 的判定 +### 3.5 Full Gate 中 `not_applicable` 的判定 只有同时满足以下条件,才允许将场景标记为 `not_applicable`: @@ -479,6 +508,7 @@ npm run test:release-gate:browser -- --artifact .runtime/portal npm run test:release-gate:providers -- --artifact .runtime/portal npm run test:release-gate:upgrade -- --artifact .runtime/portal npm run test:release-gate:all -- --artifact .runtime/portal +node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit <103-stable-sha> npm run verify:release-gate-report -- --artifact .runtime/portal ``` @@ -497,7 +527,8 @@ npm run verify:release-gate-report -- --artifact .runtime/portal └── partials/ ``` -当前执行器从本文解析并校验 187 个连续唯一编号。现有自动化 suite 已为 184 +当前执行器从本文解析并校验 187 个连续唯一编号。187 项是完整回归目录,不是常规发布 +必须逐项执行的固定数量。现有自动化 suite 已为 184 个场景提供逐项业务断言,仓库/候选检查另直接判定 `REL-01`、`REL-02` 和 `REL-04`,共 187 个场景都有自动判定能力。未执行 mode 仍为 `unknown`; 缺少 active 脱敏回归 fixture 时 `COMP-09` 明确失败;候选不等于 `origin/main` @@ -505,11 +536,12 @@ npm run verify:release-gate-report -- --artifact .runtime/portal 这是发布保护而不是测试故障。精确分组覆盖、命令和扩展方式见 [自动发布闸门实现说明](release-gate-automation.md)。 -`report.json` 至少包含: +常规风险分层 `report.json` 至少包含: ```json { "schema_version": 1, + "mode": "impact", "commit_sha": "", "branch": "main", "artifact_sha256": "", @@ -517,9 +549,22 @@ npm run verify:release-gate-report -- --artifact .runtime/portal "completed_at": "", "expires_at": "", "environment_fingerprint": "", + "selection": { + "policy_version": 1, + "strategy": "core|impact|full", + "base_commit": "<103 stable sha>", + "catalog_total": 187, + "selected_total": 32, + "core_ids": [], + "impact_groups": [], + "changed_paths": [], + "unmapped_paths": [], + "full_gate_reasons": [], + "selected_ids": [] + }, "summary": { - "required": 187, - "passed": 187, + "required": 32, + "passed": 32, "not_applicable": 0, "failed": 0, "skipped": 0, @@ -533,7 +578,14 @@ npm run verify:release-gate-report -- --artifact .runtime/portal } ``` -如果存在合法 `not_applicable`,`required` 仍为 187,且必须满足: +风险分层报告必须满足: + +```text +passed = required = selected_total +failed = skipped = blocked = unknown = cleanup_failed = 0 +``` + +完整 187 项报告如果存在合法 `not_applicable`,必须满足: ```text passed + not_applicable = required @@ -565,12 +617,11 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批 2. 获取完整 `main` SHA 和远端 CI 状态 3. 构建 Candidate artifact 4. 计算 artifact SHA256 -5. 读取对应 Gate report -6. 校验 commit、artifact、环境、有效期和场景汇总 -7. 校验全部 `not_applicable` 记录 +5. 读取对应 Gate report;不存在或过期时自动读取 103 稳定 commit 并执行 Core + Impact Gate +6. 从 Git diff 重算并校验 base commit、changed paths、影响域、selected IDs、artifact、环境和有效期 +7. 满足全量触发条件时确认报告覆盖完整 187 项 8. 执行 103 只读预检 -9. 请求本次发布的明确人工批准 -10. 批准后才允许上传 +9. 请求本次发布的明确人工批准,批准后才允许上传 以下情况必须直接退出: @@ -578,12 +629,17 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批 - 使用范围绕过变量 - report 不存在、过期或解析失败 - commit 或 artifact SHA 不一致 -- 任一场景失败、跳过、未知或清理失败 -- 豁免记录缺少审核信息 +- 任一被选场景失败、跳过、未知、未执行或清理失败 +- 影响选择结果不可重现、存在未处理路径,或应全量时报告不完整 +- 全量报告中的豁免记录缺少审核信息 - 103 有活动任务且未完成排空 - 备份、磁盘空间、数据库锁或回滚预检失败 -当前脚本已在任何 103 SSH、上传或切换之前执行 `verify-release-gate-report.mjs`,并硬拒绝 `--skip-tests`、`ALLOW_PORTAL_RELEASE_SCOPE_BYPASS` 和 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES`。Gate report 绑定 `.runtime/portal` 的排序目录树 SHA256;打包后的传输压缩包另行记录自身 SHA256。 +当前脚本会先尝试验证已有报告;报告缺失或过期时,只读获取 103 稳定 manifest 的 +`git_head` 并自动执行风险分层 Gate。任何 artifact 上传、runtime 切换或生产写操作前都必须 +再次通过 `verify-release-gate-report.mjs`,并硬拒绝 `--skip-tests`、 +`ALLOW_PORTAL_RELEASE_SCOPE_BYPASS` 和 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES`。Gate report +绑定 `.runtime/portal` 的排序目录树 SHA256;打包后的传输压缩包另行记录自身 SHA256。 ## 11. 发布后的最小保护 diff --git a/docs/release-canary-103.md b/docs/release-canary-103.md index 3153e4c..e674bee 100644 --- a/docs/release-canary-103.md +++ b/docs/release-canary-103.md @@ -51,7 +51,8 @@ LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于 ## 发布入口 候选必须来自干净且等于 `origin/main` 的完整 `main`,CI 成功,并具有与 -`.runtime/portal` 完全一致、未过期的 187 项 Gate report。 +`.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的风险分层 +Gate report;满足全量触发条件时报告必须覆盖完整 187 项。 ```bash bash scripts/release-portal-canary-prod.sh --yes @@ -59,7 +60,7 @@ bash scripts/release-portal-canary-prod.sh --yes 该脚本按顺序执行: -1. 验证源码、分支、远端主线、Gate report 和 artifact hash。 +1. 验证源码、分支、远端主线、artifact hash;缺少有效报告时自动执行 Core + Impact Gate。 2. 执行 103 只读预检。 3. 上传经过验证的候选包。 4. 创建稳定 live 全量备份、持久目录备份和 105 两份活动 nginx 配置备份,并校验完整性。 diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md index cc47501..00b175d 100644 --- a/docs/release-gate-automation.md +++ b/docs/release-gate-automation.md @@ -2,17 +2,21 @@ ## 当前状态 -自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验 187 个连续、唯一场景编号;执行器不能把没有实现、没有执行或没有证据的场景视为成功。 +自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验 +187 个连续、唯一场景编号,但常规发布只执行 16 项核心场景和 Git diff 选择的影响域。 +关键、共享或无法映射的运行时路径自动升级为完整 187 项;执行器不能把被选择但没有实现、 +没有执行或没有证据的场景视为成功。 已经实现: - `release-gate/catalog.mjs`:187 场景机器目录和永远不可豁免清单。 - `release-gate/safety.mjs`:拒绝生产 IP、`*.tkmind.cn`、生产路径和生产关联环境变量。 - `release-gate/artifact.mjs`:runtime 排序目录树 SHA256、依赖闭包和持久数据扫描。 +- `release-gate/impact.mjs`:Core Gate、changed-path 业务域映射、依赖闭包和全量触发规则。 - `release-gate/runner.mjs`:suite 执行、日志归档和失败关闭。 - `release-gate/report.mjs`:`report.json`、Markdown、JUnit、环境指纹及四小时有效期。 - `release-gate/regression-corpus.mjs`:脱敏生产回归 fixture 和 active manifest 校验。 -- `scripts/verify-release-gate-report.mjs`:校验完整 `main` SHA、artifact SHA、187 项状态和合法豁免。 +- `scripts/verify-release-gate-report.mjs`:校验完整 `main` SHA、artifact SHA,并从 Git diff 重算风险分层选择结果。 - `scripts/release-portal-runtime-prod.sh`:任何 103 连接或上传之前强制验证 Gate report。 当前已经有自动判定证据的场景如下。这里的“覆盖”必须同时满足 @@ -45,20 +49,25 @@ 分别执行生产配置冷启动和脱敏旧库升级,并断言运行前后 artifact 哈希不变;`REL-11` 在无网络 Linux ARM64 容器中实际加载 Portal、WeChat、worker 和每个 MCP 模块。 -全部 187 项都已有自动判定路径,未执行的 mode 仍保持 `unknown`。UI-01 至 UI-08 +全部 187 项都已有自动判定路径。完整 Gate 中未执行的 mode 仍保持 `unknown`;风险分层 +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`。 -当 runtime artifact SHA256 与已发布版本一致时,可使用增量 Gate: +常规发布使用风险分层 Gate: ```bash -node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal --deployed-commit <103-stable-sha> +node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit <103-stable-sha> ``` -增量 Gate 会复用基线 report 中的非 `REL-*` 场景,仅重跑发布相关场景。 -稳定发布脚本在完整 report 缺失或过期时会自动尝试增量 Gate。 +它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及 +依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、 +生产启动/发布脚本、Gate 自身或未映射运行时代码会自动选择完整 187 项。发布脚本在有效报告 +缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。 +离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置 +`MEMIND_RELEASE_BASE_COMMIT=`,未提供时安全退化为本地 Full Gate。 2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台 @@ -76,14 +85,22 @@ node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal --deplo npm run test:release-gate:unit ``` -构建候选 runtime 后运行完整闸门: +构建候选 runtime 后,普通发布运行风险分层闸门: ```bash npm run build:portal-runtime +node scripts/run-release-gate-impact.mjs \ + --artifact .runtime/portal \ + --deployed-commit <103-stable-sha> +``` + +高风险变更、选择器自身变化或定期回归运行完整闸门: + +```bash npm run test:release-gate:all -- --artifact .runtime/portal ``` -完整报告路径: +风险分层或完整报告路径: ```text .release-gate//report.json @@ -95,7 +112,7 @@ npm run test:release-gate:all -- --artifact .runtime/portal .release-gate//partials// ``` -只验证已有完整报告与当前 artifact: +只验证已有报告与当前 artifact: ```bash npm run verify:release-gate-report -- --artifact .runtime/portal @@ -116,11 +133,11 @@ npm run verify:release-gate-report -- --artifact .runtime/portal `RELEASE_GATE_SCENARIO_TIMEOUT_MS` 和 `RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS` 在 CI 资源范围内收紧预算,不得用它们 放宽为无限超时。 -- 完整闸门只为干净 `main` 的精确 commit + artifact 生成一次四小时有效报告; +- 风险分层或完整闸门只为干净 `main` 的精确 commit + artifact 生成一次四小时有效报告; 发布脚本复用并校验这份报告,不重复跑慢场景。commit、artifact 或环境指纹变化后 证据立即失效并重新执行。 -- 本地开发先跑相关确定性 suite;只有形成发布候选时才聚合全部 187 项。任何未执行、 - 超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 +- 本地开发先跑相关确定性 suite;普通发布候选执行 Core + Impact,完整 187 项用于高风险 + 候选和定期回归。任何被选择但未执行、超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 报告出现以下任意状态都会返回非零: @@ -129,7 +146,7 @@ npm run verify:release-gate-report -- --artifact .runtime/portal - `blocked` - `unknown` - `cleanup_failed` -- 缺少场景 +- 缺少被选择场景,或影响选择结果无法从 Git diff 重现 - 非法 `not_applicable` - commit、branch 或 artifact SHA 不匹配 - 报告超过四小时 @@ -172,8 +189,8 @@ scenarios/production-regressions/manifests/active.json 1. 检查干净、完整且不落后远端的 `main`。 2. 运行既有本地 verify。 3. 构建并检查 `.runtime/portal`。 -4. 验证与当前 commit 和 runtime tree SHA256 完全一致的完整 Gate report。 -5. Gate report 通过后才允许执行 103 只读预检。 +4. 验证与当前 commit 和 runtime tree SHA256 完全一致的风险分层或完整 Gate report;缺失时自动执行风险分层 Gate。 +5. Gate report 通过后才允许执行 103 只读预检和任何生产写操作。 6. 再次请求本次发布的明确人工确认。 7. 确认后才允许上传和切换。 diff --git a/docs/发包必看.md b/docs/发包必看.md index 96a446e..2e3f51b 100644 --- a/docs/发包必看.md +++ b/docs/发包必看.md @@ -45,7 +45,7 @@ npm run check:mindspace-public-links ## 2. Portal runtime 发布唯一流程 -生产发布守门员、完整 Gate report 和本次明确人工批准全部满足后,首次生产动作只使用: +生产发布守门员、风险分层或全量 Gate report 和本次明确人工批准全部满足后,首次生产动作只使用: ```bash bash scripts/release-portal-canary-prod.sh --yes diff --git a/release-gate/impact.mjs b/release-gate/impact.mjs new file mode 100644 index 0000000..ef1b792 --- /dev/null +++ b/release-gate/impact.mjs @@ -0,0 +1,172 @@ +export const CORE_SCENARIO_IDS = Object.freeze([ + 'REL-01', + 'REL-02', + 'REL-04', + 'REL-05', + 'REL-06', + 'REL-09', + 'REL-11', + 'AUTH-01', + 'AUTH-05', + 'CHAT-01', + 'CHAT-06', + 'CHAT-07', + 'AGENT-01', + 'AGENT-03', + 'MS-01', + 'COMP-09', +]); + +const FULL_GATE_PATHS = Object.freeze([ + /^(?:package|npm-shrinkwrap).*\.json$/i, + /^(?:pnpm-lock\.yaml|yarn\.lock)$/i, + /^(?:server\.mjs|schema\.sql)$/i, + /^(?:migrations?|database)\//i, + /^release-gate\//i, + /^scripts\/(?:build-portal-runtime|release-|run-release-gate|verify-release-gate|verify-canary-)/i, + /^scripts\/(?:run-memind-portal-prod|run-memind-portal-candidate|goosed.*compose)/i, + /^docs\/(?:production-release-guardian|release-gate-automation|release-canary-103)\.md$/i, + /^(?:PRODUCTION_RELEASE_RULES|ENGINEERING_WORKFLOW_RULES)\.md$/i, + /^\.github\/workflows\//i, + /^server\/portal-(?:access-policy|auth-services-bootstrap|auth-session-helpers|session-coordinator|gateway-services-bootstrap|integration-services-bootstrap)/i, + /^src\/(?:api\/core|config)\.[cm]?[jt]sx?$/i, + /^(?:tkmind-proxy|session-stream|session-stream-store|deepseek-no-think-proxy)\.mjs$/i, +]); + +const NON_RUNTIME_PATHS = Object.freeze([ + /^(?:AGENTS|README|CHANGELOG)\.md$/i, + /^docs\//i, + /^\.cursor\//i, + /^\.codex\//i, + /^scripts\/dev(?:-|\.|\/)/i, + /^scripts\/.*(?:test|fixture|mock).*\.[cm]?[jt]s$/i, + /\.(?:test|spec)\.[cm]?[jt]sx?$/i, + /^(?:eslint|prettier|tsconfig|components)\..*$/i, + /^openapi\.json$/i, +]); + +const IMPACT_RULES = Object.freeze([ + { groups: ['MEM'], pattern: /(?:^|\/)(?:memory|episodic)[^/]*|memory-v2/i }, + { groups: ['DATA'], pattern: /(?:page-data|dataset|page-policy)/i }, + { groups: ['WX'], pattern: /(?:wechat|weixin|wx-)/i }, + { groups: ['BILL'], pattern: /(?:billing|payment|charge|balance|subscription)/i }, + { groups: ['PLAZA'], pattern: /(?:^|\/)plaza/i }, + { 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: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i }, + { groups: ['MS'], pattern: /mindspace/i }, + { groups: ['PAGE'], pattern: /(?:public-page|published-page|publication|page-delivery|mindspace-public)/i }, + { groups: ['AGENT'], pattern: /(?:agent|goosed|worker|aider|mcp)/i }, + { groups: ['CHAT'], pattern: /(?:chat|conversation|message|sse|routing|intent)/i }, + { groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i }, + { groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i }, + { groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i }, +]); + +const GROUP_DEPENDENCIES = Object.freeze({ + MEM: ['CHAT'], + DATA: ['AUTH', 'PAGE', 'MS'], + WX: ['AUTH', 'CHAT'], + BILL: ['AUTH', 'CHAT'], + PLAZA: ['AUTH'], + SEARCH: ['CHAT'], + XLS: ['FILE'], + IMGPG: ['FILE', 'PAGE'], + FILE: ['AUTH', 'CHAT'], + MS: ['PAGE'], + PAGE: ['FILE', 'MS'], + AGENT: ['CHAT', 'CFG'], +}); + +function normalizePaths(paths) { + return [...new Set(paths.map((item) => String(item).replaceAll('\\', '/')).filter(Boolean))].sort(); +} + +function matchesAny(patterns, relativePath) { + return patterns.some((pattern) => pattern.test(relativePath)); +} + +function closeGroupDependencies(initialGroups) { + const groups = new Set(initialGroups); + const pending = [...groups]; + while (pending.length > 0) { + const group = pending.shift(); + for (const dependency of GROUP_DEPENDENCIES[group] ?? []) { + if (groups.has(dependency)) continue; + groups.add(dependency); + pending.push(dependency); + } + } + return [...groups].sort(); +} + +export function selectImpactScenarios({ + catalog, + changedPaths, + forceFullReasons = [], +}) { + const normalizedPaths = normalizePaths(changedPaths); + const catalogIds = new Set(catalog.map((scenario) => scenario.id)); + const missingCore = CORE_SCENARIO_IDS.filter((id) => !catalogIds.has(id)); + if (missingCore.length > 0) { + throw new Error(`Core release scenarios are missing from the catalog: ${missingCore.join(',')}`); + } + + const fullGateReasons = [...forceFullReasons]; + const directGroups = new Set(); + const unmappedPaths = []; + + for (const relativePath of normalizedPaths) { + if (matchesAny(FULL_GATE_PATHS, relativePath)) { + fullGateReasons.push(`critical_path:${relativePath}`); + continue; + } + if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue; + + let matched = false; + for (const rule of IMPACT_RULES) { + if (!rule.pattern.test(relativePath)) continue; + matched = true; + for (const group of rule.groups) directGroups.add(group); + } + if (matched) continue; + + unmappedPaths.push(relativePath); + fullGateReasons.push(`unmapped_runtime_path:${relativePath}`); + } + + const impactGroups = closeGroupDependencies(directGroups); + const strategy = fullGateReasons.length > 0 + ? 'full' + : impactGroups.length > 0 + ? 'impact' + : 'core'; + const selected = new Set(CORE_SCENARIO_IDS); + + if (strategy === 'full') { + for (const scenario of catalog) selected.add(scenario.id); + } else { + for (const scenario of catalog) { + if (impactGroups.includes(scenario.group)) selected.add(scenario.id); + } + } + + const selectedIds = catalog + .map((scenario) => scenario.id) + .filter((id) => selected.has(id)); + + return { + policy_version: 1, + strategy, + catalog_total: catalog.length, + core_ids: [...CORE_SCENARIO_IDS], + impact_groups: impactGroups, + changed_paths: normalizedPaths, + unmapped_paths: unmappedPaths, + full_gate_reasons: [...new Set(fullGateReasons)].sort(), + selected_ids: selectedIds, + selected_total: selectedIds.length, + }; +} diff --git a/release-gate/impact.test.mjs b/release-gate/impact.test.mjs new file mode 100644 index 0000000..dde4037 --- /dev/null +++ b/release-gate/impact.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { loadScenarioCatalog } from './catalog.mjs'; +import { CORE_SCENARIO_IDS, selectImpactScenarios } from './impact.mjs'; + +test('docs-only changes use the compact core gate', async () => { + const catalog = await loadScenarioCatalog(); + const selection = selectImpactScenarios({ + catalog, + changedPaths: ['AGENTS.md', 'docs/local-dev.md'], + }); + assert.equal(selection.strategy, 'core'); + assert.deepEqual(selection.selected_ids, catalog + .map((scenario) => scenario.id) + .filter((id) => CORE_SCENARIO_IDS.includes(id))); + assert.equal(selection.unmapped_paths.length, 0); +}); + +test('domain changes select the domain and dependency closure', async () => { + const catalog = await loadScenarioCatalog(); + const selection = selectImpactScenarios({ + catalog, + changedPaths: ['memory-v2-lifecycle.mjs'], + }); + assert.equal(selection.strategy, 'impact'); + assert.deepEqual(selection.impact_groups, ['CHAT', 'MEM']); + assert.equal(selection.selected_ids.includes('MEM-16'), true); + assert.equal(selection.selected_ids.includes('CHAT-15'), true); + assert.equal(selection.selected_ids.includes('BILL-07'), false); +}); + +test('critical and unmapped runtime paths fail closed to the full gate', async () => { + const catalog = await loadScenarioCatalog(); + const critical = selectImpactScenarios({ + catalog, + changedPaths: ['server.mjs'], + }); + assert.equal(critical.strategy, 'full'); + assert.equal(critical.selected_total, catalog.length); + assert.deepEqual(critical.full_gate_reasons, ['critical_path:server.mjs']); + + const unknown = selectImpactScenarios({ + catalog, + changedPaths: ['new-runtime-kernel.mjs'], + }); + assert.equal(unknown.strategy, 'full'); + assert.deepEqual(unknown.unmapped_paths, ['new-runtime-kernel.mjs']); + assert.equal(unknown.selected_total, catalog.length); +}); diff --git a/release-gate/release-script.test.mjs b/release-gate/release-script.test.mjs index 8c7c36c..43919ce 100644 --- a/release-gate/release-script.test.mjs +++ b/release-gate/release-script.test.mjs @@ -27,7 +27,7 @@ function assertShellParses(source, label) { assert.equal(result.status, 0, `${label} failed bash -n:\n${result.stderr}`); } -test('production release verifies gate report before any 103 connection', async () => { +test('production release verifies gate report before 103 preflight and upload', async () => { const source = await fs.readFile( path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'), 'utf8', @@ -49,10 +49,10 @@ test('production stable release verifies canary promotion evidence before gate c ); const promotionIndex = source.indexOf('verify-canary-promotion-evidence.mjs'); const gateIndex = source.indexOf('verify-release-gate-report.mjs'); - const incrementalIndex = source.indexOf('run-release-gate-incremental.mjs'); + const impactIndex = source.indexOf('run-release-gate-impact.mjs'); assert.ok(promotionIndex > 0, 'missing canary promotion evidence verifier'); assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence'); - assert.ok(incrementalIndex > 0, 'missing incremental gate fallback'); + assert.ok(impactIndex > 0, 'missing risk-based impact gate fallback'); assert.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/); assert.match(source, /read_agent_run_status_json/); assert.match(source, /sed -n '\/\^\{/); @@ -140,6 +140,9 @@ test('production canary verifies the exact Gate artifact before any 103 prefligh assert.match(source, /branch.*!= "main"/); assert.match(source, /rev-parse origin\/main/); assert.match(source, /Production canary release forbids/); + 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/); }); test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => { diff --git a/release-gate/report.mjs b/release-gate/report.mjs index 7955980..f2d0bb5 100644 --- a/release-gate/report.mjs +++ b/release-gate/report.mjs @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { expectedScenarioIds, isScenarioExemptable } from './catalog.mjs'; +import { CORE_SCENARIO_IDS } from './impact.mjs'; const TERMINAL_STATUSES = new Set([ 'passed', @@ -67,9 +68,10 @@ export function createGateReport({ completedAt = new Date(), maxAgeMs = 4 * 60 * 60 * 1000, environment = createEnvironmentFingerprint(), + selection = null, }) { const summary = summarizeScenarios(scenarios); - return { + const report = { schema_version: 1, mode, commit_sha: commitSha, @@ -88,6 +90,8 @@ export function createGateReport({ .map((scenario) => scenario.exemption), approved_for_release: false, }; + if (selection) report.selection = selection; + return report; } function validateExemption(scenario, commitSha, errors) { @@ -118,6 +122,76 @@ function validateExemption(scenario, commitSha, errors) { } } +function sameValues(left, right) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + const leftSorted = [...left].sort(); + const rightSorted = [...right].sort(); + return leftSorted.every((value, index) => value === rightSorted[index]); +} + +function validateImpactSelection(report, scenarioIds, errors) { + const selection = report?.selection; + if (!selection || typeof selection !== 'object') { + errors.push('impact report is missing selection metadata'); + return; + } + + if (selection.policy_version !== 1) errors.push('impact policy_version must be 1'); + if (!['core', 'impact', 'full'].includes(selection.strategy)) { + errors.push('impact strategy must be core, impact, or full'); + } + if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) { + errors.push('impact base_commit must be a full SHA'); + } + if (selection.catalog_total !== expectedScenarioIds().length) { + errors.push(`impact catalog_total must be ${expectedScenarioIds().length}`); + } + for (const field of [ + 'core_ids', + 'impact_groups', + 'changed_paths', + 'unmapped_paths', + 'full_gate_reasons', + 'selected_ids', + ]) { + if (!Array.isArray(selection[field])) errors.push(`impact ${field} must be an array`); + } + if (!sameValues(selection.core_ids, CORE_SCENARIO_IDS)) { + errors.push('impact core_ids do not match the release policy'); + } + if (!sameValues(selection.selected_ids, scenarioIds)) { + errors.push('impact selected_ids do not match report scenarios'); + } + if (selection.selected_total !== scenarioIds.length) { + errors.push('impact selected_total does not match report scenarios'); + } + const missingCore = CORE_SCENARIO_IDS.filter((id) => !scenarioIds.includes(id)); + if (missingCore.length > 0) { + errors.push(`impact report is missing core scenarios: ${missingCore.join(',')}`); + } + + if (selection.strategy === 'core' && selection.impact_groups?.length > 0) { + errors.push('core impact report cannot contain impact groups'); + } + if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) { + errors.push('impact report must contain at least one impact group'); + } + if (selection.strategy !== 'full' && selection.unmapped_paths?.length > 0) { + errors.push('unmapped runtime paths require a full Gate'); + } + if (selection.strategy === 'full') { + if (!sameValues(scenarioIds, expectedScenarioIds())) { + errors.push('full impact strategy must execute the complete catalog'); + } + if (selection.full_gate_reasons?.length === 0) { + errors.push('full impact strategy is missing its reason'); + } + } else if (selection.full_gate_reasons?.length > 0) { + errors.push('non-full impact strategy cannot contain full Gate reasons'); + } +} + export function validateGateReport(report, { expectedCommit, expectedArtifactSha256, @@ -128,8 +202,8 @@ export function validateGateReport(report, { } = {}) { const errors = []; if (report?.schema_version !== 1) errors.push('schema_version must be 1'); - if (report?.mode !== 'all' && report?.mode !== 'incremental' && requireFullCatalog) { - errors.push('release report mode must be all or incremental'); + if (!['all', 'incremental', 'impact'].includes(report?.mode) && requireFullCatalog) { + errors.push('release report mode must be all, incremental, or impact'); } if (report?.mode === 'incremental') { const baseline = report?.baseline; @@ -174,7 +248,8 @@ export function validateGateReport(report, { } else { const ids = report.scenarios.map((scenario) => scenario.id); const unique = new Set(ids); - if (requireFullCatalog) { + const fullCatalogRequired = requireFullCatalog && report.mode !== 'impact'; + if (fullCatalogRequired) { const expected = expectedScenarioIds(); const expectedSet = new Set(expected); if (ids.length !== expected.length || unique.size !== expected.length) { @@ -185,6 +260,7 @@ export function validateGateReport(report, { if (missing.length) errors.push(`report is missing scenarios: ${missing.join(',')}`); if (unexpected.length) errors.push(`report has unexpected scenarios: ${unexpected.join(',')}`); } + if (report.mode === 'impact') validateImpactSelection(report, ids, errors); for (const scenario of report.scenarios) { if (!TERMINAL_STATUSES.has(scenario.status)) { @@ -192,7 +268,11 @@ export function validateGateReport(report, { continue; } if (scenario.status === 'not_applicable') { - validateExemption(scenario, report.commit_sha, errors); + if (report.mode === 'impact') { + errors.push(`${scenario.id} must execute when selected by the impact Gate`); + } else { + validateExemption(scenario, report.commit_sha, errors); + } } else if (scenario.status !== 'passed') { errors.push(`${scenario.id} is ${scenario.status}`); } @@ -232,6 +312,14 @@ function renderMarkdown(report) { `- Artifact SHA256: \`${report.artifact_sha256}\``, `- Completed: ${report.completed_at}`, `- Expires: ${report.expires_at}`, + ...(report.selection + ? [ + `- Strategy: ${report.selection.strategy}`, + `- Base commit: \`${report.selection.base_commit}\``, + `- Selected: ${report.selection.selected_total}/${report.selection.catalog_total}`, + `- Impact groups: ${report.selection.impact_groups.join(', ') || 'none'}`, + ] + : []), '', '| Result | Count |', '|---|---:|', diff --git a/release-gate/report.test.mjs b/release-gate/report.test.mjs index b671596..6a207af 100644 --- a/release-gate/report.test.mjs +++ b/release-gate/report.test.mjs @@ -6,6 +6,7 @@ import { createGateReport, validateGateReport, } from './report.mjs'; +import { selectImpactScenarios } from './impact.mjs'; const COMMIT = 'a'.repeat(40); const ARTIFACT = 'b'.repeat(64); @@ -110,3 +111,81 @@ test('expired report or artifact mismatch is rejected', async () => { assert.match(result.errors.join('\n'), /artifact_sha256 does not match/); assert.match(result.errors.join('\n'), /report has expired/); }); + +test('impact report accepts only the selected core and affected scenarios', async () => { + const catalog = await loadScenarioCatalog(); + const selection = { + ...selectImpactScenarios({ + catalog, + changedPaths: ['memory-v2-lifecycle.mjs'], + }), + base_commit: 'c'.repeat(40), + }; + const selected = new Set(selection.selected_ids); + const report = createGateReport({ + commitSha: COMMIT, + branch: 'main', + artifactSha256: ARTIFACT, + artifact: { path: '.runtime/portal', kind: 'directory-tree' }, + mode: 'impact', + selection, + scenarios: catalog + .filter((scenario) => selected.has(scenario.id)) + .map((scenario) => ({ + id: scenario.id, + name: scenario.name, + status: 'passed', + cleanup_status: 'not_required', + evidence: ['fixture'], + })), + completedAt: new Date('2026-07-26T10:00:00.000Z'), + }); + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.deepEqual(result, { valid: true, errors: [] }); + assert.ok(report.summary.required < catalog.length); +}); + +test('impact report rejects missing core coverage and unmapped non-full paths', async () => { + const catalog = await loadScenarioCatalog(); + const selection = { + ...selectImpactScenarios({ + catalog, + changedPaths: ['docs/local-dev.md'], + }), + base_commit: 'c'.repeat(40), + }; + selection.unmapped_paths = ['unknown-runtime.mjs']; + selection.selected_ids = selection.selected_ids.filter((id) => id !== 'AUTH-05'); + selection.selected_total = selection.selected_ids.length; + const selected = new Set(selection.selected_ids); + const report = createGateReport({ + commitSha: COMMIT, + branch: 'main', + artifactSha256: ARTIFACT, + artifact: { path: '.runtime/portal', kind: 'directory-tree' }, + mode: 'impact', + selection, + scenarios: catalog + .filter((scenario) => selected.has(scenario.id)) + .map((scenario) => ({ + id: scenario.id, + name: scenario.name, + status: 'passed', + cleanup_status: 'not_required', + evidence: ['fixture'], + })), + completedAt: new Date('2026-07-26T10:00:00.000Z'), + }); + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /missing core scenarios: AUTH-05/); + assert.match(result.errors.join('\n'), /unmapped runtime paths require a full Gate/); +}); diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs index bd47965..18d202f 100644 --- a/release-gate/runner.mjs +++ b/release-gate/runner.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './artifact.mjs'; import { loadScenarioCatalog } from './catalog.mjs'; import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs'; +import { selectImpactScenarios } from './impact.mjs'; import { loadActiveRegressionCorpus } from './regression-corpus.mjs'; import { buildIncrementalReport, @@ -17,7 +18,16 @@ import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs'; import { resolveReleaseCiStatus } from './ci-status.mjs'; const ROOT = path.resolve(new URL('..', import.meta.url).pathname); -const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all', 'incremental']); +const MODES = new Set([ + 'deterministic', + 'scenarios', + 'browser', + 'providers', + 'upgrade', + 'all', + 'impact', + 'incremental', +]); async function git(...args) { const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 }); @@ -233,8 +243,10 @@ async function applyRepositoryChecks(results, artifactPath) { if (!artifactPath) return null; const artifact = await hashArtifact(artifactPath); const rel03 = byId.get('REL-03'); - rel03.reason = 'repeat_build_comparison_not_implemented'; - rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`); + if (rel03) { + rel03.reason = 'repeat_build_comparison_not_implemented'; + rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`); + } const inspection = await inspectPortalRuntime(artifactPath); const rel04 = byId.get('REL-04'); @@ -324,6 +336,129 @@ export async function executeReleaseGate(options) { return { report, outputDir }; } +export async function listChangedPathsBetween(baseCommit, candidateCommit = 'HEAD') { + if (!/^[0-9a-f]{40}$/i.test(baseCommit ?? '')) { + throw new Error('deployed commit must be a full SHA'); + } + const exists = await runCommand( + 'git', + ['cat-file', '-e', `${baseCommit}^{commit}`], + { cwd: ROOT, timeoutMs: 30_000 }, + ); + if (exists.code !== 0) { + throw new Error(`deployed commit is not available locally: ${baseCommit}`); + } + const diff = await runCommand( + 'git', + ['diff', '--name-only', '-z', `${baseCommit}..${candidateCommit}`], + { cwd: ROOT, timeoutMs: 30_000 }, + ); + if (diff.code !== 0) { + throw new Error(`cannot calculate release impact from ${baseCommit} to ${candidateCommit}`); + } + return diff.stdout + .split('\0') + .filter(Boolean); +} + +export async function executeImpactReleaseGate(options) { + const startedAt = new Date(); + assertSafeGateEnvironment({ targets: [options.portalBase] }); + assertSafePortalBase(options.portalBase); + if (!options.artifact) { + throw new Error('impact release gate requires --artifact'); + } + options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); + if (!options.deployedCommit) { + return executeReleaseGate({ ...options, mode: 'all' }); + } + + 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 changedPaths = await listChangedPathsBetween(options.deployedCommit, commitSha); + const ancestry = await runCommand( + 'git', + ['merge-base', '--is-ancestor', options.deployedCommit, commitSha], + { cwd: ROOT, timeoutMs: 30_000 }, + ); + const selection = { + ...selectImpactScenarios({ + catalog, + changedPaths, + forceFullReasons: ancestry.code === 0 + ? [] + : [`deployed_commit_not_ancestor:${options.deployedCommit}`], + }), + base_commit: options.deployedCommit, + }; + const selectedIds = new Set(selection.selected_ids); + const results = catalog + .filter((scenario) => selectedIds.has(scenario.id)) + .map(scenarioResult); + const byId = new Map(results.map((result) => [result.id, result])); + const artifact = await applyRepositoryChecks(results, options.artifact); + + if (selectedIds.has('COMP-09')) { + const regressionCorpus = await loadActiveRegressionCorpus({ root: ROOT, catalog }); + const comp09 = byId.get('COMP-09'); + comp09.reason = regressionCorpus.status === 'ready' + ? 'production_regression_replay_not_implemented' + : `production_regression_corpus_${regressionCorpus.status}`; + comp09.evidence.push( + `manifest=${path.relative(ROOT, regressionCorpus.manifestPath)}`, + `fixtures=${regressionCorpus.fixtures.length}`, + ...regressionCorpus.errors.map((error) => `error=${error}`), + ); + } + + const suites = AUTOMATION_SUITES.filter( + (suite) => suite.scenarios.some((scenarioId) => selectedIds.has(scenarioId)), + ); + const executions = await runSuitesWithConcurrency( + suites, + options.suiteConcurrency, + (suite) => runSuite(suite, outputDir, options.timeoutMs), + ); + for (let index = 0; index < suites.length; index += 1) { + const suite = suites[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( + `suite=${suite.id}`, + `log=${execution.logPath}`, + ...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`), + ); + } + } + + const completedAt = new Date(); + const report = createGateReport({ + commitSha, + branch, + artifactSha256: artifact.sha256, + artifact: { + path: path.relative(ROOT, options.artifact), + kind: artifact.kind, + files: artifact.files, + bytes: artifact.bytes, + }, + scenarios: results, + mode: 'impact', + selection, + startedAt, + completedAt, + }); + await writeGateReport(report, outputDir); + return { report, outputDir, selection }; +} + export async function executeIncrementalReleaseGate(options) { const startedAt = new Date(); assertSafeGateEnvironment({ targets: [options.portalBase] }); diff --git a/scripts/release-portal-canary-prod.sh b/scripts/release-portal-canary-prod.sh index c06b4e7..3afa553 100755 --- a/scripts/release-portal-canary-prod.sh +++ b/scripts/release-portal-canary-prod.sh @@ -141,12 +141,28 @@ required_runtime_paths=( ) for relative in "${required_runtime_paths[@]}"; do [[ -e "${RUNTIME_ROOT}/${relative}" ]] || { - echo "Candidate runtime is missing ${relative}; rebuild and rerun the complete Gate." >&2 + echo "Candidate runtime is missing ${relative}; rebuild and rerun the risk-based Gate." >&2 exit 1 } done -say "Verify the complete Gate report against the exact candidate artifact" +say "Verify the risk-based Gate report against the exact candidate artifact" +if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then + say "Run the core + changed-impact Gate" + DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}" + if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then + DEPLOYED_SHA="$( + ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \ + "grep -E '^git_head=' '${STABLE_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \ + 2>/dev/null || true + )" + fi + IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}") + if [[ -n "${DEPLOYED_SHA}" ]]; then + IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}") + fi + node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}" +fi node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" say "Package the verified candidate artifact" diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 4638ff3..cc7f46e 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -337,17 +337,20 @@ REMOTE 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}") + say "执行核心场景 + 变更影响域 Gate" + DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}" + if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then + 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 + )" fi - node "${ROOT}/scripts/run-release-gate-incremental.mjs" "${INCREMENTAL_ARGS[@]}" + IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}") + if [[ -n "${DEPLOYED_SHA}" ]]; then + IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}") + fi + node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}" fi node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" diff --git a/scripts/run-release-gate-impact.mjs b/scripts/run-release-gate-impact.mjs new file mode 100644 index 0000000..e25601c --- /dev/null +++ b/scripts/run-release-gate-impact.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { executeImpactReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs'; + +function usage() { + console.log(`Usage: + node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit + +Runs the compact production core Gate plus scenarios selected from the Git diff. +Critical, shared, or unmapped runtime changes automatically expand to the full catalog.`); +} + +try { + const options = parseRunnerArgs(process.argv); + if (options.help) { + usage(); + process.exit(0); + } + options.mode = 'impact'; + const { report, outputDir, selection } = await executeImpactReleaseGate(options); + const summary = report.summary; + console.log(`Risk-based release gate report: ${outputDir}`); + console.log(`mode=${report.mode}`); + if (selection) { + console.log(`strategy=${selection.strategy}`); + console.log(`selected=${selection.selected_total}/${selection.catalog_total}`); + console.log(`impact_groups=${selection.impact_groups.join(',') || 'none'}`); + console.log(`changed_paths=${selection.changed_paths.length}`); + } else { + console.log('strategy=full'); + console.log('reason=deployed_commit_unavailable'); + } + 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.required; + process.exit(passed ? 0 : 1); +} catch (error) { + console.error(`Risk-based release gate failed: ${error.message}`); + process.exit(1); +} diff --git a/scripts/verify-release-gate-report.mjs b/scripts/verify-release-gate-report.mjs index ac21d7a..1dfed00 100644 --- a/scripts/verify-release-gate-report.mjs +++ b/scripts/verify-release-gate-report.mjs @@ -3,8 +3,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs'; +import { loadScenarioCatalog } from '../release-gate/catalog.mjs'; +import { selectImpactScenarios } from '../release-gate/impact.mjs'; import { validateGateReport } from '../release-gate/report.mjs'; -import { runCommand } from '../release-gate/runner.mjs'; +import { listChangedPathsBetween, runCommand } from '../release-gate/runner.mjs'; const ROOT = path.resolve(new URL('..', import.meta.url).pathname); @@ -28,6 +30,22 @@ async function git(...args) { return result.stdout.trim(); } +function comparableSelection(selection) { + return { + policy_version: selection.policy_version, + strategy: selection.strategy, + catalog_total: selection.catalog_total, + core_ids: selection.core_ids, + impact_groups: selection.impact_groups, + changed_paths: selection.changed_paths, + unmapped_paths: selection.unmapped_paths, + full_gate_reasons: selection.full_gate_reasons, + selected_ids: selection.selected_ids, + selected_total: selection.selected_total, + base_commit: selection.base_commit, + }; +} + try { const options = parseArgs(process.argv); options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); @@ -43,6 +61,39 @@ try { expectedArtifactSha256: artifact.sha256, expectedBranch: 'main', }); + if (report.mode === 'impact' && /^[0-9a-f]{40}$/i.test(report?.selection?.base_commit ?? '')) { + try { + const [catalog, changedPaths, ancestry] = await Promise.all([ + loadScenarioCatalog({ root: ROOT }), + listChangedPathsBetween(report.selection.base_commit, commitSha), + runCommand( + 'git', + ['merge-base', '--is-ancestor', report.selection.base_commit, commitSha], + { cwd: ROOT, timeoutMs: 30_000 }, + ), + ]); + const expectedSelection = { + ...selectImpactScenarios({ + catalog, + changedPaths, + forceFullReasons: ancestry.code === 0 + ? [] + : [`deployed_commit_not_ancestor:${report.selection.base_commit}`], + }), + base_commit: report.selection.base_commit, + }; + if ( + JSON.stringify(comparableSelection(report.selection)) + !== JSON.stringify(comparableSelection(expectedSelection)) + ) { + validation.errors.push('impact selection does not match the candidate Git diff'); + validation.valid = false; + } + } catch (error) { + validation.errors.push(`impact selection cannot be reproduced: ${error.message}`); + validation.valid = false; + } + } if (!validation.valid) { const blocking = report.scenarios.filter( (scenario) => !['passed', 'not_applicable'].includes(scenario.status),