diff --git a/AGENTS.md b/AGENTS.md index 4e1a912..7c99d77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 2. 在“修复 bug / 开发中”阶段,默认只允许本地修改、本地运行、本地测试;**没有用户明确批准,不允许 `git push`、不允许生成或发布任何 `103` 相关 runtime/artifact、不允许合并或并入 `main`、不允许触发任何生产动作。** @@ -32,7 +32,7 @@ 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。 ## 必读:历史分支处置登记 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md index 51e6f30..9767d91 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。常规发布执行 Core + 自动影响域,高风险或未映射变更自动升级为完整 187 项;所有被选场景成功后仍须取得明确人工批准。 +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 5ee3c54..c033283 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -20,6 +20,6 @@ 12. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 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`,被选择的场景必须通过。 -17. 完整 187 项 Gate 用于高风险发布、选择器或发布规则变化、无有效基线、灰度/生产失败后的重新验证,以及定期回归。即使 Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 +17. 完整 187 项仅保留为人工或定期审计命令,不作为生产发布前置条件,也不得由生产发布脚本自动触发。无有效基线、基线非候选祖先、选择器无法重现或灰度失败时直接阻断;即使 Core + Impact Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md index 1a5c7b7..504c60b 100644 --- a/docs/production-release-guardian.md +++ b/docs/production-release-guardian.md @@ -2,14 +2,14 @@ > 适用目标:生产 `103` Portal runtime 及其共享链路。 > 规则状态:已确认为生产发布的强制验收规范。 -> 当前实施状态:187 场景机器目录保留为完整回归资产;常规生产发布使用 16 项 Core Gate 加 Git diff 自动选择的影响域 Gate。关键共享路径、无法映射的运行时代码或高风险变更自动升级为完整 187 项 Gate。任何被选择但未实现、未执行或证据不完整的场景保持 `unknown` 并阻断发布。 +> 当前实施状态:187 场景机器目录保留为回归审计资产;生产发布使用 16 项 Core Gate 加 Git diff 自动选择的影响域 Gate,不再自动执行完整 187 项。关键共享路径展开到预定义影响域;无法映射的运行时代码、无有效基线或不可重现的选择结果直接阻断发布。任何被选择但未实现、未执行或证据不完整的场景保持 `unknown` 并阻断发布。 ## 1. 目标 本守门员将生产发布从“运行若干测试后人工判断”升级为绑定同一源码和同一产物的可审计硬闸门: 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`。 4. 常规风险分层报告不要求为未选场景逐项填写 `not_applicable`;选择器必须记录 changed paths、影响域和选择证据,被选中的场景不能豁免。 5. 即使机器闸门全部通过,仍须取得用户针对“发布 103”动作本身的明确批准。 @@ -24,7 +24,7 @@ | Scenario family | 本文定义的一条业务或运行时场景;可包含多个参数化 case | | Core Gate | 每次生产候选都必须执行的 16 项最小安全闭包 | | Impact Gate | 根据线上 commit 到候选 commit 的 Git diff 自动选择的业务域场景 | -| Full Gate | 高风险、共享入口或影响范围不明时执行的完整 187 项回归 | +| Catalog audit | 人工或定期执行的完整 187 项回归资产,不是生产发布前置条件 | | Deterministic case | 不依赖真实外部模型输出、可以稳定重复的单元或集成测试 | | Live canary | 在隔离或预生产环境调用真实 LLM、图片、搜索等服务的最小验证 | | `not_applicable` | 有完整影响证明并经审核后,确认本次发布不适用的场景状态 | @@ -43,7 +43,6 @@ - Core Gate 和影响选择器选出的全部场景均为 `passed`。 - 没有 `failed`、`skipped`、`blocked`、`unknown` 或未执行场景。 - 风险分层报告的 base commit、changed paths、影响域和 selected IDs 能够从 Git diff 重算且完全一致。 -- 全量报告如果使用 `not_applicable`,所有记录均具有有效的影响评估和审核信息。 - 测试数据和临时资源清理完成。 - Gate report 未过期;默认有效期不超过 4 小时。 - 103 只读预检通过。 @@ -66,17 +65,17 @@ Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base co commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。 正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`。 -### 3.3 自动升级为 Full Gate +### 3.3 关键路径与阻断规则 -以下情况必须执行完整 187 项: +生产选择器不再升级为完整 187 项,按以下规则处理: -- `server.mjs`、鉴权/会话基础设施、数据库 schema/migration、runtime 构建和生产启动脚本变化。 -- 依赖清单或 lockfile、goosed/worker/代理、公共配置和发布闸门自身变化。 -- 影响选择器无法映射任一运行时代码路径。 -- 线上 base commit 不可用、不是候选祖先,或选择结果无法从 Git diff 重现。 -- 上次灰度或生产发布失败,或明确要求完整回归。 +- `server.mjs`、鉴权/会话基础设施、数据库 schema/migration、runtime 构建、依赖、 + goosed/worker/代理、公共配置和发布闸门自身变化时,展开到版本化规则声明的影响域。 +- 影响选择器无法映射任一运行时代码路径时直接失败,必须先提交映射与测试。 +- 线上 base commit 不可用、不是候选祖先,或选择结果无法从 Git diff 重现时直接失败。 +- 上次灰度或生产发布失败时先回滚并修复;新的候选重新执行 Core + Impact。 -### 3.4 Full Gate 中永远不可豁免的场景 +### 3.4 回归目录中永远不可豁免的场景 下列场景不允许标记为 `not_applicable`: @@ -95,7 +94,7 @@ commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则 - `CFG-08` 模型配置兼容场景。 - `COMP-08`、`COMP-09`。 -### 3.5 Full Gate 中 `not_applicable` 的判定 +### 3.5 人工目录审计中 `not_applicable` 的判定 只有同时满足以下条件,才允许将场景标记为 `not_applicable`: @@ -550,8 +549,8 @@ npm run verify:release-gate-report -- --artifact .runtime/portal "expires_at": "", "environment_fingerprint": "", "selection": { - "policy_version": 1, - "strategy": "core|impact|full", + "policy_version": 2, + "strategy": "core|impact", "base_commit": "<103 stable sha>", "catalog_total": 187, "selected_total": 32, @@ -585,7 +584,7 @@ passed = required = selected_total failed = skipped = blocked = unknown = cleanup_failed = 0 ``` -完整 187 项报告如果存在合法 `not_applicable`,必须满足: +人工或定期目录审计如果存在合法 `not_applicable`,必须满足: ```text passed + not_applicable = required @@ -619,9 +618,8 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批 4. 计算 artifact SHA256 5. 读取对应 Gate report;不存在或过期时自动读取 103 稳定 commit 并执行 Core + Impact Gate 6. 从 Git diff 重算并校验 base commit、changed paths、影响域、selected IDs、artifact、环境和有效期 -7. 满足全量触发条件时确认报告覆盖完整 187 项 -8. 执行 103 只读预检 -9. 请求本次发布的明确人工批准,批准后才允许上传 +7. 执行 103 只读预检 +8. 请求本次发布的明确人工批准,批准后才允许上传 以下情况必须直接退出: @@ -630,8 +628,7 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批 - report 不存在、过期或解析失败 - commit 或 artifact SHA 不一致 - 任一被选场景失败、跳过、未知、未执行或清理失败 -- 影响选择结果不可重现、存在未处理路径,或应全量时报告不完整 -- 全量报告中的豁免记录缺少审核信息 +- 影响选择结果不可重现或存在未映射路径 - 103 有活动任务且未完成排空 - 备份、磁盘空间、数据库锁或回滚预检失败 diff --git a/docs/release-canary-103.md b/docs/release-canary-103.md index e674bee..d0fb879 100644 --- a/docs/release-canary-103.md +++ b/docs/release-canary-103.md @@ -51,8 +51,8 @@ LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于 ## 发布入口 候选必须来自干净且等于 `origin/main` 的完整 `main`,CI 成功,并具有与 -`.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的风险分层 -Gate report;满足全量触发条件时报告必须覆盖完整 187 项。 +`.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的 Core + Impact +Gate report。关键路径展开到预定义影响域;未映射路径或无效基线直接阻断。 ```bash bash scripts/release-portal-canary-prod.sh --yes diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md index de7ae66..4758a37 100644 --- a/docs/release-gate-automation.md +++ b/docs/release-gate-automation.md @@ -4,15 +4,15 @@ 自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验 187 个连续、唯一场景编号,但常规发布只执行 16 项核心场景和 Git diff 选择的影响域。 -关键、共享或无法映射的运行时路径自动升级为完整 187 项;执行器不能把被选择但没有实现、 -没有执行或没有证据的场景视为成功。 +关键、共享路径展开到预定义影响域;无法映射的运行时路径直接阻断并要求先补映射。 +生产发布不再自动执行完整 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/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 校验。 @@ -67,10 +67,10 @@ 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 自身或未映射运行时代码会自动选择完整 187 项。发布脚本在有效报告 +生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。发布脚本在有效报告 缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。 离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置 -`MEMIND_RELEASE_BASE_COMMIT=`,未提供时安全退化为本地 Full Gate。 +`MEMIND_RELEASE_BASE_COMMIT=`;未提供有效基线时直接阻断。 2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台 @@ -97,7 +97,7 @@ node scripts/run-release-gate-impact.mjs \ --deployed-commit <103-stable-sha> ``` -高风险变更、选择器自身变化或定期回归运行完整闸门: +人工或定期目录审计仍可运行完整闸门,但生产发布脚本不会调用它: ```bash 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 生成一次四小时有效报告; 发布脚本复用并校验这份报告,不重复跑慢场景。commit、artifact 或环境指纹变化后 证据立即失效并重新执行。 -- 本地开发先跑相关确定性 suite;普通发布候选执行 Core + Impact,完整 187 项用于高风险 - 候选和定期回归。任何被选择但未执行、超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 +- 本地开发先跑相关确定性 suite;生产候选固定执行 Core + Impact。完整 187 项仅用于人工 + 或定期审计。任何被选择但未执行、超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 报告出现以下任意状态都会返回非零: diff --git a/docs/发包必看.md b/docs/发包必看.md index 2e3f51b..2d130fb 100644 --- a/docs/发包必看.md +++ b/docs/发包必看.md @@ -45,7 +45,7 @@ npm run check:mindspace-public-links ## 2. Portal runtime 发布唯一流程 -生产发布守门员、风险分层或全量 Gate report 和本次明确人工批准全部满足后,首次生产动作只使用: +生产发布守门员、Core + Impact Gate report 和本次明确人工批准全部满足后,首次生产动作只使用: ```bash bash scripts/release-portal-canary-prod.sh --yes diff --git a/release-gate/impact.mjs b/release-gate/impact.mjs index ef1b792..66d8b84 100644 --- a/release-gate/impact.mjs +++ b/release-gate/impact.mjs @@ -17,24 +17,44 @@ export const CORE_SCENARIO_IDS = Object.freeze([ '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 CRITICAL_IMPACT_RULES = Object.freeze([ + { + groups: ['AGENT', 'CFG', 'UI'], + pattern: /^(?:(?:package|npm-shrinkwrap).*\.json|pnpm-lock\.yaml|yarn\.lock)$/i, + }, + { + groups: ['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA'], + pattern: /^(?:server\.mjs|schema\.sql|(?:migrations?|database)\/)/i, + }, + { + groups: ['CFG', 'REL'], + pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|run-release-gate|verify-release-gate|verify-canary-))/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([ /^(?:AGENTS|README|CHANGELOG)\.md$/i, + /^\.runtime\//i, /^docs\//i, /^\.cursor\//i, /^\.codex\//i, @@ -57,11 +77,12 @@ const IMPACT_RULES = Object.freeze([ { 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: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i }, + { groups: ['AGENT'], pattern: /(?:agent|capabilit|goosed|worker|aider|mcp)/i }, + { groups: ['CHAT'], pattern: /(?:chat|conversation|message|reply|session|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: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i }, { groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i }, ]); @@ -105,7 +126,7 @@ function closeGroupDependencies(initialGroups) { export function selectImpactScenarios({ catalog, changedPaths, - forceFullReasons = [], + blockingReasons = [], }) { const normalizedPaths = normalizePaths(changedPaths); 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(',')}`); } - const fullGateReasons = [...forceFullReasons]; + if (blockingReasons.length > 0) { + throw new Error(`Impact selection is blocked: ${blockingReasons.join(',')}`); + } const directGroups = new Set(); const unmappedPaths = []; for (const relativePath of normalizedPaths) { - if (matchesAny(FULL_GATE_PATHS, relativePath)) { - fullGateReasons.push(`critical_path:${relativePath}`); - continue; + let matched = false; + for (const rule of CRITICAL_IMPACT_RULES) { + if (!rule.pattern.test(relativePath)) continue; + matched = true; + for (const group of rule.groups) directGroups.add(group); } if (matchesAny(NON_RUNTIME_PATHS, relativePath)) continue; - let matched = false; for (const rule of IMPACT_RULES) { if (!rule.pattern.test(relativePath)) continue; matched = true; @@ -134,23 +158,19 @@ export function selectImpactScenarios({ if (matched) continue; 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 strategy = fullGateReasons.length > 0 - ? 'full' - : impactGroups.length > 0 - ? 'impact' - : 'core'; + const strategy = 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); - } + for (const scenario of catalog) { + if (impactGroups.includes(scenario.group)) selected.add(scenario.id); } const selectedIds = catalog @@ -158,14 +178,14 @@ export function selectImpactScenarios({ .filter((id) => selected.has(id)); return { - policy_version: 1, + policy_version: 2, 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(), + full_gate_reasons: [], selected_ids: selectedIds, selected_total: selectedIds.length, }; diff --git a/release-gate/impact.test.mjs b/release-gate/impact.test.mjs index dde4037..a012e31 100644 --- a/release-gate/impact.test.mjs +++ b/release-gate/impact.test.mjs @@ -30,21 +30,51 @@ test('domain changes select the domain and dependency closure', async () => { 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 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']); + assert.equal(critical.policy_version, 2); + assert.equal(critical.strategy, 'impact'); + 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({ - 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); + assert.throws( + () => selectImpactScenarios({ + catalog, + changedPaths: ['new-runtime-kernel.mjs'], + }), + /unmapped runtime paths: new-runtime-kernel\.mjs/, + ); + 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); }); diff --git a/release-gate/report.mjs b/release-gate/report.mjs index f2d0bb5..8470f77 100644 --- a/release-gate/report.mjs +++ b/release-gate/report.mjs @@ -137,9 +137,18 @@ function validateImpactSelection(report, scenarioIds, errors) { 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 (![1, 2].includes(selection.policy_version)) { + errors.push('impact policy_version must be 1 or 2'); + } + 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 ?? '')) { 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) { 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.unmapped_paths?.length > 0) { + 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 (!sameValues(scenarioIds, expectedScenarioIds())) { diff --git a/release-gate/report.test.mjs b/release-gate/report.test.mjs index 6a207af..97c8907 100644 --- a/release-gate/report.test.mjs +++ b/release-gate/report.test.mjs @@ -187,5 +187,44 @@ test('impact report rejects missing core coverage and unmapped non-full paths', }); 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/); + 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/); }); diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs index c9a15e2..26c6e7e 100644 --- a/release-gate/runner.mjs +++ b/release-gate/runner.mjs @@ -372,7 +372,7 @@ export async function executeImpactReleaseGate(options) { } options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); if (!options.deployedCommit) { - return executeReleaseGate({ ...options, mode: 'all' }); + throw new Error('impact release gate requires --deployed-commit'); } const catalog = await loadScenarioCatalog({ root: ROOT }); @@ -390,7 +390,7 @@ export async function executeImpactReleaseGate(options) { ...selectImpactScenarios({ catalog, changedPaths, - forceFullReasons: ancestry.code === 0 + blockingReasons: ancestry.code === 0 ? [] : [`deployed_commit_not_ancestor:${options.deployedCommit}`], }), diff --git a/scripts/run-release-gate-impact.mjs b/scripts/run-release-gate-impact.mjs index e25601c..303348b 100644 --- a/scripts/run-release-gate-impact.mjs +++ b/scripts/run-release-gate-impact.mjs @@ -6,7 +6,8 @@ function 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.`); +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 { @@ -25,9 +26,6 @@ try { 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 diff --git a/scripts/verify-release-gate-report.mjs b/scripts/verify-release-gate-report.mjs index 1dfed00..ca7ad01 100644 --- a/scripts/verify-release-gate-report.mjs +++ b/scripts/verify-release-gate-report.mjs @@ -76,7 +76,7 @@ try { ...selectImpactScenarios({ catalog, changedPaths, - forceFullReasons: ancestry.code === 0 + blockingReasons: ancestry.code === 0 ? [] : [`deployed_commit_not_ancestor:${report.selection.base_commit}`], }),