refactor: make production gate impact only
Memind CI / Test, build, and release guards (push) Has been cancelled

This commit is contained in:
john
2026-07-28 01:13:28 +08:00
parent 864197162d
commit 7a381db132
14 changed files with 195 additions and 98 deletions
+2 -2
View File
@@ -14,7 +14,7 @@
## 必读:分支与发布闸门 ## 必读:分支与发布闸门
生产 `103` 发布还必须完整遵守 [docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档维护 187 个完整回归场景族;常规发布执行 16 项核心场景加自动影响域场景,高风险或无法识别影响范围时自动升级为全量 Gate。不得手工删减选择结果或绕过 Gate report 硬阻断。 生产 `103` 发布还必须完整遵守 [docs/production-release-guardian.md](docs/production-release-guardian.md)。该文档维护 187 个回归场景族作为审计目录;生产发布执行 16 项核心场景加自动影响域场景,不再自动执行 187 项。关键共享路径必须展开到预定义影响域,无法识别影响范围时直接阻断并先补充映射。不得手工删减选择结果或绕过 Gate report 硬阻断。
1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx` 1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`
2. 在“修复 bug / 开发中”阶段,默认只允许本地修改、本地运行、本地测试;**没有用户明确批准,不允许 `git push`、不允许生成或发布任何 `103` 相关 runtime/artifact、不允许合并或并入 `main`、不允许触发任何生产动作。** 2. 在“修复 bug / 开发中”阶段,默认只允许本地修改、本地运行、本地测试;**没有用户明确批准,不允许 `git push`、不允许生成或发布任何 `103` 相关 runtime/artifact、不允许合并或并入 `main`、不允许触发任何生产动作。**
@@ -32,7 +32,7 @@
bash scripts/check-release-ready.sh bash scripts/check-release-ready.sh
``` ```
12. `check-release-ready.sh` 只是源码闸门,不代表业务场景闸门通过;生产发布还必须取得与同一 commit、同一 runtime artifact 绑定、且可从 Git diff 重现选择结果的风险分层或全量 Gate report。 12. `check-release-ready.sh` 只是源码闸门,不代表业务场景闸门通过;生产发布还必须取得与同一 commit、同一 runtime artifact 绑定、且可从 Git diff 重现选择结果的 Core + Impact Gate report。
## 必读:历史分支处置登记 ## 必读:历史分支处置登记
+1 -1
View File
@@ -67,7 +67,7 @@ bash scripts/check-release-ready.sh
``` ```
10. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。 10. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。
11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md)Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact。常规发布执行 Core + 自动影响域,高风险或未映射变更自动升级为完整 187 项所有被选场景成功后仍须取得明确人工批准。 11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md)Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact。生产发布固定执行 Core + 自动影响域;关键共享路径展开到预定义影响域,未映射变更直接阻断并要求先补映射,不再自动执行完整 187 项所有被选场景成功后仍须取得明确人工批准。
## 5. 文档约束 ## 5. 文档约束
+2 -2
View File
@@ -20,6 +20,6 @@
12. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 12. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。
13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
14. 每次生产发布必须生成与完整 `main` commit 和 runtime artifact SHA256 绑定的 Gate report。常规发布执行 16 项核心场景加 Git diff 自动选择的影响域场景;所有被选择场景必须真实执行并满足 `failed=0``skipped=0``blocked=0``unknown=0``cleanup_failed=0` 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 15. 187 项保留为回归审计目录,不进入生产发布脚本。鉴权、数据库、runtime 构建、依赖、共享入口发布闸门自身变化时,影响选择器必须展开到预定义业务域;任何未映射的运行时代码直接阻断发布,先补映射再生成报告
16. 风险分层报告必须记录线上基线 commit、changed paths、核心场景、影响域、最终选择结果和选择策略;发布校验器必须从 Git diff 重新计算并核对,禁止人工删减选择结果。正常风险分层 Gate 不使用逐项 `not_applicable`,被选择的场景必须通过。 16. 风险分层报告必须记录线上基线 commit、changed paths、核心场景、影响域、最终选择结果和选择策略;发布校验器必须从 Git diff 重新计算并核对,禁止人工删减选择结果。正常风险分层 Gate 不使用逐项 `not_applicable`,被选择的场景必须通过。
17. 完整 187 项 Gate 用于高风险发布、选择器或发布规则变化、无有效基线、灰度/生产失败后的重新验证,以及定期回归。即使 Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 17. 完整 187 项仅保留为人工或定期审计命令,不作为生产发布前置条件,也不得由生产发布脚本自动触发。无有效基线、基线非候选祖先、选择器无法重现或灰度失败时直接阻断;即使 Core + Impact Gate 全绿,也不能把整包替换脚本当作灰度发布入口。
+18 -21
View File
@@ -2,14 +2,14 @@
> 适用目标:生产 `103` Portal runtime 及其共享链路。 > 适用目标:生产 `103` Portal runtime 及其共享链路。
> 规则状态:已确认为生产发布的强制验收规范。 > 规则状态:已确认为生产发布的强制验收规范。
> 当前实施状态:187 场景机器目录保留为完整回归资产;常规生产发布使用 16 项 Core Gate 加 Git diff 自动选择的影响域 Gate。关键共享路径、无法映射的运行时代码或高风险变更自动升级为完整 187 项 Gate。任何被选择但未实现、未执行或证据不完整的场景保持 `unknown` 并阻断发布。 > 当前实施状态:187 场景机器目录保留为回归审计资产;生产发布使用 16 项 Core Gate 加 Git diff 自动选择的影响域 Gate,不再自动执行完整 187 项。关键共享路径展开到预定义影响域;无法映射的运行时代码、无有效基线或不可重现的选择结果直接阻断发布。任何被选择但未实现、未执行或证据不完整的场景保持 `unknown` 并阻断发布。
## 1. 目标 ## 1. 目标
本守门员将生产发布从“运行若干测试后人工判断”升级为绑定同一源码和同一产物的可审计硬闸门: 本守门员将生产发布从“运行若干测试后人工判断”升级为绑定同一源码和同一产物的可审计硬闸门:
1. 只有完整、干净、可追溯的 `main` 可以成为候选版本。 1. 只有完整、干净、可追溯的 `main` 可以成为候选版本。
2. 同一个 `main` commit 构建出的同一个 runtime artifact,必须完成 Core Gate 和影响选择器选出的全部场景;满足全量触发条件时执行完整 187 项。 2. 同一个 `main` commit 构建出的同一个 runtime artifact,必须完成 Core Gate 和影响选择器选出的全部场景;生产发布脚本不执行完整 187 项。
3. 结果必须满足 `failed=0``skipped=0``cleanup_failed=0` 3. 结果必须满足 `failed=0``skipped=0``cleanup_failed=0`
4. 常规风险分层报告不要求为未选场景逐项填写 `not_applicable`;选择器必须记录 changed paths、影响域和选择证据,被选中的场景不能豁免。 4. 常规风险分层报告不要求为未选场景逐项填写 `not_applicable`;选择器必须记录 changed paths、影响域和选择证据,被选中的场景不能豁免。
5. 即使机器闸门全部通过,仍须取得用户针对“发布 103”动作本身的明确批准。 5. 即使机器闸门全部通过,仍须取得用户针对“发布 103”动作本身的明确批准。
@@ -24,7 +24,7 @@
| Scenario family | 本文定义的一条业务或运行时场景;可包含多个参数化 case | | Scenario family | 本文定义的一条业务或运行时场景;可包含多个参数化 case |
| Core Gate | 每次生产候选都必须执行的 16 项最小安全闭包 | | Core Gate | 每次生产候选都必须执行的 16 项最小安全闭包 |
| Impact Gate | 根据线上 commit 到候选 commit 的 Git diff 自动选择的业务域场景 | | Impact Gate | 根据线上 commit 到候选 commit 的 Git diff 自动选择的业务域场景 |
| Full Gate | 高风险、共享入口或影响范围不明时执行的完整 187 项回归 | | Catalog audit | 人工或定期执行的完整 187 项回归资产,不是生产发布前置条件 |
| Deterministic case | 不依赖真实外部模型输出、可以稳定重复的单元或集成测试 | | Deterministic case | 不依赖真实外部模型输出、可以稳定重复的单元或集成测试 |
| Live canary | 在隔离或预生产环境调用真实 LLM、图片、搜索等服务的最小验证 | | Live canary | 在隔离或预生产环境调用真实 LLM、图片、搜索等服务的最小验证 |
| `not_applicable` | 有完整影响证明并经审核后,确认本次发布不适用的场景状态 | | `not_applicable` | 有完整影响证明并经审核后,确认本次发布不适用的场景状态 |
@@ -43,7 +43,6 @@
- Core Gate 和影响选择器选出的全部场景均为 `passed` - Core Gate 和影响选择器选出的全部场景均为 `passed`
- 没有 `failed``skipped``blocked``unknown` 或未执行场景。 - 没有 `failed``skipped``blocked``unknown` 或未执行场景。
- 风险分层报告的 base commit、changed paths、影响域和 selected IDs 能够从 Git diff 重算且完全一致。 - 风险分层报告的 base commit、changed paths、影响域和 selected IDs 能够从 Git diff 重算且完全一致。
- 全量报告如果使用 `not_applicable`,所有记录均具有有效的影响评估和审核信息。
- 测试数据和临时资源清理完成。 - 测试数据和临时资源清理完成。
- Gate report 未过期;默认有效期不超过 4 小时。 - Gate report 未过期;默认有效期不超过 4 小时。
- 103 只读预检通过。 - 103 只读预检通过。
@@ -66,17 +65,17 @@ Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base co
commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。 commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。
正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable` 正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`
### 3.3 自动升级为 Full Gate ### 3.3 关键路径与阻断规则
以下情况必须执行完整 187 项 生产选择器不再升级为完整 187 项,按以下规则处理
- `server.mjs`、鉴权/会话基础设施、数据库 schema/migration、runtime 构建和生产启动脚本变化。 - `server.mjs`、鉴权/会话基础设施、数据库 schema/migration、runtime 构建、依赖、
- 依赖清单或 lockfile、goosed/worker/代理、公共配置和发布闸门自身变化。 goosed/worker/代理、公共配置和发布闸门自身变化时,展开到版本化规则声明的影响域
- 影响选择器无法映射任一运行时代码路径。 - 影响选择器无法映射任一运行时代码路径时直接失败,必须先提交映射与测试
- 线上 base commit 不可用、不是候选祖先,或选择结果无法从 Git diff 重现。 - 线上 base commit 不可用、不是候选祖先,或选择结果无法从 Git diff 重现时直接失败
- 上次灰度或生产发布失败,或明确要求完整回归 - 上次灰度或生产发布失败时先回滚并修复;新的候选重新执行 Core + Impact
### 3.4 Full Gate 中永远不可豁免的场景 ### 3.4 回归目录中永远不可豁免的场景
下列场景不允许标记为 `not_applicable` 下列场景不允许标记为 `not_applicable`
@@ -95,7 +94,7 @@ commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则
- `CFG-08` 模型配置兼容场景。 - `CFG-08` 模型配置兼容场景。
- `COMP-08``COMP-09` - `COMP-08``COMP-09`
### 3.5 Full Gate 中 `not_applicable` 的判定 ### 3.5 人工目录审计中 `not_applicable` 的判定
只有同时满足以下条件,才允许将场景标记为 `not_applicable` 只有同时满足以下条件,才允许将场景标记为 `not_applicable`
@@ -550,8 +549,8 @@ npm run verify:release-gate-report -- --artifact .runtime/portal
"expires_at": "<ISO-8601>", "expires_at": "<ISO-8601>",
"environment_fingerprint": "<sha256>", "environment_fingerprint": "<sha256>",
"selection": { "selection": {
"policy_version": 1, "policy_version": 2,
"strategy": "core|impact|full", "strategy": "core|impact",
"base_commit": "<103 stable sha>", "base_commit": "<103 stable sha>",
"catalog_total": 187, "catalog_total": 187,
"selected_total": 32, "selected_total": 32,
@@ -585,7 +584,7 @@ passed = required = selected_total
failed = skipped = blocked = unknown = cleanup_failed = 0 failed = skipped = blocked = unknown = cleanup_failed = 0
``` ```
完整 187 项报告如果存在合法 `not_applicable`,必须满足: 人工或定期目录审计如果存在合法 `not_applicable`,必须满足:
```text ```text
passed + not_applicable = required passed + not_applicable = required
@@ -619,9 +618,8 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批
4. 计算 artifact SHA256 4. 计算 artifact SHA256
5. 读取对应 Gate report;不存在或过期时自动读取 103 稳定 commit 并执行 Core + Impact Gate 5. 读取对应 Gate report;不存在或过期时自动读取 103 稳定 commit 并执行 Core + Impact Gate
6. 从 Git diff 重算并校验 base commit、changed paths、影响域、selected IDs、artifact、环境和有效期 6. 从 Git diff 重算并校验 base commit、changed paths、影响域、selected IDs、artifact、环境和有效期
7. 满足全量触发条件时确认报告覆盖完整 187 项 7. 执行 103 只读预检
8. 执行 103 只读预检 8. 请求本次发布的明确人工批准,批准后才允许上传
9. 请求本次发布的明确人工批准,批准后才允许上传
以下情况必须直接退出: 以下情况必须直接退出:
@@ -630,8 +628,7 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批
- report 不存在、过期或解析失败 - report 不存在、过期或解析失败
- commit 或 artifact SHA 不一致 - commit 或 artifact SHA 不一致
- 任一被选场景失败、跳过、未知、未执行或清理失败 - 任一被选场景失败、跳过、未知、未执行或清理失败
- 影响选择结果不可重现存在未处理路径,或应全量时报告不完整 - 影响选择结果不可重现存在未映射路径
- 全量报告中的豁免记录缺少审核信息
- 103 有活动任务且未完成排空 - 103 有活动任务且未完成排空
- 备份、磁盘空间、数据库锁或回滚预检失败 - 备份、磁盘空间、数据库锁或回滚预检失败
+2 -2
View File
@@ -51,8 +51,8 @@ LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于
## 发布入口 ## 发布入口
候选必须来自干净且等于 `origin/main` 的完整 `main`CI 成功,并具有与 候选必须来自干净且等于 `origin/main` 的完整 `main`CI 成功,并具有与
`.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的风险分层 `.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的 Core + Impact
Gate report;满足全量触发条件时报告必须覆盖完整 187 项 Gate report。关键路径展开到预定义影响域;未映射路径或无效基线直接阻断
```bash ```bash
bash scripts/release-portal-canary-prod.sh --yes bash scripts/release-portal-canary-prod.sh --yes
+8 -8
View File
@@ -4,15 +4,15 @@
自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验 自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验
187 个连续、唯一场景编号,但常规发布只执行 16 项核心场景和 Git diff 选择的影响域。 187 个连续、唯一场景编号,但常规发布只执行 16 项核心场景和 Git diff 选择的影响域。
关键、共享或无法映射的运行时路径自动升级为完整 187 项;执行器不能把被选择但没有实现、 关键、共享路径展开到预定义影响域;无法映射的运行时路径直接阻断并要求先补映射。
没有执行或没有证据的场景视为成功。 生产发布不再自动执行完整 187 项,执行器不能把被选择但没有实现、没有执行或没有证据的场景视为成功。
已经实现: 已经实现:
- `release-gate/catalog.mjs`:187 场景机器目录和永远不可豁免清单。 - `release-gate/catalog.mjs`:187 场景机器目录和永远不可豁免清单。
- `release-gate/safety.mjs`:拒绝生产 IP、`*.tkmind.cn`、生产路径和生产关联环境变量。 - `release-gate/safety.mjs`:拒绝生产 IP、`*.tkmind.cn`、生产路径和生产关联环境变量。
- `release-gate/artifact.mjs`runtime 排序目录树 SHA256、依赖闭包和持久数据扫描。 - `release-gate/artifact.mjs`runtime 排序目录树 SHA256、依赖闭包和持久数据扫描。
- `release-gate/impact.mjs`Core Gate、changed-path 业务域映射、依赖闭包和全量触发规则。 - `release-gate/impact.mjs`Core Gate、changed-path 业务域映射、依赖闭包和未映射阻断规则。
- `release-gate/runner.mjs`:suite 执行、日志归档和失败关闭。 - `release-gate/runner.mjs`:suite 执行、日志归档和失败关闭。
- `release-gate/report.mjs``report.json`、Markdown、JUnit、环境指纹及四小时有效期。 - `release-gate/report.mjs``report.json`、Markdown、JUnit、环境指纹及四小时有效期。
- `release-gate/regression-corpus.mjs`:脱敏生产回归 fixture 和 active manifest 校验。 - `release-gate/regression-corpus.mjs`:脱敏生产回归 fixture 和 active manifest 校验。
@@ -67,10 +67,10 @@ node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-c
它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及 它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及
依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、 依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、
生产启动/发布脚本Gate 自身或未映射运行时代码会自动选择完整 187 项。发布脚本在有效报告 生产启动/发布脚本Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。发布脚本在有效报告
缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。 缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。
离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置 离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
`MEMIND_RELEASE_BASE_COMMIT=<known-stable-sha>`未提供时安全退化为本地 Full Gate `MEMIND_RELEASE_BASE_COMMIT=<known-stable-sha>`未提供有效基线时直接阻断
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台 分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台
@@ -97,7 +97,7 @@ node scripts/run-release-gate-impact.mjs \
--deployed-commit <103-stable-sha> --deployed-commit <103-stable-sha>
``` ```
高风险变更、选择器自身变化或定期回归运行完整闸门 人工或定期目录审计仍可运行完整闸门,但生产发布脚本不会调用它
```bash ```bash
npm run test:release-gate:all -- --artifact .runtime/portal npm run test:release-gate:all -- --artifact .runtime/portal
@@ -139,8 +139,8 @@ npm run verify:release-gate-report -- --artifact .runtime/portal
- 风险分层或完整闸门只为干净 `main` 的精确 commit + artifact 生成一次四小时有效报告; - 风险分层或完整闸门只为干净 `main` 的精确 commit + artifact 生成一次四小时有效报告;
发布脚本复用并校验这份报告,不重复跑慢场景。commit、artifact 或环境指纹变化后 发布脚本复用并校验这份报告,不重复跑慢场景。commit、artifact 或环境指纹变化后
证据立即失效并重新执行。 证据立即失效并重新执行。
- 本地开发先跑相关确定性 suite普通发布候选执行 Core + Impact完整 187 项用于高风险 - 本地开发先跑相关确定性 suite生产候选固定执行 Core + Impact完整 187 项用于人工
候选和定期回归。任何被选择但未执行、超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 或定期审计。任何被选择但未执行、超时或缺证据项仍保持 `unknown/failed` 并阻断发布。
报告出现以下任意状态都会返回非零: 报告出现以下任意状态都会返回非零:
+1 -1
View File
@@ -45,7 +45,7 @@ npm run check:mindspace-public-links
## 2. Portal runtime 发布唯一流程 ## 2. Portal runtime 发布唯一流程
生产发布守门员、风险分层或全量 Gate report 和本次明确人工批准全部满足后,首次生产动作只使用: 生产发布守门员、Core + Impact Gate report 和本次明确人工批准全部满足后,首次生产动作只使用:
```bash ```bash
bash scripts/release-portal-canary-prod.sh --yes bash scripts/release-portal-canary-prod.sh --yes
+57 -37
View File
@@ -17,24 +17,44 @@ export const CORE_SCENARIO_IDS = Object.freeze([
'COMP-09', 'COMP-09',
]); ]);
const FULL_GATE_PATHS = Object.freeze([ const CRITICAL_IMPACT_RULES = Object.freeze([
/^(?:package|npm-shrinkwrap).*\.json$/i, {
/^(?:pnpm-lock\.yaml|yarn\.lock)$/i, groups: ['AGENT', 'CFG', 'UI'],
/^(?:server\.mjs|schema\.sql)$/i, pattern: /^(?:(?:package|npm-shrinkwrap).*\.json|pnpm-lock\.yaml|yarn\.lock)$/i,
/^(?:migrations?|database)\//i, },
/^release-gate\//i, {
/^scripts\/(?:build-portal-runtime|release-|run-release-gate|verify-release-gate|verify-canary-)/i, groups: ['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA'],
/^scripts\/(?:run-memind-portal-prod|run-memind-portal-candidate|goosed.*compose)/i, pattern: /^(?:server\.mjs|schema\.sql|(?:migrations?|database)\/)/i,
/^docs\/(?:production-release-guardian|release-gate-automation|release-canary-103)\.md$/i, },
/^(?:PRODUCTION_RELEASE_RULES|ENGINEERING_WORKFLOW_RULES)\.md$/i, {
/^\.github\/workflows\//i, groups: ['CFG', 'REL'],
/^server\/portal-(?:access-policy|auth-services-bootstrap|auth-session-helpers|session-coordinator|gateway-services-bootstrap|integration-services-bootstrap)/i, pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|run-release-gate|verify-release-gate|verify-canary-))/i,
/^src\/(?:api\/core|config)\.[cm]?[jt]sx?$/i, },
/^(?:tkmind-proxy|session-stream|session-stream-store|deepseek-no-think-proxy)\.mjs$/i, {
groups: ['AGENT', 'CFG', 'REL'],
pattern: /^scripts\/(?:run-memind-portal-prod|run-memind-portal-candidate|goosed.*compose)/i,
},
{
groups: ['CFG', 'REL'],
pattern: /^(?:docs\/(?:production-release-guardian|release-gate-automation|release-canary-103)\.md|(?:PRODUCTION_RELEASE_RULES|ENGINEERING_WORKFLOW_RULES)\.md|\.github\/workflows\/)/i,
},
{
groups: ['AGENT', 'AUTH', 'CFG', 'CHAT', 'WX'],
pattern: /^server\/portal-/i,
},
{
groups: ['AUTH', 'CFG', 'UI'],
pattern: /^src\/(?:api\/core|config)\.[cm]?[jt]sx?$/i,
},
{
groups: ['AGENT', 'CFG', 'CHAT'],
pattern: /^(?:tkmind-proxy|session-stream|session-stream-store|deepseek-no-think-proxy)\.mjs$/i,
},
]); ]);
const NON_RUNTIME_PATHS = Object.freeze([ const NON_RUNTIME_PATHS = Object.freeze([
/^(?:AGENTS|README|CHANGELOG)\.md$/i, /^(?:AGENTS|README|CHANGELOG)\.md$/i,
/^\.runtime\//i,
/^docs\//i, /^docs\//i,
/^\.cursor\//i, /^\.cursor\//i,
/^\.codex\//i, /^\.codex\//i,
@@ -57,11 +77,12 @@ const IMPACT_RULES = Object.freeze([
{ groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i }, { groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i },
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i }, { groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
{ groups: ['MS'], pattern: /mindspace/i }, { groups: ['MS'], pattern: /mindspace/i },
{ groups: ['PAGE'], pattern: /(?:public-page|published-page|publication|page-delivery|mindspace-public)/i }, { groups: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
{ groups: ['AGENT'], pattern: /(?:agent|goosed|worker|aider|mcp)/i }, { groups: ['AGENT'], pattern: /(?:agent|capabilit|goosed|worker|aider|mcp)/i },
{ groups: ['CHAT'], pattern: /(?:chat|conversation|message|sse|routing|intent)/i }, { groups: ['CHAT'], pattern: /(?:chat|conversation|message|reply|session|sse|routing|intent)/i },
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i }, { groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
{ groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i }, { groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i },
{ groups: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i },
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i }, { groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
]); ]);
@@ -105,7 +126,7 @@ function closeGroupDependencies(initialGroups) {
export function selectImpactScenarios({ export function selectImpactScenarios({
catalog, catalog,
changedPaths, changedPaths,
forceFullReasons = [], blockingReasons = [],
}) { }) {
const normalizedPaths = normalizePaths(changedPaths); const normalizedPaths = normalizePaths(changedPaths);
const catalogIds = new Set(catalog.map((scenario) => scenario.id)); const catalogIds = new Set(catalog.map((scenario) => scenario.id));
@@ -114,18 +135,21 @@ export function selectImpactScenarios({
throw new Error(`Core release scenarios are missing from the catalog: ${missingCore.join(',')}`); throw new Error(`Core release scenarios are missing from the catalog: ${missingCore.join(',')}`);
} }
const fullGateReasons = [...forceFullReasons]; if (blockingReasons.length > 0) {
throw new Error(`Impact selection is blocked: ${blockingReasons.join(',')}`);
}
const directGroups = new Set(); const directGroups = new Set();
const unmappedPaths = []; const unmappedPaths = [];
for (const relativePath of normalizedPaths) { for (const relativePath of normalizedPaths) {
if (matchesAny(FULL_GATE_PATHS, relativePath)) { let matched = false;
fullGateReasons.push(`critical_path:${relativePath}`); for (const rule of CRITICAL_IMPACT_RULES) {
continue; if (!rule.pattern.test(relativePath)) continue;
matched = true;
for (const group of rule.groups) directGroups.add(group);
} }
if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue; if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue;
let matched = false;
for (const rule of IMPACT_RULES) { for (const rule of IMPACT_RULES) {
if (!rule.pattern.test(relativePath)) continue; if (!rule.pattern.test(relativePath)) continue;
matched = true; matched = true;
@@ -134,23 +158,19 @@ export function selectImpactScenarios({
if (matched) continue; if (matched) continue;
unmappedPaths.push(relativePath); unmappedPaths.push(relativePath);
fullGateReasons.push(`unmapped_runtime_path:${relativePath}`);
} }
if (unmappedPaths.length > 0) {
throw new Error(
`Impact selection has unmapped runtime paths: ${unmappedPaths.join(',')}`,
);
}
const impactGroups = closeGroupDependencies(directGroups); const impactGroups = closeGroupDependencies(directGroups);
const strategy = fullGateReasons.length > 0 const strategy = impactGroups.length > 0 ? 'impact' : 'core';
? 'full'
: impactGroups.length > 0
? 'impact'
: 'core';
const selected = new Set(CORE_SCENARIO_IDS); const selected = new Set(CORE_SCENARIO_IDS);
if (strategy === 'full') { for (const scenario of catalog) {
for (const scenario of catalog) selected.add(scenario.id); if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
} else {
for (const scenario of catalog) {
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
}
} }
const selectedIds = catalog const selectedIds = catalog
@@ -158,14 +178,14 @@ export function selectImpactScenarios({
.filter((id) => selected.has(id)); .filter((id) => selected.has(id));
return { return {
policy_version: 1, policy_version: 2,
strategy, strategy,
catalog_total: catalog.length, catalog_total: catalog.length,
core_ids: [...CORE_SCENARIO_IDS], core_ids: [...CORE_SCENARIO_IDS],
impact_groups: impactGroups, impact_groups: impactGroups,
changed_paths: normalizedPaths, changed_paths: normalizedPaths,
unmapped_paths: unmappedPaths, unmapped_paths: unmappedPaths,
full_gate_reasons: [...new Set(fullGateReasons)].sort(), full_gate_reasons: [],
selected_ids: selectedIds, selected_ids: selectedIds,
selected_total: selectedIds.length, selected_total: selectedIds.length,
}; };
+41 -11
View File
@@ -30,21 +30,51 @@ test('domain changes select the domain and dependency closure', async () => {
assert.equal(selection.selected_ids.includes('BILL-07'), false); assert.equal(selection.selected_ids.includes('BILL-07'), false);
}); });
test('critical and unmapped runtime paths fail closed to the full gate', async () => { test('critical paths expand mapped domains and unmapped paths block release', async () => {
const catalog = await loadScenarioCatalog(); const catalog = await loadScenarioCatalog();
const critical = selectImpactScenarios({ const critical = selectImpactScenarios({
catalog, catalog,
changedPaths: ['server.mjs'], changedPaths: ['server.mjs'],
}); });
assert.equal(critical.strategy, 'full'); assert.equal(critical.policy_version, 2);
assert.equal(critical.selected_total, catalog.length); assert.equal(critical.strategy, 'impact');
assert.deepEqual(critical.full_gate_reasons, ['critical_path:server.mjs']); assert.ok(critical.selected_total < catalog.length);
assert.deepEqual(
critical.impact_groups,
['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA', 'FILE', 'MS', 'PAGE'],
);
assert.deepEqual(critical.full_gate_reasons, []);
const unknown = selectImpactScenarios({ assert.throws(
catalog, () => selectImpactScenarios({
changedPaths: ['new-runtime-kernel.mjs'], catalog,
}); changedPaths: ['new-runtime-kernel.mjs'],
assert.equal(unknown.strategy, 'full'); }),
assert.deepEqual(unknown.unmapped_paths, ['new-runtime-kernel.mjs']); /unmapped runtime paths: new-runtime-kernel\.mjs/,
assert.equal(unknown.selected_total, catalog.length); );
assert.throws(
() => selectImpactScenarios({
catalog,
changedPaths: [],
blockingReasons: ['deployed_commit_not_ancestor:abc'],
}),
/Impact selection is blocked/,
);
});
test('release policy changes use mapped REL and CFG domains without selecting the catalog', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: [
'release-gate/impact.mjs',
'scripts/release-portal-canary-prod.sh',
'.runtime/portal/server.mjs',
],
});
assert.equal(selection.strategy, 'impact');
assert.deepEqual(selection.impact_groups, ['CFG', 'REL']);
assert.ok(selection.selected_total < catalog.length);
assert.equal(selection.changed_paths.includes('.runtime/portal/server.mjs'), true);
}); });
+18 -5
View File
@@ -137,9 +137,18 @@ function validateImpactSelection(report, scenarioIds, errors) {
return; return;
} }
if (selection.policy_version !== 1) errors.push('impact policy_version must be 1'); if (![1, 2].includes(selection.policy_version)) {
if (!['core', 'impact', 'full'].includes(selection.strategy)) { errors.push('impact policy_version must be 1 or 2');
errors.push('impact strategy must be core, impact, or full'); }
const allowedStrategies = selection.policy_version === 2
? ['core', 'impact']
: ['core', 'impact', 'full'];
if (!allowedStrategies.includes(selection.strategy)) {
errors.push(
selection.policy_version === 2
? 'impact strategy must be core or impact'
: 'impact strategy must be core, impact, or full',
);
} }
if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) { if (!/^[0-9a-f]{40}$/i.test(selection.base_commit ?? '')) {
errors.push('impact base_commit must be a full SHA'); errors.push('impact base_commit must be a full SHA');
@@ -177,8 +186,12 @@ function validateImpactSelection(report, scenarioIds, errors) {
if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) { if (selection.strategy === 'impact' && selection.impact_groups?.length === 0) {
errors.push('impact report must contain at least one impact group'); errors.push('impact report must contain at least one impact group');
} }
if (selection.strategy !== 'full' && selection.unmapped_paths?.length > 0) { if (selection.unmapped_paths?.length > 0) {
errors.push('unmapped runtime paths require a full Gate'); errors.push(
selection.policy_version === 2
? 'unmapped runtime paths block the impact Gate'
: 'unmapped runtime paths require a full Gate',
);
} }
if (selection.strategy === 'full') { if (selection.strategy === 'full') {
if (!sameValues(scenarioIds, expectedScenarioIds())) { if (!sameValues(scenarioIds, expectedScenarioIds())) {
+40 -1
View File
@@ -187,5 +187,44 @@ test('impact report rejects missing core coverage and unmapped non-full paths',
}); });
assert.equal(result.valid, false); assert.equal(result.valid, false);
assert.match(result.errors.join('\n'), /missing core scenarios: AUTH-05/); assert.match(result.errors.join('\n'), /missing core scenarios: AUTH-05/);
assert.match(result.errors.join('\n'), /unmapped runtime paths require a full Gate/); assert.match(result.errors.join('\n'), /unmapped runtime paths block the impact Gate/);
});
test('policy version 2 rejects a full production impact strategy', async () => {
const catalog = await loadScenarioCatalog();
const selection = {
...selectImpactScenarios({
catalog,
changedPaths: ['memory-v2-lifecycle.mjs'],
}),
strategy: 'full',
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.equal(result.valid, false);
assert.match(result.errors.join('\n'), /impact strategy must be core or impact/);
}); });
+2 -2
View File
@@ -372,7 +372,7 @@ export async function executeImpactReleaseGate(options) {
} }
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
if (!options.deployedCommit) { if (!options.deployedCommit) {
return executeReleaseGate({ ...options, mode: 'all' }); throw new Error('impact release gate requires --deployed-commit');
} }
const catalog = await loadScenarioCatalog({ root: ROOT }); const catalog = await loadScenarioCatalog({ root: ROOT });
@@ -390,7 +390,7 @@ export async function executeImpactReleaseGate(options) {
...selectImpactScenarios({ ...selectImpactScenarios({
catalog, catalog,
changedPaths, changedPaths,
forceFullReasons: ancestry.code === 0 blockingReasons: ancestry.code === 0
? [] ? []
: [`deployed_commit_not_ancestor:${options.deployedCommit}`], : [`deployed_commit_not_ancestor:${options.deployedCommit}`],
}), }),
+2 -4
View File
@@ -6,7 +6,8 @@ function usage() {
node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit <sha> node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-commit <sha>
Runs the compact production core Gate plus scenarios selected from the Git diff. 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.`); Critical and shared paths expand their mapped impact domains. Unmapped paths or
an invalid production baseline block release until the impact map is updated.`);
} }
try { try {
@@ -25,9 +26,6 @@ try {
console.log(`selected=${selection.selected_total}/${selection.catalog_total}`); console.log(`selected=${selection.selected_total}/${selection.catalog_total}`);
console.log(`impact_groups=${selection.impact_groups.join(',') || 'none'}`); console.log(`impact_groups=${selection.impact_groups.join(',') || 'none'}`);
console.log(`changed_paths=${selection.changed_paths.length}`); console.log(`changed_paths=${selection.changed_paths.length}`);
} else {
console.log('strategy=full');
console.log('reason=deployed_commit_unavailable');
} }
console.log(JSON.stringify(summary)); console.log(JSON.stringify(summary));
const passed = summary.failed === 0 const passed = summary.failed === 0
+1 -1
View File
@@ -76,7 +76,7 @@ try {
...selectImpactScenarios({ ...selectImpactScenarios({
catalog, catalog,
changedPaths, changedPaths,
forceFullReasons: ancestry.code === 0 blockingReasons: ancestry.code === 0
? [] ? []
: [`deployed_commit_not_ancestor:${report.selection.base_commit}`], : [`deployed_commit_not_ancestor:${report.selection.base_commit}`],
}), }),