From 1eda3a940341df56825af7bd0f60981ea504e04b Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Sun, 16 Aug 2026 10:24:33 +0800 Subject: [PATCH 01/32] docs(194): register dependabot batch handling rules after closing 14 PRs --- .../PLAN.md" | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git "a/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" "b/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" index 1deca14f8..58d917758 100644 --- "a/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" +++ "b/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" @@ -58,6 +58,21 @@ API fixture(当前通用空 envelope 下,认证页挂起不渲染)。另需 `lhci/login.js` 的 sessionStorage 注入配合(已就绪)。 +### Task J:dependabot 升级批次处理(2026-08 挂起 14 个 PR) + +2026-08-16 关闭了 14 个 dependabot PR(#7-#20,python/node 运行时 major、mypy 2.3、 +pinia 4、plugin-vue 6、actions major、若干 patch/minor)。后续批次按以下规则处理: + +- **patch/minor**:重生成 dev 锁(uv pip compile,完整 extras、`--no-emit-package pip`); + npm 类需 `npm ci` 后跑 `ALLOW_BASELINE_UPDATE=1 python3 scripts/ci/npm_audit_ratchet.py --update` + 刷新棘轮基线(锁哈希绑定是预期门禁,不是故障);全量测试后合并。 +- **mypy major**:repo-wide 棘轮基线按版本锁定(当前 1.20.2),升级 = 锁重生成 + 基线重生成 + + 759 个历史错误的再审计,与 Task I(mypy 历史清理)合并做。 +- **运行时 major(python/node Docker 镜像)**:发布链路无验证,需先专项验证依赖兼容性再升级。 +- **actions major**:PR 自身 CI 即验证(改的就是 CI),绿即可合;docs.yml 相关(Pages 部署) + 在 master push 后确认部署正常。 +- 可选治理:在 `.github/dependabot.yml` 为暂缓的 major 增加 `ignore` 条目,减少重复 PR 噪音。 + ## 验收标准 - `large_file_baseline.json` 中 `_deferred_regressions` 清空(2 项前端回归修复至原基线) From b8b949c5dc7c4d8eee70b03040c21f5792bdac28 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Sun, 16 Aug 2026 10:28:48 +0800 Subject: [PATCH 02/32] ci(dependabot): ignore deferred majors to stop weekly re-open noise 194 Task J governance: the 14 closed dependabot PRs included major migrations that need dedicated validation (mypy 2.x ratchet re-audit, pinia 3+/plugin-vue 6 frontend migration, python/node runtime image bumps). Ignore them in dependabot.yml so they stop re-opening every Monday until 194 unblocks them; patch flows are unaffected. --- .github/dependabot.yml | 23 +++++++++++++++++++ .../PLAN.md" | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 909a5c0f2..2b48e99aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,6 +18,12 @@ updates: - "python" commit-message: prefix: "chore(deps)" + # mypy 2.x is deferred (194 Task J): the repo-wide ratchet baseline is + # pinned to mypy 1.20.2; upgrading requires lock + baseline regeneration + # plus a re-audit of the 759 historical errors (194 Task I). + ignore: + - dependency-name: "mypy" + versions: [">=2"] # Frontend (npm) - package-ecosystem: "npm" @@ -31,8 +37,19 @@ updates: - "frontend" commit-message: prefix: "chore(deps)" + # pinia 3+/4 and @vitejs/plugin-vue 6 are major migrations deferred to + # 194 Task J: they need full vitest/build validation and possibly code + # adaptation before landing, not just the ratchet baseline refresh. + ignore: + - dependency-name: "pinia" + versions: [">=3"] + - dependency-name: "@vitejs/plugin-vue" + versions: [">=6"] # Docker base images + # Runtime majors (python >=3.13, node >=21) are deferred to 194 Task J: + # the publish pipeline has no pre-release validation, so each upgrade + # needs a dedicated compatibility check first. Patch tags still flow. - package-ecosystem: "docker" directory: "/src/backend" schedule: @@ -43,6 +60,9 @@ updates: - "docker" commit-message: prefix: "chore(deps)" + ignore: + - dependency-name: "python" + versions: [">=3.13"] - package-ecosystem: "docker" directory: "/src/frontend" @@ -54,6 +74,9 @@ updates: - "docker" commit-message: prefix: "chore(deps)" + ignore: + - dependency-name: "node" + versions: [">=21"] # GitHub Actions - package-ecosystem: "github-actions" diff --git "a/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" "b/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" index 58d917758..81121b49b 100644 --- "a/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" +++ "b/docs/iterations/\350\277\255\344\273\243194-\345\267\245\347\250\213\345\200\272\345\210\207\347\211\207\347\273\255\344\275\234/PLAN.md" @@ -71,7 +71,7 @@ pinia 4、plugin-vue 6、actions major、若干 patch/minor)。后续批次按 - **运行时 major(python/node Docker 镜像)**:发布链路无验证,需先专项验证依赖兼容性再升级。 - **actions major**:PR 自身 CI 即验证(改的就是 CI),绿即可合;docs.yml 相关(Pages 部署) 在 master push 后确认部署正常。 -- 可选治理:在 `.github/dependabot.yml` 为暂缓的 major 增加 `ignore` 条目,减少重复 PR 噪音。 +- 治理已落地(2026-08-16):`.github/dependabot.yml` 已为暂缓 major 加 `ignore` 条目(mypy >=2、pinia >=3、@vitejs/plugin-vue >=6、docker python >=3.13、docker node >=21),避免被关闭的 PR 每周重复提出;解除时机 = 本 Task 对应验证完成。 ## 验收标准 From ebec2a0adf0f239784edbe4d2f3221ac581bd65e Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Sun, 16 Aug 2026 10:34:36 +0800 Subject: [PATCH 03/32] docs: point doc links at the live GitHub Pages site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and docs pointed at aifortrader.cn, a domain that no longer resolves. The published documentation actually lives on GitHub Pages (docs.yml deploys docs/mkdocs.yml): - README.md / README.en.md: 中文文档 · English docs links now go to https://cloudquant.github.io/backtrader_web/ (and /en/); local API docs link unchanged (localhost:8000/docs). - docs/mkdocs.yml: site_url updated so canonical URLs and the sitemap point at the live Pages site. - docs/INDEX.md: external-entry section lists the Pages URLs and the deploy pipeline. - Removed docs/CNAME (dead custom domain) and .readthedocs.yml (dead RTD config; iteration 193 audit finding D12 already flagged it as non-functional and scheduled deletion). Verified: mkdocs.yml parses, check_doc_links passes, no aifortrader.cn references remain in the docs entry points. --- .readthedocs.yml | 20 -------------------- README.en.md | 4 ++++ README.md | 6 ++++-- docs/CNAME | 1 - docs/INDEX.md | 5 +++-- docs/mkdocs.yml | 2 +- 6 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 .readthedocs.yml delete mode 100644 docs/CNAME diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 70d520045..000000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,20 +0,0 @@ -# ReadTheDocs Configuration -# https://docs.readthedocs.io/en/stable/config-file/v2.html - -version: 2 - -build: - os: ubuntu-22.04 - tools: - python: "3.11" - -mkdocs: - configuration: docs/mkdocs.yml - fail_on_warning: false - -python: - install: - - method: pip - path: . - extra_requirements: - - docs diff --git a/README.en.md b/README.en.md index d595b343d..a22179197 100644 --- a/README.en.md +++ b/README.en.md @@ -10,6 +10,8 @@ [中文文档](README.md) | **English** +[中文文档](https://cloudquant.github.io/backtrader_web/) · [English docs](https://cloudquant.github.io/backtrader_web/en/) · [Local API docs](http://localhost:8000/docs) + --- AI for Investor is an AI + quantitative trading MVP for developers, traders, and small research teams who want to turn market knowledge and natural-language strategy ideas into executable research workflows. Strategy development, backtesting, parameter optimization, paper trading, live trading, knowledge-base Q&A, and AI-assisted research are integrated in one product. @@ -306,6 +308,8 @@ See [docs/explanation/STRATEGIC_ROADMAP.md](docs/explanation/STRATEGIC_ROADMAP.m | Document | Description | | -------- | ----------- | +| [Published docs (English)](https://cloudquant.github.io/backtrader_web/en/) | Online documentation site (GitHub Pages) | +| [Published docs (中文)](https://cloudquant.github.io/backtrader_web/) | Online documentation site (GitHub Pages) | | [Installation Guide](docs/guides/INSTALLATION.md) | Environment setup and installation | | [Quick Start](docs/guides/QUICKSTART.md) | 5-minute first backtest tutorial | | [API Usage Guide](docs/guides/API_GUIDE.md) | REST API examples and best practices | diff --git a/README.md b/README.md index 5d7c7d95a..91d4a2dc2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ AI 驱动的量化研究、策略生成、回测验证与交易辅助平台。 [![FastAPI](https://img.shields.io/badge/FastAPI-0.109+-teal.svg)](https://fastapi.tiangolo.com) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[中文文档](https://aifortrader.cn/) · [English docs](https://aifortrader.cn/en/) · [本地 API 文档](http://localhost:8000/docs) +[中文文档](https://cloudquant.github.io/backtrader_web/) · [English docs](https://cloudquant.github.io/backtrader_web/en/) · [本地 API 文档](http://localhost:8000/docs) ## 项目定位 @@ -173,7 +173,9 @@ python scripts/ci/check_doc_links.py | 目标 | 入口 | | --- | --- | -| 产品概览与投研流程 | [项目介绍](docs/project-introduction/ai-for-investor-project-introduction.md) / [中文文档首页](docs/docs/zh/index.md) | +| 在线文档(中文) | | +| 在线文档(English) | | +| 产品概览与投研流程 | [项目介绍](docs/project-introduction/ai-for-investor-project-introduction.md) / [中文文档首页源码](docs/docs/zh/index.md) | | 安装、知识库、市场数据、策略与回测 | [快速开始与功能文档](docs/docs/zh/getting-started/index.md) | | 架构、API、数据库与配置 | [开发文档](docs/docs/zh/development/index.md) | | Docker 与生产运维 | [部署文档](docs/docs/zh/deployment/index.md) | diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 836610665..000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -aifortrader.cn \ No newline at end of file diff --git a/docs/INDEX.md b/docs/INDEX.md index bc970758c..af56820f1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -48,6 +48,7 @@ python -m mkdocs build -f docs/mkdocs.yml --strict ## 发布与外部入口 -- 中文站点: -- English site: +- 中文站点(GitHub Pages): +- English site(GitHub Pages): - 运行中 API:`http://localhost:8000/docs` +- 部署流水线:`.github/workflows/docs.yml`(master 上 `docs/**` 变更自动重建) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ff5019c59..072a416cf 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -2,7 +2,7 @@ # https://www.mkdocs.org site_name: AI for Investor -site_url: https://aifortrader.cn/ +site_url: https://cloudquant.github.io/backtrader_web/ site_description: 基于 Backtrader 的现代化量化交易全栈管理平台 | Modern Quantitative Trading Platform based on Backtrader site_author: AI for Investor Team From b9d1c734a1ea7cab39179eb90bf69fdc87d9ef4a Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Sun, 23 Aug 2026 12:40:31 +0800 Subject: [PATCH 04/32] docs(governance): record iteration 195 decisions and baseline --- .github/governance/decisions/iteration-195.md | 71 ++ .../decisions/remote-sync-incident.md | 31 + .../evidence/preflight.md" | 64 ++ ...55\344\273\243\350\256\241\345\210\222.md" | 659 ++++++++++++++++++ 4 files changed, 825 insertions(+) create mode 100644 .github/governance/decisions/iteration-195.md create mode 100644 .github/governance/decisions/remote-sync-incident.md create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/preflight.md" create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" diff --git a/.github/governance/decisions/iteration-195.md b/.github/governance/decisions/iteration-195.md new file mode 100644 index 000000000..16cb065ac --- /dev/null +++ b/.github/governance/decisions/iteration-195.md @@ -0,0 +1,71 @@ +# Iteration 195 — Decision Gates (D0–D7) + +> 记录日期:2026-08-23 +> 决策依据:仓库所有者授权按《迭代195:社区 PR 治理与双分支协作正式迭代计划》实施;各门推荐值即计划 §2 的推荐值。 +> 规则:没有决策记录不得将任何规则切为阻塞状态;"稍后再说"不是已通过。本文件只登记仓库内可追溯决策,外部 GitHub/Gitee 设置仍须由管理员在对应门通过后单独执行。 + +## D0 — 默认分支与双分支语义 + +- **状态:通过** +- 决策:暂保留 `master` 为默认分支,保持稳定克隆/发布入口;由 `PR Governance` 门禁阻断普通 PR 目标为 `master`。安全治理 workflow、其调用的标准库脚本与治理 manifest 须经 `release/governance-bootstrap` PR 提升到 `master` 后才对 fork 的 `pull_request_target` 生效。 +- 决策人:仓库所有者 `@cloudQuant`(2026-08-23 授权实施本计划) +- 证据:`docs/iterations/迭代195-社区PR治理与双分支协作/evidence/preflight.md` §1/§3(GitHub API 返回默认分支 `master`) +- 下一步:bootstrap PR 合并后在 fork 草稿 PR 上做 shadow 演练(Task 2 Step 3.5)。 + +## D1 — 远端权威与当前漂移处置 + +- **状态:通过** +- 决策:GitHub 为唯一 PR / 发布权威;Gitee 仅作受监控只读镜像。当前 GitHub/Gitee `master` SHA 差异登记为镜像事件,由人工确定同步方案;监控脚本只报告、不自动同步、不 force-push。 +- 决策人:仓库所有者 `@cloudQuant`(2026-08-23) +- 证据:preflight §2(GitHub master=`605d4d0e` vs Gitee master=`3d051306`;dev 两端一致);事件记录 `.github/governance/decisions/remote-sync-incident.md` +- 下一步:管理员人工核对两段历史后择期同步;复核日期 2026-09-30。 + +## D2 — 真实 owner 与审批池 + +- **状态:通过(含到期日例外)** +- 决策:受保护域主 owner 使用已验证的真实 GitHub 用户 `@cloudQuant`(类型 `User`,CODEOWNERS errors API 返回空)。R2/R3 与 `master` 的第二独立维护者审批池尚未建立——在该审批池确认前,不启用 code-owner required review,Ruleset 审批数先按"至少 1 位批准"落地,`master` 双人审批作为 Ruleset 静态要求保留但依赖后续审批池扩充。 +- 决策人:核心维护者 `@cloudQuant`(2026-08-23) +- 证据:preflight §1 #5(errors 空)、§3 #3(owner 类型验证) +- 到期日例外:备用 owner 名单须于 **2026-10-01** 前补充并经成员可访问性复核;逾期未补则维持"仅 code-owner 提示、不强制 required review"。 +- 下一步:邀请第二维护者加入审批池;补充后更新 CODEOWNERS 与 manifest。 + +## D3 — Ruleset 能力与 bypass + +- **状态:有到期日的例外** +- 决策:manifest 先行(`.github/governance/rulesets/*.json` 为归一化 desired state);沙盒/观察期验证(required workflow/check、code-owner review 字段可用性、evaluate 支持)未完成前,不在远端应用任何 blocking Ruleset。常规 bypass 保持为零;紧急 bypass 须 incident + 理由 + 24 小时复盘。 +- 决策人:仓库管理员 `@cloudQuant`(2026-08-23) +- 证据:preflight §1 #2/#3/#4(Ruleset 列表为空、无既有保护) +- 到期日:**2026-10-15** 前完成沙盒验证与 `verify_github_governance.py --live` 读回对比;逾期则继续以 shadow gate 运行并在本文件追加延期理由。 +- 下一步:Task 6 shadow 期结束后按 manifest 应用 `dev`,读回验证后再处理 `master`。 + +## D4 — required-check 合同 + +- **状态:阻塞** +- 阻塞原因:确切的 check context 名称必须来自真实草稿 PR 的 Checks API/界面记录(Task 4 三类演练),禁止以 YAML job id 猜测。截至 2026-08-23 尚无演练记录。 +- 责任人:CI owner `@cloudQuant` +- 解除条件:三类草稿 PR(R1→dev、R2→dev、hotfix/release→master)演练完成,且 `Governance Gate` 等聚合 check 在 opened/synchronize/labeled/unlabeled/review 等事件下均稳定报告;记录写入 `evidence/dry-run-matrix.md`。 +- 未解除前的约束:Ruleset 不配置 required checks;manifest 中 required-check 字段保留占位来源说明。 + +## D5 — preview 产品边界 + +- **状态:通过** +- 决策:本迭代只保留无 secrets 的 "Preview Build Artifact";删除虚构部署 URL 与部署宣称。真实托管预览(平台、凭据、cleanup、环境保护)另立方案,不在本迭代承诺。 +- 决策人:发布/平台 owner `@cloudQuant`(2026-08-23) +- 证据:`deploy-preview.yml` 改造(Task 5),artifact 名称与 job summary 不再出现 "Deployed" 字样。 +- 下一步:如需真实 preview,另立迭代签署平台与凭据设计。 + +## D6 — release tag 与镜像权限 + +- **状态:有到期日的例外** +- 决策:仅允许指向 `master` 当前 HEAD 的受保护版本 tag(`vMAJOR.MINOR.PATCH[-rcN]`)触发镜像发布;工作流内先实现 tag↔`origin/master` HEAD 相等校验(不等则在 Docker login 前失败)。手动 `image_tag` 发布入口移除。Docker Hub 凭据绑定受保护 release environment —— environment 尚未配置,属本例外范围。 +- 决策人:发布负责人 + 仓库管理员 `@cloudQuant`(2026-08-23) +- 到期日:**2026-10-15** 前由管理员创建 `release` environment 并加审批规则;在此之前发布工作流对生产凭据保持不可用状态(secrets 未注入 environment 即无法登录)。 +- 下一步:非生产 release 演练(Task 5 Step 4)通过后配置 environment,再恢复生产发布。 + +## D7 — 安全披露通道与响应承诺 + +- **状态:阻塞** +- 阻塞原因:尚未书面确认受监控披露通道(候选:GitHub 私密漏洞报告 Private Vulnerability Reporting;备选受监控邮箱或工单系统)。未确认前不新增根 `SECURITY.md`,不承诺任何邮箱、私信或 SLA。 +- 责任人:安全负责人 `@cloudQuant` +- 解除条件:通道开通验证(能收到测试报告)、支持版本清单、响应时限书面确认后,方可在独立 PR 中新增 `SECURITY.md` 并更新 Issue Template 指向。 +- 过渡措施:`config.yml` 仅指向 GitHub Discussions 与文档,安全问题描述引导至私密漏洞报告(启用后自动生效),不写虚构联系方式。 diff --git a/.github/governance/decisions/remote-sync-incident.md b/.github/governance/decisions/remote-sync-incident.md new file mode 100644 index 000000000..4d9b270d4 --- /dev/null +++ b/.github/governance/decisions/remote-sync-incident.md @@ -0,0 +1,31 @@ +# Incident: GitHub ↔ Gitee `master` 镜像漂移(iteration 195 D1) + +> 登记日期:2026-08-23 +> 类型:镜像一致性(低危,不影响 GitHub 权威流程) +> 状态:Open + +## 事实(2026-08-23 只读快照) + +| 远端 | ref | SHA | +|---|---|---| +| GitHub `cloudQuant/backtrader_web` | `master` | `605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f` | +| Gitee `yunjinqi/backtrader_web` | `master` | `3d05130635f50c45adeaa4514af246380ff00451` | +| GitHub / Gitee | `dev` | `ebec2a0adf0f239784edbe4d2f3221ac581bd65e`(两端一致) | + +发现方式:计划编制基线采集(`git ls-remote --heads`),见 `evidence/preflight.md` §2。 + +## 权威端 + +GitHub 为唯一权威(D1 决策)。Gitee 仅作只读镜像;差异**不得**由 CI、脚本或执行者自动 force-push、覆盖或修复。 + +## 处置方案(人工) + +1. 管理员分别核对两端 `master` 各自领先/独有的提交(`git log --left-right --cherry-pick --oneline GitHub/master...Gitee/master`),确认需保留的历史。 +2. 由有权限的人工执行一次明确方向的同步(fast-forward 或显式合并),操作前后各留存一份 `ls-remote` 记录到本目录。 +3. 同步完成后在本文件回填结果并将状态改为 Resolved。 + +## 责任人与时间线 + +- 责任人:`@cloudQuant`(仓库管理员) +- 通知渠道:本 incident 文件 + 迭代看板条目(唯一治理工作流标识 `iteration-195-pr-governance`) +- 复核日期:**2026-09-30**(逾期未解决则升级为阻塞项重新评审 D1) diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/preflight.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/preflight.md" new file mode 100644 index 000000000..f4b8b7df9 --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/preflight.md" @@ -0,0 +1,64 @@ +# Iteration 195 — Preflight Baseline Evidence + +> 采集日期:2026-08-23(Asia/Shanghai) +> 执行者角色:治理实施代理(以仓库所有者 `cloudQuant` 的本地凭据执行只读命令) +> 命令来源:`docs/iterations/迭代195-社区PR治理与双分支协作/正式迭代计划.md` §1.1 +> 纪律:本文件只记录时间、命令、摘要、HTTP 状态与 SHA;原始 API 响应、token 与私密内容不入库。 + +## 1. 只读快照结果 + +采集时间:2026-08-23(本地会话,UTC+8) + +| # | 命令 | HTTP/退出状态 | 摘要 | +|---|---|---|---| +| 1 | `gh repo view cloudQuant/backtrader_web --json defaultBranchRef,visibility` | 0 (200) | 默认分支 `master`;可见性 `PUBLIC` | +| 2 | `gh api repos/cloudQuant/backtrader_web/rulesets --paginate` | 0 (200) | 返回空数组 `[]`:当前无任何 Ruleset | +| 3 | `gh api repos/cloudQuant/backtrader_web/branches/master/protection` | 404 | `"Branch not protected"` — 传统 Branch Protection 未启用(有效基线证据) | +| 4 | `gh api repos/cloudQuant/backtrader_web/branches/dev/protection` | 404 | 同上,`dev` 亦未保护 | +| 5 | `gh api repos/cloudQuant/backtrader_web/codeowners/errors` | 0 (200) | `{"errors":[]}` — CODEOWNERS 无解析错误 | +| 6 | `gh label list --repo cloudQuant/backtrader_web --limit 100` | 0 (200) | `bug, documentation, duplicate, enhancement, good first issue, help wanted, invalid, merge-ready, question, wontfix`。注意:历史遗留 label `merge-ready` 已存在 | +| 7 | `git ls-remote --heads https://github.com/cloudQuant/backtrader_web.git master dev` | 0 | GitHub: `master=605d4d0e…`, `dev=ebec2a0a…` | +| 8 | `git ls-remote --heads https://gitee.com/yunjinqi/backtrader_web.git master dev` | 0 | Gitee: `master=3d051306…`, `dev=ebec2a0a…` | + +## 2. 四个远端 ref SHA 登记 + +| 远端 | 分支 | SHA(短) | 说明 | +|---|---|---|---| +| GitHub (`cloudQuant/backtrader_web`) | master | `605d4d0e` | 权威端(D1 推荐) | +| GitHub (`cloudQuant/backtrader_web`) | dev | `ebec2a0a` | 日常集成 | +| Gitee (`yunjinqi/backtrader_web`) | master | `3d051306` | **与 GitHub master 不同 SHA → 镜像漂移**,登记于 `.github/governance/decisions/remote-sync-incident.md` | +| Gitee (`yunjinqi/backtrader_web`) | dev | `ebec2a0a` | 与 GitHub dev 一致 | + +结论:`dev` 双远端一致;`master` 双远端已漂移。监控只报告差异,不自动同步或覆盖任一远端。 + +## 3. 关键事实对计划的影响复核 + +1. 默认分支为 `master`:新 fork / 新 PR 的默认 base 偏向 `master`。按 D0 推荐保留 `master` 为默认分支,由 PR Governance 门禁阻断普通 PR 目标为 `master`;安全治理 workflow 须先经 `release/governance-bootstrap` PR 提升到 `master` 后才会在 fork 的 `pull_request_target` 中生效。 +2. `master`/`dev` 均无 Branch Protection 且 Ruleset 列表为空:文档承诺在 Ruleset 应用前不具备服务器端强制力。 +3. CODEOWNERS 存在且无 errors,仅保护 iteration 193 ratchet 基线文件;新增领域 owner 时必须保留既有条目。owner 使用真实用户 `@cloudQuant`(GitHub 用户类型为 `User`,已验证存在)。 +4. `pr-check.yml` 同时监听 `pull_request` + `pull_request_target`,顶层权限含 `pull-requests: write`,并执行 checkout/install 与自动贴 `merge-ready` 标签——P0 特权执行风险,Task 2 处理。 +5. `deploy-preview.yml` 构建前端 artifact 却评论虚构 URL `https://pr-N.backtrader-web.preview.dev`——Task 5 改为如实命名。 +6. `docker-publish.yml` 接受任意 `v*` tag 和手动输入 tag,未验证 tag 指向 `master` HEAD——Task 5 收紧。 +7. 仓库布局:交易适配目录为 `src/bt_api_py/`;根 `dags/` 与 `tests/unit/scripts/` 不存在,本迭代不创建虚假路径规则。 + +## 4. 决策门状态摘要 + +详细决策记录见 `.github/governance/decisions/iteration-195.md`: + +| 门 | 状态(2026-08-23) | +|---|---| +| D0 默认分支与双分支语义 | 通过(保留 `master` 为默认) | +| D1 远端权威与漂移处置 | 通过(GitHub 权威 / Gitee 只读镜像)+ incident 登记 | +| D2 真实 owner 与审批池 | 通过(主 owner `@cloudQuant` 已验证;备用 owner 待补,有到期日) | +| D3 Ruleset 能力与 bypass | 有到期日的例外(沙盒验证待做;manifest 先行,外部应用延后) | +| D4 required-check 合同 | 阻塞(依赖 Task 4 三类草稿 PR 演练取得确切 context 名) | +| D5 preview 产品边界 | 通过(仅 Preview Build Artifact,不宣称部署) | +| D6 release tag 与镜像权限 | 有到期日的例外(受保护 environment 待管理员配置;工作流先落 provenance 校验) | +| D7 安全披露通道 | 阻塞(未确认受监控通道前不发布 SECURITY.md) | + +## 5. 后续证据索引 + +- 演练矩阵:`evidence/dry-run-matrix.md` +- Ruleset 读回:`evidence/ruleset-readback.md` +- 度量口径:`evidence/weekly-metrics-schema.md` +- 回滚手册:`evidence/rollback-runbook.md` diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" new file mode 100644 index 000000000..a404dd48d --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" @@ -0,0 +1,659 @@ +# 迭代195:社区 PR 治理与双分支协作正式迭代计划 + +> 计划日期:2026-08-23 +> 状态:**设计稿 / 未授权实施** +> 适用仓库:`cloudQuant/backtrader_web` +> 目标分支模型:`master`(发布主干)与 `dev`(日常集成) +> 计划边界:本文件只定义治理、文档、GitHub 配置与相关自动化的交付方案;不实施业务功能、不修改远端设置、不推送、不合并。 + +> **For Codex:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Each task must remain on an implementation branch targeting `dev`; external GitHub/Gitee settings are applied only after the designated decision gate is signed and the repository change has been reviewed. + +**Goal:** 建立可审计的社区 PR 路由、评审、CI、发布和双远端镜像治理,使普通贡献稳定进入 `dev`,而 `master` 只接收经过完整证据验证的 release promotion 或紧急 hotfix。 + +**Architecture:** 将不可变的分支保护、审批数量与 required checks 放在 GitHub Ruleset;将目标分支、来源分支命名、路径风险和证据完整性放在一个始终产出同名结果的 `PR Governance` 聚合门禁;将人工不可替代的 owner、例外和发布授权留在可追溯的 PR/issue/决策记录中。GitHub 是 PR 与治理设置的权威,Gitee 仅在 D1 签署后作为受监控镜像。 + +**Tech Stack:** GitHub Rulesets、GitHub Actions、GitHub CLI、Git、YAML/JSON、Python 标准库脚本、pytest、MkDocs、Vue/Node 与 FastAPI 现有质量门禁。 + +--- + +## 0. 本次完善结论与范围边界 + +原稿方向正确,但不宜直接实施。以下问题已在 2026-08-23 以仓库与 GitHub 只读证据复核,并已在本计划中纠正: + +1. 标题误写为“迭代183”,现统一为“迭代195”。 +2. `.github/CODEOWNERS` **已存在**,但当前仅保护 iteration 193 的 ratchet 基线文件;不能再将“没有 CODEOWNERS”当作基线,也不能在其上覆盖式重建。 +3. `master` 与 `dev` 当前都没有 Branch Protection 或 Ruleset;GitHub Ruleset 列表为空。计划须先建立可验证的 desired-state manifest,再由有权限的管理员执行外部设置。 +4. `pr-check.yml` 同时监听 `pull_request` 与 `pull_request_target`,且会 checkout、安装和执行 PR 代码,同时工作流声明 `pull-requests: write`。这在面向 fork 的社区工作流中是 P0 安全风险,必须早于“自动贴标签/自动 merge-ready”类便利功能处理。后续如保留 `pull_request_target`,只能用于只读元数据与**base SHA** 的受信任脚本,绝不 checkout 或执行 PR head。 +5. `deploy-preview.yml` 只构建前端 artifact,却评论一个未实际部署的 URL;它不是可验收的 preview 环境。计划改为默认“预览构建产物”,真实托管预览须另有已签署的平台与凭据设计。 +6. `docker-publish.yml` 对任意 `v*` tag 和手动输入 tag 都可发布镜像,未验证 tag 指向 `master` 当前 HEAD。仅写“由 master tag 触发”不足以实现该约束。 +7. GitHub 与 Gitee 的 `dev` 在本次快照中同 SHA,但 `master` 已不同 SHA;监控不得把当前漂移误报为“已一致”,更不得由 CI 自动 force-push 修复。 +8. 原稿中的 `dags/` 与 `tests/unit/scripts/` 不是当前仓库路径;风险映射、CODEOWNERS 和测试位置改为当前真实目录,未来新增 Airflow/DAG 根目录时再通过单独 PR 扩展。 +9. `CONTRIBUTING.md` 中的 `YOUR_USERNAME/backtrader_web.git` 是 fork 克隆占位符,不是错误仓库名;应补充替换说明和 `upstream` 配置,而不是改成所有贡献者都克隆上游。 + +本计划与仓库中已存在的“跨平台产品发布”迭代195草案是**两个独立范围**。本计划不修改其文件、产品运行时、Compose、数据库迁移或跨平台发布承诺;实施前应在 issue/项目看板中为本计划使用唯一的治理工作流标识,避免同一迭代编号被误当作同一交付物。 + +## 1. 已核验基线与证据纪律 + +下表是本计划编制时的事实快照,不是未来实施完成的声明。每次实际落地前必须重新采集,并把结果保存为本迭代证据。 + +| 项目 | 2026-08-23 只读结论 | 证据与影响 | +|---|---|---| +| 默认分支 | GitHub API 返回 `master` | 新 fork/新 PR 默认基线会偏向 `master`;D0 必须决定保持它还是切换为 `dev`,且两种选择都要有防误投机制。 | +| 分支保护 | `master` 与 `dev` 的传统 Branch Protection API 均返回未保护;Ruleset 列表为空 | 未经配置前,任何文档承诺都不能阻止直接 push、删除或 force push。 | +| CODEOWNERS | 文件存在、GitHub `codeowners/errors` 返回空;当前只覆盖 ratchet 相关文件 | 必须在保留既有条目的前提下增加领域覆盖;不可用未验证团队名或占位符合入。 | +| PR 自动化 | `pr-check.yml` 具有 `pull_request_target` 与写权限,并执行 checkout/install | 先消除特权执行不受信任代码,再加入新的治理检查。 | +| preview | 工作流只产出 `src/frontend/dist/` artifact,却宣称已部署 | 默认改名为 build artifact;不得向贡献者提供虚构 URL。 | +| 镜像发布 | 现有工作流接受任意 `v*` tag 与手动 tag 输入 | 必须验证 tag 解析出的提交等于 `origin/master` 当前 HEAD,并把镜像 digest 留为发行证据。 | +| 双远端 | `dev` SHA 一致;GitHub/Gitee 的 `master` SHA 不同 | D1 前先登记并分类现有漂移;监控只报告,不自动同步或覆盖任一远端。 | +| 仓库布局 | 交易适配目录是 `src/bt_api_py/`;根 `dags/` 与 `tests/unit/scripts/` 不存在 | 计划中的目录匹配、测试与 CODEOWNERS 均使用现有路径。 | + +### 1.1 基线采集命令 + +以下命令仅用于采集。输出不得包含 token、环境变量、私有 URL、issue 私密内容或 secrets 值。实施者把时间、命令、摘要、HTTP 状态和 SHA 写入 `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/preflight.md`,原始敏感响应不提交。 + +```bash +gh repo view cloudQuant/backtrader_web --json defaultBranchRef,visibility +gh api repos/cloudQuant/backtrader_web/rulesets --paginate +gh api repos/cloudQuant/backtrader_web/branches/master/protection || true +gh api repos/cloudQuant/backtrader_web/branches/dev/protection || true +gh api repos/cloudQuant/backtrader_web/codeowners/errors +gh label list --repo cloudQuant/backtrader_web --limit 100 +git ls-remote --heads https://github.com/cloudQuant/backtrader_web.git master dev +git ls-remote --heads https://gitee.com/yunjinqi/backtrader_web.git master dev +``` + +传统 Branch Protection 的 `404 Branch not protected` 是本次基线的有效证据;Ruleset 应始终以 Rulesets API/界面记录为主,传统 API 仅作补充。若 GitHub API 权限不足,记录为阻塞项,而不是以本地 `.github` 文件替代远端设置证据。 + +## 2. 必须签署的决策门 + +没有决策记录,不得将任何规则切为阻塞状态。所有决策应记录决策人、日期、理由、链接和到期日;“稍后再说”不是已通过。 + +| 门 | 决策与推荐值 | 决策人 | 通过证据 | 未通过时的处理 | +|---|---|---|---|---| +| D0 | **默认分支与双分支语义。** 推荐暂保留 `master` 为默认,保持稳定克隆/发布入口;同时由 `PR Governance` 阻断普通 PR 目标为 `master`。因 `pull_request_target` 的工作流定义取自默认分支,保留 `master` 时须先通过受审的 `release/governance-bootstrap` PR 将安全的治理 workflow、它调用的标准库脚本和治理 manifest 一并提升到 `master`,绝不直接 push。若改为 `dev`,须同步 README、Docs、Pages/Release 链接和 downstream 指引。 | 仓库管理员 + 核心维护者 | 书面决议、一个 fork 草稿 PR 的默认 base 截图/链接、bootstrap PR 记录(如适用) | 仅更新文档草案,不能启用目标分支阻断。 | +| D1 | **远端权威与当前漂移处置。** 推荐 GitHub 为唯一 PR/发布权威,Gitee 为只读镜像;先对两端 `master` 差异创建 incident,确定保留哪一个历史与有权限的人工同步方案。 | 仓库管理员 | D1 记录、当前漂移 issue、镜像责任人与通知渠道 | 不部署同步脚本,也不把 SHA 差异定义为失败。 | +| D2 | **真实 owner 与审批池。** 每个受保护域至少一位有仓库权限的真实 GitHub 用户;R2/R3 与 `master` 两人审批前必须确认足够的独立维护者。 | 核心维护者 | owner 矩阵、成员可访问性复核、备用 owner | 不添加 `@` 或不存在的团队;不启用 code-owner required review。 | +| D3 | **Ruleset 能力与 bypass。** 确认仓库可用的 Ruleset 字段、是否支持 evaluate、required workflow/check 和 code-owner review;常规 bypass 为零,紧急 bypass 需 incident、理由和 24 小时复盘。 | 仓库管理员 | 沙盒/观察期验证、manifest 与 API 读回对比 | 使用非阻塞 shadow gate;不得假设某套餐能力存在。 | +| D4 | **required-check 合同。** 从真实草稿 PR 的 Checks API/界面记录确切 context 名称;禁止以 YAML job id 猜测 required check 名。 | CI owner | 三类草稿 PR 的 check 清单与稳定聚合 check 名 | Ruleset 不配置 required checks。 | +| D5 | **preview 产品边界。** 推荐本迭代只保留无 secrets 的“Preview Build Artifact”;真实 URL、托管商、cleanup、环境保护和 fork 策略另立方案。 | 发布/平台 owner | 承诺的模式、环境/secret 边界说明 | 删除虚构部署文字,不宣称 preview 已上线。 | +| D6 | **release tag 与镜像权限。** 仅允许经 `master` promotion 后、指向 `master` 当前 HEAD 的受保护版本 tag;Docker Hub 凭据绑定受保护环境。 | 发布负责人 + 仓库管理员 | tag 规则、environment 审批规则、演练记录 | 保持发布工作流非生产状态;不可用手动输入 tag 绕过。 | +| D7 | **安全披露通道与响应承诺。** 使用已验证的 GitHub Security Advisory、受监控邮箱或工单系统;不要填 `security@`、维护者私信或 72h SLA 等猜测值。 | 安全负责人 | 可访问的通道、支持版本、响应 SLA 书面确认 | 不发布 `SECURITY.md` 中的承诺。 | + +## 3. 目标治理合同 + +### 3.1 双分支流向 + +```mermaid +flowchart LR + A[贡献者 fork / feature/*\n基于 dev] --> B[PR -> dev] + B --> C[PR Governance + CI\nowner review] + C --> D[dev 集成] + D --> E[release/vX.Y.Z\n基于 dev] + E --> F[PR -> master] + F --> G[完整门禁 + 2 人审批] + G --> H[master] + H --> I[受保护 tag vX.Y.Z\n必须指向 master HEAD] + I --> J[镜像发布 + digest 证据] + + K[安全/生产事故] --> L[私密披露或 incident] + L --> M[hotfix/master-*\n基于 master] + M --> F + F --> N[等价修复 PR -> dev\n或已证明不受影响] +``` + +| 情况 | 起始分支与目标 | 进入条件 | 合并后动作 | +|---|---|---|---| +| 文档、测试、普通功能、普通修复、策略或常规适配器变更 | `feature/*` 或 `fix/*` 基于 `dev`,PR → `dev` | 分级测试、治理门禁、所需 review | 进入下次 release 候选。 | +| R2 核心路径变更 | 基于 `dev`,PR → `dev` | 最低风险不能靠 label 下调;owner review、额外证据与受影响测试 | release 清单列为高风险项。 | +| 生产/Security hotfix | `hotfix/master-*` 基于 `master`,PR → `master` | 私密披露/incident、最小复现、回归、兼容性与回滚说明 | 创建前移 issue;在 `dev` 完成等价修复或记录“不受影响”理由后关闭。 | +| release promotion | `release/vX.Y.Z` 基于 `dev`,PR → `master` | release checklist、完整验证、版本/变更日志、回滚点 | 合并后在 `master` HEAD 创建版本 tag,再发布镜像。 | +| 两分支语义不同的修复 | 两个独立 PR | 分别实现、分别测试、互相链接 | 禁止将 cherry-pick 或 merge 本身当作完成证据。 | + +规则:不允许日常功能直接 PR 到 `master`;不允许直接 push 到任一长期分支;不允许“CI 绿”“merge-ready 标签”或大量 mock 测试替代审批、风险判断与发布证据。 + +### 3.2 风险分类与不可下调规则 + +| 等级 | 当前路径/情形(任一命中即取最高等级) | 最低人工审查 | 最低自动化与证据 | +|---|---|---|---| +| R0 | `docs/**`、`README*`、纯注释、非行为性说明 | 1 位维护者 | 格式、链接/文档构建;不要求无关 e2e。 | +| R1 | 常规 backend service/schema、普通 Vue 组件、策略与常规脚本 | 1 位模块维护者 | 受影响单元/集成测试、快速 CI。 | +| R2 | `src/backend/app/api/auth*`、认证/会话中间件、`src/backend/app/db/**`、`src/backend/app/models/**`、`src/frontend/src/router/**`、`src/frontend/src/stores/**`、`src/bt_api_py/**` | 对应 owner;D2 通过后再要求第二独立维护者 | 全量受影响测试;认证/资金/交易路径提供最小复现和明确的模拟或纸面验证边界。 | +| R3 | `.github/workflows/**`、`.github/governance/**`、`docker/**`、Dockerfile、`scripts/ops/release.sh`、依赖/lock 文件、`.env.example`、`SECURITY.md`、release/hotfix PR | 核心维护者;`master` 需两位独立批准 | 安全扫描、完整相关测试、配置/镜像验证、回滚/发行证据。 | + +分类器只可将贡献提升到更高风险,不能因为 PR 作者的 label、短小 diff、`merge-ready` 状态或自述而降低从路径得出的最低等级。任何人工覆盖必须记录“原分类、目标分类、理由、批准人、到期日”;覆盖只允许升高风险,降低风险需单独安全例外。 + +### 3.3 执行面分工 + +| 约束 | 主执行面 | 说明 | +|---|---|---| +| 禁止 force push/删除、要求 PR、静态审批数、解决 conversation、静态 required check、code owner review | GitHub Ruleset | 这些是不可由仓库 YAML 假装实现的服务器端边界。 | +| `master` 只接受 `release/*`/`hotfix/master-*`、风险分类、PR 正文证据、动态 R2/R3 审查、错误目标分支提示 | `PR Governance` 工作流 | Ruleset 不应被假定能按 PR label 或 head branch 动态改变规则;工作流只读取 PR metadata,并只执行以 base SHA checkout 的受信任脚本。 | +| owner 覆盖、例外、紧急 bypass、hotfix 前移、release 批准 | 人工 PR/issue/决策记录 | 自动化只能提示/阻断,不可把权限或业务判断委托给 PR 作者。 | +| GitHub/Gitee ref 一致性 | 夜间只读检查 + incident | 检查脚本不执行 fetch、push、merge、rebase 或 force push。 | + +## 4. 交付队列、所有权与并行边界 + +所有仓库内变更先进入 `feature/iteration-195-pr-governance-*` 分支并 PR → `dev`。外部设置只在对应仓库 PR 合并且 D 门签署后由管理员执行。每个提交使用显式路径 allowlist;禁止 `git add .`、清理他人改动或把外部设置截图当作代码提交。 + +| 工作包 | 主责 | 允许改动的主路径 | 与其他工作包的边界 | +|---|---|---|---| +| A:决策与基线 | 治理负责人 + 仓库管理员 | `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/**`、`.github/governance/decisions/**` | 先于所有 blocking enforcement;不改 workflow。 | +| B:贡献入口与文档 | 文档/社区 owner | `CONTRIBUTING.md`、`README*`、`AGENTS.md`、`docs/docs/**`、`docs/mkdocs.yml`、Issue/PR 模板、`SECURITY.md` | 不改 Ruleset manifest 或 workflow。 | +| C:安全工作流与治理门禁 | CI/Security owner | `.github/workflows/pr-check.yml`、`.github/workflows/pr-governance.yml`、`scripts/ci/**`、`scripts/ci/tests/**` | 单一负责人修改 workflow;不改 Docker 发布策略。 | +| D:所有权与 Ruleset desired state | 仓库管理员 + 领域 owner | `.github/CODEOWNERS`、`.github/governance/**` | 不替换既有 ratchet owner 条目;外部 settings 由管理员单独操作。 | +| E:发布、preview 与镜像同步 | 发布/平台 owner | `docker-publish.yml`、`deploy-preview.yml`、`nightly.yml`、`scripts/ci/check_remote_sync.py` | 不创建真实 preview 平台;不自动同步远端。 | +| F:演练、度量与收口 | 治理负责人 + QA | 本迭代 `evidence/**`、指标说明 | 不为了获得绿灯降低规则或删除证据。 | + +## 5. 可交接的实施任务 + +### Task 0:冻结基线、签署决策并登记现有漂移 + +**Owner:** 治理负责人 + 仓库管理员 +**前置:** 无 +**Files:** + +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/preflight.md` +- Create: `.github/governance/decisions/iteration-195.md` +- Create: `.github/governance/decisions/remote-sync-incident.md`(仅当 D1 把当前 `master` 差异认定为需跟踪的镜像事件) + +**Step 1: 记录可复现的只读快照** + +运行第 1.1 节命令,并记录 UTC/本地时间、执行者角色、默认分支、Ruleset/Protection 结果、CODEOWNERS errors、labels 和四个远端 ref SHA。不得把 gh token、`.env`、webhook、私密 advisory 内容或完整 API 原文写进证据文件。 + +**Step 2: 填写 D0-D7** + +每个门必须写出 `状态(通过/阻塞/有到期日的例外)`、责任人、证据链接和下一步。D2 必须列出真实 GitHub 用户,不能用 `@`、不存在的组织 team 或通用邮箱取代。 + +**Step 3: 处置当前双远端差异** + +只登记 GitHub/Gitee `master` 的不同 SHA、差异发现时间、权威端、人工修复方案和复核日期。此步骤明确禁止由 CI、脚本或执行者自动 force-push、覆盖或删除任一远端历史。 + +**Step 4: 验证** + +```bash +rg -n 'D[0-7].*(通过|阻塞|例外)' .github/governance/decisions/iteration-195.md +git diff --check +``` + +**退出条件:** D0-D7 均有明确状态;D1/D2/D3/D4/D6/D7 未通过时,后续相应任务只能准备草案,不得启用阻塞规则或发布路径。 + +**Commit / handoff:** + +```bash +git add docs/iterations/迭代195-社区PR治理与双分支协作/evidence/preflight.md \ + .github/governance/decisions/iteration-195.md +if test -f .github/governance/decisions/remote-sync-incident.md; then + git add .github/governance/decisions/remote-sync-incident.md +fi +git commit -m "docs(governance): record iteration 195 decisions and baseline" +``` + +若 remote incident 不适用,必须从 allowlist 中移除该文件,而不是创建空文件。 + +### Task 1:收敛贡献入口、文档和披露说明 + +**Owner:** 文档/社区 owner +**前置:** D0;`SECURITY.md` 另需 D7 +**Files:** + +- Modify: `CONTRIBUTING.md` +- Modify: `README.md` +- Modify: `README.en.md` +- Modify: `AGENTS.md` +- Create: `docs/docs/zh/development/contributing.md` +- Create: `docs/docs/en/development/contributing.md` +- Modify: `docs/docs/zh/development/index.md` +- Modify: `docs/docs/en/development/index.md` +- Modify: `docs/mkdocs.yml` +- Modify: `.github/PULL_REQUEST_TEMPLATE.md` +- Modify: `scripts/ci/check_pr_template.py` +- Create: `scripts/ci/tests/test_pr_template_contract.py` +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml` +- Create: `.github/ISSUE_TEMPLATE/feature_request.yml` +- Modify: `.github/ISSUE_TEMPLATE/config.yml` +- Delete: `.github/ISSUE_TEMPLATE/bug_report.md` +- Delete: `.github/ISSUE_TEMPLATE/feature_request.md` +- Delete: `.github/ISSUE_TEMPLATE/question.md` +- Create: `SECURITY.md`(仅 D7 已通过) + +**Step 1: 先写文档与模板契约测试** + +新增 `scripts/ci/tests/test_pr_template_contract.py`,以 PR 正文 fixture 验证:普通 PR、R2 PR、`master` hotfix、`master` release 四种模板的必填区块分别被接受/拒绝;既有 i18n manifest 在 locale 变更时仍不可缺失。实际 base/head 分支和 review 状态的验证属于 Task 4。测试应调用 `check_pr_template.py` 的可复用函数或 subprocess,不依赖 GitHub API。 + +**Step 2: 运行失败测试** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_pr_template_contract.py -q +``` + +预期:在新增治理字段和 checker 逻辑前,缺少字段/错误目标分支的 fixture 至少有一个失败。 + +**Step 3: 最小实现** + +1. `CONTRIBUTING.md` 明确 fork 后将 `YOUR_USERNAME` 替换成自己的用户名,添加 `git remote add upstream https://github.com/cloudQuant/backtrader_web.git`,并写明 feature/fix 基于 `dev`、release/hotfix 的专用起点与目标。 +2. 中英文在线文档使用 `docs/docs/{zh,en}/development/contributing.md`,并在 `mkdocs.yml` 的双语 Development 导航中加入该页;根 README 只保留简短、互相一致的入口。 +3. `AGENTS.md` 增加“常规仓库变更 PR → `dev`;不得直接向 `master` 提交”的协作边界,但不复制整份发布手册。 +4. PR 模板增加稳定标题:`## Governance declaration`、目标分支理由、自动/人工风险等级、测试证据、以及仅 `master` 可见的 hotfix 前移或 release 清单。保留现有 i18n 和 bundle-size 区块,不改其语义。 +5. 将 Issue Template 转为 Issue Forms:bug form 采集版本/环境/最小复现/期望与实际;feature form 采集问题、用户、范围与替代方案;`config.yml` 将一般问题指向 Discussions,并把安全问题指向 D7 已确认的私密通道。 +6. 仅在 D7 通过后新增根 `SECURITY.md`,写实际报告渠道、支持版本、确认时限和不在公开 issue 披露漏洞的说明。不得凭空承诺邮箱、私信或 SLA。 + +**Step 4: 验证** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_pr_template_contract.py -q +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_pr_template.py +DOCS_ITER195_SITE_DIR="$(mktemp -d)" +( + cd docs && /Users/yunjinqi/opt/anaconda3/bin/conda run -n base \ + mkdocs build --strict --site-dir "$DOCS_ITER195_SITE_DIR" +) +git diff --check +``` + +`check_pr_template.py` 在非 PR 环境可保持其既有“未提供 PR_BODY”退出语义;测试必须显式传入 PR_BODY/CHANGED_FILES,不可把本地空环境当作通过证据。 + +**退出条件:** 双语文档、README、CONTRIBUTING、AGENTS 和模板对分支语义一致;Issue Forms 在 GitHub UI 演练可用;安全说明不含虚构联系信息;既有 i18n 模板验证未回归。 + +**Commit / handoff:** + +```bash +git add CONTRIBUTING.md README.md README.en.md AGENTS.md \ + docs/docs/zh/development/contributing.md docs/docs/en/development/contributing.md \ + docs/docs/zh/development/index.md docs/docs/en/development/index.md docs/mkdocs.yml \ + .github/PULL_REQUEST_TEMPLATE.md .github/ISSUE_TEMPLATE/config.yml \ + .github/ISSUE_TEMPLATE/bug_report.yml .github/ISSUE_TEMPLATE/feature_request.yml \ + scripts/ci/check_pr_template.py scripts/ci/tests/test_pr_template_contract.py +git add -u .github/ISSUE_TEMPLATE +if test -f SECURITY.md; then git add SECURITY.md; fi +git commit -m "docs(governance): clarify contribution and disclosure flow" +``` + +### Task 2:先消除 fork PR 的特权执行路径 + +**Owner:** CI/Security owner +**前置:** Task 0;不依赖 Ruleset +**Files:** + +- Modify: `.github/workflows/pr-check.yml` +- Create: `.github/workflows/pr-governance.yml` +- Create: `scripts/ci/tests/test_workflow_governance_contract.py` +- Modify: `.github/workflows/ci.yml`(仅在最小 `permissions` 声明经 dry-run 验证后) + +**Step 1: 写失败的工作流安全契约测试** + +测试以文本/结构解析检查以下不变量: + +- `pr-check.yml` 不再声明 `pull_request_target`; +- 执行 PR 代码的 workflow 只由 `pull_request` 触发,默认只读权限; +- `PR Governance` 若使用 `pull_request_target`,只能 checkout `github.event.pull_request.base.sha`,并设置 `persist-credentials: false`;不得 checkout/执行 PR head、安装依赖或读取 secrets; +- workflow 不再自动赋予 `merge-ready` 来暗示可合并; +- 新增/修改的 action 使用不可变 SHA,并在注释中标注人类可读版本。 + +**Step 2: 运行失败测试** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_workflow_governance_contract.py -q +``` + +预期:现有 `pull_request_target`、写权限和自动贴 label 行为使测试失败。 + +**Step 3: 最小安全重构** + +1. 从 `pr-check.yml` 移除 `pull_request_target`,并将工作流顶层权限收紧为执行现有检查所需的最小只读集合。删除或禁用自动评论、自动标签和 `merge-ready` 副作用;它们不得成为合并资格的来源。 +2. 保持 smoke/build 等会执行 PR 代码的步骤在 `pull_request` 上运行;来自 fork 的 token/secrets 约束以 GitHub 默认安全模型为准,不通过 `pull_request_target` 绕过。 +3. 新建 `pr-governance.yml`。它可以使用 `pull_request_target` 取得对 fork 也稳定的 metadata 事件,但顶层权限只能是 `contents: read`、`pull-requests: read`,不使用 secrets、不写 label/comment。若需运行 `scripts/ci/check_pr_governance.py`,只 checkout `github.event.pull_request.base.sha` 的受信任基线配置,设置 `persist-credentials: false`,且脚本只能使用 Python 标准库;绝不 checkout、import、安装或执行 PR head。 +4. 对 `ci.yml` 增加最小 `permissions` 前,先用内部 PR 和 fork PR 各做一次 shadow run。任何 job 缺权限时只补充该 job 的最小权限并记录原因,不能恢复全局写权限。 +5. 若 D0 保持 `master` 为默认分支,标记此工作流为“待 bootstrap”:它在 `dev` 合入后不能假定会被 `pull_request_target` 采用。仅在 `release/governance-bootstrap` PR 将同一 workflow、它调用的标准库脚本和 manifest 一并提升到 `master` 后,才开始 GitHub 上的 fork shadow 演练。 + +**Step 4: 验证** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_workflow_governance_contract.py -q +git diff --check +``` + +在 GitHub 中至少用一个 fork 草稿 PR 验证:不受信任 head 的代码仍能在 `pull_request` 门禁中被测试;若治理工作流使用 `pull_request_target`,其日志只显示 base SHA checkout,且不存在写权限、secrets 或 PR head 执行路径。 + +**退出条件:** P0 特权执行风险已消除;`merge-ready` 即使保留为历史 label,也不再由 CI 自动写入或作为规则前提;所有变更 workflow 的安全性质由测试和草稿 PR 双重证明。 + +**Commit / handoff:** + +```bash +git add .github/workflows/pr-check.yml .github/workflows/pr-governance.yml \ + .github/workflows/ci.yml scripts/ci/tests/test_workflow_governance_contract.py +git commit -m "fix(ci): remove privileged execution from fork PR checks" +``` + +若 `ci.yml` 在本任务未改动,必须从 allowlist 中移除它。 + +### Task 3:建立可审计的 owner、风险与 Ruleset desired state + +**Owner:** 仓库管理员 + 领域 owner +**前置:** D1-D4、Task 2 通过 +**Files:** + +- Modify: `.github/CODEOWNERS` +- Create: `.github/governance/README.md` +- Create: `.github/governance/risk-paths.json` +- Create: `.github/governance/rulesets/dev.json` +- Create: `.github/governance/rulesets/master.json` +- Create: `scripts/ci/governance_contract.py` +- Create: `scripts/ci/verify_github_governance.py` +- Create: `scripts/ci/tests/test_governance_contract.py` +- Create: `scripts/ci/tests/test_verify_github_governance.py` +- Create: `scripts/ci/tests/fixtures/github-rulesets.json` + +**Step 1: 写纯函数测试** + +为 `governance_contract.py` 写 fixture 驱动测试,至少覆盖: + +- R0/R1/R2/R3 路径取最高风险; +- PR label 不能把 `.github/workflows/**`、认证、DB、router/store 或 `src/bt_api_py/**` 降级; +- `master` 的 `feature/*` 被拒绝,`release/vX.Y.Z` 与 `hotfix/master-*` 被按不同证据契约处理; +- manifest 缺一个分支、错误 enforcement、遗漏 required check 或无效 bypass actor 时产生可读 diff; +- GitHub API fixture 的无关字段顺序、时间戳和 ID 变化不会造成假漂移。 + +**Step 2: 运行失败测试** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_governance_contract.py \ + scripts/ci/tests/test_verify_github_governance.py -q +``` + +预期:在 risk map、manifest 和 verifier 尚未实现时失败。 + +**Step 3: 最小实现与文件规则** + +1. 保留 `.github/CODEOWNERS` 的 iteration 193 ratchet 条目,在其后新增经过 D2 确认的真实 owner 覆盖。规则从宽到窄排列,至少覆盖 `.github/**`、`scripts/ci/**`、认证/会话/DB/models、frontend router/stores、`src/bt_api_py/**`、Docker/发布脚本和依赖/lock 文件。现有仓库不存在根 `dags/`,本迭代不得添加虚假的 `dags/` owner 规则。 +2. `risk-paths.json` 是风险路由的唯一事实来源,内容包含 `version`、R2/R3 glob 列表、默认 R1、R0 文档规则和“取最高等级”的声明。新增目录只有在同一 PR 同时更新 map、CODEOWNERS、测试与文档时才可降为已治理。 +3. 两个 Ruleset JSON 是**归一化的 desired state**,不复制 GitHub API 返回中的 ID、时间戳、actor 数据或 secrets。每个 manifest 必须包含 target ref、enforcement、bypass 原则、PR 审批、conversation resolution、force/delete 防护、code-owner review 与 required-check 占位来源。required check 名称只能在 D4 的真实 Check Run 证据得到后填写。 +4. `verify_github_governance.py` 只读 GitHub API 或本地 fixture,归一化后比较 manifest,机器输出 JSON、人工输出差异摘要;漂移退出非零。它不创建、更新、删除 Ruleset,也不需要管理员 token。 +5. `dev` 静态规则的目标是:PR、至少一位批准、解决 conversation、受覆盖文件的 code-owner review、禁止 force/delete、稳定 required checks。`master` 在此基础上要求两位独立批准和更强的 release checks。R2/R3 的动态审查数由 Task 4 的治理门禁加固,不能假装 Ruleset 会随 label 变化。 + +**Step 4: 验证** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_governance_contract.py \ + scripts/ci/tests/test_verify_github_governance.py -q +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check scripts/ci +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/verify_github_governance.py --fixture \ + scripts/ci/tests/fixtures/github-rulesets.json --manifest-dir .github/governance/rulesets +git diff --check +``` + +**Step 5: 分阶段应用外部 Ruleset** + +先以 `PR Governance` 的 report-only shadow 运行 10 个工作日。若 GitHub 支持 evaluate,则可同步 evaluate;若不支持,禁止把“无 evaluate”当作跳过观察期的理由。观察期无未解释阻断后,由 D3 指定管理员按 manifest 在 GitHub UI/API 应用 `dev`,再用 verifier/API 读回。`master` 只在三类演练完成后应用。外部设置的截图/API 摘要写入本迭代 evidence,不混入 JSON manifest。 + +**退出条件:** `CODEOWNERS` 无 GitHub errors、无占位 owner;manifest 和实际 Ruleset 的归一化比较为零差异或有批准且未过期的例外;启用前已验证真实 required check context。 + +**Commit / handoff:** + +```bash +git add .github/CODEOWNERS .github/governance/README.md \ + .github/governance/risk-paths.json .github/governance/rulesets/dev.json \ + .github/governance/rulesets/master.json scripts/ci/governance_contract.py \ + scripts/ci/verify_github_governance.py scripts/ci/tests/test_governance_contract.py \ + scripts/ci/tests/test_verify_github_governance.py \ + scripts/ci/tests/fixtures/github-rulesets.json +git commit -m "feat(governance): define owners and ruleset desired state" +``` + +### Task 4:实现稳定的 PR Governance 聚合门禁 + +**Owner:** CI owner +**前置:** Task 2、Task 3 的 risk map 和 manifest 可用 +**Files:** + +- Modify: `.github/workflows/pr-governance.yml` +- Create: `scripts/ci/classify_pr_risk.py` +- Create: `scripts/ci/check_pr_governance.py` +- Create: `scripts/ci/tests/test_classify_pr_risk.py` +- Create: `scripts/ci/tests/test_check_pr_governance.py` +- Modify: `scripts/ci/tests/test_workflow_governance_contract.py` + +**Step 1: 写失败测试和 event fixtures** + +测试必须使用脱敏的 GitHub event/changed-files fixtures,不请求网络。最低案例: + +1. 普通 `feature/*` → `dev`,R1,PR body 具备测试证据; +2. `feature/*` → `master`,被明确阻断并给出正确改投路径; +3. R2 auth/router/store 变更,自动提升风险并要求 owner/额外证据; +4. R3 workflow/lock/release 变更,自动提升风险且不能靠 label 下调; +5. `hotfix/master-*` → `master` 缺 incident/前移字段时失败; +6. `release/vX.Y.Z` → `master` 缺 release 清单时失败; +7. 任意事件类型(PR 打开、同步、编辑、标签变化、review 提交/撤回)都生成同一个 `Governance Gate` check,而不是被条件跳过。 + +**Step 2: 运行失败测试** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_classify_pr_risk.py \ + scripts/ci/tests/test_check_pr_governance.py -q +``` + +**Step 3: 最小实现** + +1. `classify_pr_risk.py` 只接收 changed-files 清单和 `.github/governance/risk-paths.json`;输出建议风险、命中规则和不可下调说明。它不从 PR label 读取风险结论。 +2. `check_pr_governance.py` 校验 base/head、PR 正文治理区块、风险证据、owner/review 状态和 hotfix/release 附加契约。失败信息必须可以让贡献者修正,不能只输出“blocked”。 +3. `pr-governance.yml` 监听 `pull_request_target` 的 opened/synchronize/reopened/edited/ready_for_review/labeled/unlabeled 事件和 `pull_request_review` 的 submitted/edited/dismissed 事件。它只读取 GitHub API metadata 与以 base SHA checkout 的受信任基线配置,始终生成稳定命名的 `Governance Gate` 结果;不得触碰 PR head。若默认分支仍是 `master`,必须先完成 D0 的 `release/governance-bootstrap` PR,否则 `pull_request_target` 不会使用 `dev` 中刚合入的 workflow、脚本和 manifest。 +4. 阴影期只发布结果/摘要,不把它设为 Ruleset required check。启用后,只有这个始终运行的聚合结果可被设为 required;不可将可能被 paths/if 跳过的 job 直接设为 required。 +5. 现有 `e2e.yml` 在本阶段保持当前覆盖范围。若未来要把 R0/R1 的 e2e 改成条件执行,必须先设计一个始终报告的 `E2E Gate` 聚合 job,并用新的独立变更证明 required check 不会缺失;本迭代不得在未证明前靠 paths filter 缩减门禁。 + +**Step 4: 验证** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_classify_pr_risk.py \ + scripts/ci/tests/test_check_pr_governance.py \ + scripts/ci/tests/test_workflow_governance_contract.py -q +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check scripts/ci +git diff --check +``` + +在内部草稿 PR 中演练 3 个情形:普通 R1 → `dev`、R2 → `dev`、`hotfix/master-*` → `master`。记录实际 check display name、事件重跑行为、所请求的 review 与可读阻断消息,供 D4 和 Task 3 使用。 + +**退出条件:** 没有 PR 因 skipped check 永久等待;目标分支和风险分级不能靠作者字段绕过;fork PR 不需写 token 或 secrets 即可获得治理反馈。 + +**Commit / handoff:** + +```bash +git add .github/workflows/pr-governance.yml scripts/ci/classify_pr_risk.py \ + scripts/ci/check_pr_governance.py scripts/ci/tests/test_classify_pr_risk.py \ + scripts/ci/tests/test_check_pr_governance.py \ + scripts/ci/tests/test_workflow_governance_contract.py +git commit -m "feat(ci): add stable pull request governance gate" +``` + +### Task 5:收紧 release、如实处理 preview,并监控镜像同步 + +**Owner:** 发布/平台 owner +**前置:** D1、D5、D6;Task 2 已消除 fork 特权路径 +**Files:** + +- Modify: `.github/workflows/docker-publish.yml` +- Modify: `.github/workflows/deploy-preview.yml` +- Modify: `.github/workflows/nightly.yml` +- Create: `scripts/ci/check_remote_sync.py` +- Create: `scripts/ci/tests/test_check_remote_sync.py` +- Create: `scripts/ci/tests/test_release_workflow_contract.py` +- Create: `.github/governance/remote-sync-exceptions.json`(仅 D1 批准且存在有到期日的当前漂移例外时) + +**Step 1: 写失败测试** + +release workflow contract 至少验证: + +- 版本 tag 解析出的 commit 必须等于完整 fetch 后的 `origin/master` HEAD; +- 删除或严格禁用带任意 `image_tag` 的手动发布入口;失败重跑使用 GitHub re-run,而不是新输入覆盖 tag; +- 发布凭据只在受保护的 release environment job 中使用; +- build 输出的两个 image digest、tag 与 commit SHA 被写入 release metadata artifact; +- preview workflow 不再输出“Deployed”或虚构 URL,且不需要 `pull-requests: write`。 + +remote sync 测试至少覆盖:完全一致、未列入例外的差异失败、列入 owner/issue/expires_at 的未过期例外为 warning、过期例外失败。测试传入伪造的 `git ls-remote` 输出,绝不访问真实远端。 + +**Step 2: 运行失败测试** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_release_workflow_contract.py \ + scripts/ci/tests/test_check_remote_sync.py -q +``` + +**Step 3: 最小实现** + +1. `docker-publish.yml` 仅响应受保护版本 tag;checkout 必须获得完整历史,使用 tag 解析出的 commit 与 `origin/master` HEAD 做**相等**比较,而不只比较“是其祖先”。未通过时在 Docker login 前失败。手动 `image_tag` 发布入口删除;如保留 `workflow_dispatch` 用于诊断,必须不能 push 且明确标为 dry-run。 +2. 构建后保存 `commit_sha`、`tag`、backend digest、frontend digest 的 JSON artifact 和 job summary。由受保护发布流程/人工 release manager 将该 artifact 附到 GitHub Release;不可伪造 digest,也不可用 `latest` 取代不可变 digest。 +3. 将 `deploy-preview.yml` 改名为“Preview Build Artifact”,删除 URL 生成、部署宣称和 PR 评论写权限。它只在 D5 承认的路径上构建、上传可下载 artifact 并说明“非托管预览”;真实环境部署单独立项。 +4. `check_remote_sync.py` 只调用 `git ls-remote --heads` 比较 D1 指定的 source/mirror 的 `master`、`dev`;输出机器 JSON 与人工摘要,不能改变 refs。`nightly.yml` 仅在 D1 已提供可达通知路径时将失败通知责任人;否则失败状态本身和 artifact 是可见证据,不得宣称已告警。 +5. 已存在的 `master` 差异只能由 `.github/governance/remote-sync-exceptions.json` 中带 issue、owner、原因、创建时间和 `expires_at` 的条目临时解释。例外不是永久白名单。 + +**Step 4: 验证** + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + scripts/ci/tests/test_release_workflow_contract.py \ + scripts/ci/tests/test_check_remote_sync.py -q +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/check_remote_sync.py --source https://github.com/cloudQuant/backtrader_web.git \ + --mirror https://gitee.com/yunjinqi/backtrader_web.git --branches master dev \ + --exceptions .github/governance/remote-sync-exceptions.json +git diff --check +``` + +在非生产 release 演练中,创建一个已合并到 `master` 的测试 release PR 和符合策略的测试 tag;验证 tag 不在 `master` HEAD 时发布被阻断,正确 tag 的 artifact 包含实际 digest。不得用生产 Docker Hub 凭据在未批准环境中演练。 + +**退出条件:** 镜像只能基于 `master` 当前 HEAD 的受保护 tag 产生;preview 不再虚假部署;镜像远端偏移有可读、可过期、不可自动修复的证据链。 + +**Commit / handoff:** + +```bash +git add .github/workflows/docker-publish.yml .github/workflows/deploy-preview.yml \ + .github/workflows/nightly.yml scripts/ci/check_remote_sync.py \ + scripts/ci/tests/test_check_remote_sync.py \ + scripts/ci/tests/test_release_workflow_contract.py +if test -f .github/governance/remote-sync-exceptions.json; then + git add .github/governance/remote-sync-exceptions.json +fi +git commit -m "fix(release): verify tag provenance and mirror drift" +``` + +### Task 6:灰度启用、演练、度量与回滚证据 + +**Owner:** 治理负责人 + QA + 仓库管理员 +**前置:** Task 1-5 已合并到 `dev`;D0-D7 状态有效 +**Files:** + +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md` +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/ruleset-readback.md` +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/weekly-metrics-schema.md` +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/rollback-runbook.md` + +**Step 1: 完成 10 个工作日 shadow period** + +`PR Governance` 只报告不阻断。每日审阅错误分类、遗漏/重复 check、fork PR 反馈、误投 `master`、owner 请求是否成功以及执行时间。任何未解释的 P0/P1 误阻塞都重开相应 Task,不进入 active。 + +**Step 2: 演练三类 PR** + +| 演练 | 必须证明 | +|---|---| +| R1 普通 PR → `dev` | 默认路径、稳定 checks、一个维护者审批、无不必要重型阻断。 | +| R2 核心路径 PR → `dev` | 路径自动升为 R2、owner 请求、额外测试/证据与审批记录。 | +| `hotfix/master-*` 或 `release/vX.Y.Z` → `master` | 来源命名、证据、两人审批、完整 checks、前移或 release metadata;不能用直接 push 替代。 | + +草稿 PR 不得修改生产凭据、不能合并未审代码;记录 PR URL、commit SHA、实际 check display name、review 状态与结果,不复制私人评论或 token。 + +**Step 3: 激活并读回 Ruleset** + +先激活 `dev`,复验一轮普通/R2 PR;再激活 `master`。每次外部设置后立即运行: + +```bash +gh api repos/cloudQuant/backtrader_web/rulesets --paginate +gh api repos/cloudQuant/backtrader_web/codeowners/errors +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/verify_github_governance.py --live \ + --repo cloudQuant/backtrader_web --manifest-dir .github/governance/rulesets +``` + +**Step 4: 运行四周度量** + +每周记录总 PR 数、目标分支误投率、首次实质评审时间、合并周期、治理门禁误报/漏报、R2/R3 owner 覆盖、热修复前移完成率、remote sync 延迟与 bypass 数。度量分母、数据提取日期和排除规则必须写在 `weekly-metrics-schema.md`;无样本时写 `N/A`,不得伪造 100%。 + +**Step 5: 可回滚但可审计** + +`rollback-runbook.md` 必须区分: + +- workflow/doc 回滚:通过 PR revert,保留失败证据; +- Ruleset 误阻塞:管理员将对应规则改回 evaluate(若平台支持)或暂时停用,并记录 incident、影响 PR、负责人、恢复条件; +- CODEOWNERS 错误:修正 owner 后再重新启用 code-owner required review; +- 镜像/远端异常:停止发布/创建 incident,不自动覆盖远端。 + +**退出条件:** 观察期无未解释 P0/P1 误阻塞;三类 PR 端到端演练成功;Ruleset 读回与 manifest 一致;四周度量已开始且能复现;所有临时例外有到期日。 + +**Commit / handoff:** + +```bash +git add docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/ruleset-readback.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/weekly-metrics-schema.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/rollback-runbook.md +git commit -m "docs(governance): record rollout evidence and rollback runbook" +``` + +## 6. Required-check 与验收矩阵 + +required check 的最终字符串必须来自 Task 4 演练的实际 Check Run,以下为角色合同而非可直接填入 GitHub UI 的猜测名称。 + +| 目标分支 | 必须始终报告的角色 | 何时转为 required | 不可接受的替代 | +|---|---|---|---| +| `dev` | 快速 CI 汇总、`Governance Gate` | shadow 通过、D4 记录 exact context、Ruleset 读回成功 | 将可跳过的 paths job、`merge-ready` label 或单个局部测试设为唯一门禁。 | +| `master` | `dev` 的门禁 + 完整 e2e/release 验证汇总 | release/hotfix 演练证明均稳定报告 | 只看 tag 格式、只看 Docker build 成功或让管理员常规 bypass。 | +| 发行 tag | tag provenance、受保护 environment、digest metadata | D6 通过且 tag 指向 `master` HEAD | 手动输入 image tag、任意分支先打 tag 后补审批、`latest`。 | + +| 验收维度 | 必须保存的证据 | 通过标准 | +|---|---|---| +| 基线与决策 | `preflight.md`、D0-D7 | 事实与后续设置可追溯;阻塞项未被假装解决。 | +| 分支语义 | 双语文档、CONTRIBUTING、AGENTS、PR 模板、三类 PR 演练 | 所有入口一致说明 `dev` 常规、`master` release/hotfix。 | +| 安全 | workflow 契约测试、fork 草稿 PR、权限 diff | 没有特权 `pull_request_target` 执行 PR 代码;没有以 bot label 代替审核。 | +| 所有权 | CODEOWNERS errors API、owner 矩阵、review request | 受保护域有真实可用 owner;既有 ratchet owner 仍被保留。 | +| Ruleset | desired manifest、API/界面读回、verifier 输出 | `master`、`dev` 都由 active 规则覆盖,或未启用原因有时限记录。 | +| CI | 三类 PR check 记录 | 所有 required context 稳定报告;R2/R3 只能升级而不能降级。 | +| 发布 | tag 反例/正例、release metadata artifact、environment 记录 | 非 `master` HEAD tag 被阻断;成功发行含 SHA/tag/digest。 | +| 远端镜像 | `check_remote_sync.py` JSON、incident/例外 | 一致或存在未过期、有人负责的显式差异;无自动覆写。 | +| 运行治理 | 四周周报、bypass/rollback 记录 | 指标分母清楚、例外可解释、没有以“CI 绿”替代人工审查。 | + +## 7. 失败模式与防误伤措施 + +| 失败模式 | 预防 | 恢复方式 | +|---|---|---| +| 新 Ruleset 让所有 PR 卡住 | 先 shadow、再 `dev`、最后 `master`;required check 只选始终运行的聚合结果 | 按 runbook 暂停/回退规则,保留 incident 和 affected PR 列表。 | +| fork PR 借 `pull_request_target` 获得写权限/执行权限 | 删除特权执行;治理工作流仅可只读、以 base SHA checkout 并执行标准库脚本,绝不安装依赖/使用 secrets/读取 head | 立即停用工作流、轮换可能暴露凭据、审查 workflow run 和审计日志。 | +| owner 缺席或无权限 | D2 前不启用 code-owner requirement;指定备用 owner | 修复 CODEOWNERS 后再激活,不使用全局管理员常规绕过。 | +| label 被用来伪造低风险 | 路径最高风险不可下降;覆盖留痕且需批准 | 移除违规 label/覆盖,按计算结果重新跑治理门禁。 | +| 条件 job 不产生 required check | 聚合 gate 始终运行;实际草稿 PR 验证 context | 从 Ruleset 移除不稳定 context,修正 workflow 后再重新启用。 | +| preview 假成功 | 默认只称 build artifact;真实托管另立项 | 删除错误评论,向受影响 PR 说明没有实际环境。 | +| tag 从错误提交发布镜像 | 强制 tag commit 与 `origin/master` HEAD 相等 | 停止发布、撤回/标记错误 tag,按发布 incident 处理。 | +| GitHub/Gitee 漂移被 CI 自动覆盖 | 检查只读;例外有 expiry;D1 指定人工方案 | 暂停镜像、创建 incident,由授权管理员执行明确同步。 | +| 计划与其他“迭代195”草案混淆 | 在 issue/看板使用唯一治理工作流 ID;提交仅含本计划路径 | 停止交叉改动,拆分 PR 并明确对应验收矩阵。 | + +## 8. 完成定义 + +本迭代只能在以下条件全部满足时标记完成: + +1. 本计划标题、范围和证据与实际仓库一致,且 D0-D7 均已通过或有明确、未过期的阻塞/例外记录; +2. 贡献者可以从文档、Issue/PR 入口和治理检查一致地得出正确目标分支; +3. fork PR 不再通过具有写权限的 `pull_request_target` 执行不受信任代码; +4. `CODEOWNERS` 既保留现有 ratchet 保护,又覆盖已确认的核心域,且 GitHub API 无 errors; +5. 当默认分支为 `master` 时,安全的治理工作流骨架已先经 `release/governance-bootstrap` PR 进入默认分支;`dev` 与 `master` 的 active Ruleset 已由 API 读回并与 desired manifest 比对,或外部能力/人员阻塞被如实保留; +6. 普通 R1、核心 R2 和 `master` hotfix/release 三类草稿 PR 均有端到端证据; +7. 镜像仅由指向 `master` 当前 HEAD 的受保护 tag 发布,成功记录 SHA/tag/digest,preview 不再虚构部署; +8. GitHub/Gitee 的 ref 一致性有可查询证据,任何差异均有 owner、incident 和到期日期; +9. 四周治理度量已启动,且没有使用“CI 绿色”“自动标签”或“测试数量”替代人工授权与风险判断。 + +在满足上述条件前,本文件仍是待实施的治理设计,不能宣称仓库已具备可规模化、可审计的社区 PR 治理能力。 From 42e96a150159764ab50f9cecac81f553ba5928e2 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 00:40:42 +0800 Subject: [PATCH 05/32] docs(governance): harden iteration 195 acceptance plan --- ...55\344\273\243\350\256\241\345\210\222.md" | 133 ++++++++++++++---- 1 file changed, 103 insertions(+), 30 deletions(-) diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" index a404dd48d..e9506b4d3 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" @@ -1,7 +1,8 @@ # 迭代195:社区 PR 治理与双分支协作正式迭代计划 > 计划日期:2026-08-23 -> 状态:**设计稿 / 未授权实施** +> 最近验收:2026-08-24 +> 状态:**已验收 / 实施中**(Task 0 已完成;D4、D7 仍阻塞;D3、D6 为有到期日的例外) > 适用仓库:`cloudQuant/backtrader_web` > 目标分支模型:`master`(发布主干)与 `dev`(日常集成) > 计划边界:本文件只定义治理、文档、GitHub 配置与相关自动化的交付方案;不实施业务功能、不修改远端设置、不推送、不合并。 @@ -29,6 +30,9 @@ 7. GitHub 与 Gitee 的 `dev` 在本次快照中同 SHA,但 `master` 已不同 SHA;监控不得把当前漂移误报为“已一致”,更不得由 CI 自动 force-push 修复。 8. 原稿中的 `dags/` 与 `tests/unit/scripts/` 不是当前仓库路径;风险映射、CODEOWNERS 和测试位置改为当前真实目录,未来新增 Airflow/DAG 根目录时再通过单独 PR 扩展。 9. `CONTRIBUTING.md` 中的 `YOUR_USERNAME/backtrader_web.git` 是 fork 克隆占位符,不是错误仓库名;应补充替换说明和 `upstream` 配置,而不是改成所有贡献者都克隆上游。 +10. Task 3 与 D4/Task 4 原有循环依赖已拆开:先交付不含猜测 check context 的 risk map 和 Ruleset 草案,再由 Task 4 实际 PR 演练解除 D4,最后回填并应用 required checks。 +11. D6 所称“受保护版本 tag”必须有独立的 tag Ruleset desired state;不能只靠 Docker workflow 内的 SHA 判断冒充服务器端 tag 保护。 +12. 验证命令已收敛到各任务 owned paths;可选例外文件、非 PR checker 和 release 演练均提供不会误失败或误触发生产发布的执行方式。 本计划与仓库中已存在的“跨平台产品发布”迭代195草案是**两个独立范围**。本计划不修改其文件、产品运行时、Compose、数据库迁移或跨平台发布承诺;实施前应在 issue/项目看板中为本计划使用唯一的治理工作流标识,避免同一迭代编号被误当作同一交付物。 @@ -36,6 +40,8 @@ 下表是本计划编制时的事实快照,不是未来实施完成的声明。每次实际落地前必须重新采集,并把结果保存为本迭代证据。 +2026-08-24 验收时再次执行第 1.1 节只读查询,默认分支、Ruleset/Protection、CODEOWNERS errors 以及四个远端 ref SHA 均与 2026-08-23 已提交的 `preflight.md` 一致;本次验收未修改任何远端设置。 + | 项目 | 2026-08-23 只读结论 | 证据与影响 | |---|---|---| | 默认分支 | GitHub API 返回 `master` | 新 fork/新 PR 默认基线会偏向 `master`;D0 必须决定保持它还是切换为 `dev`,且两种选择都要有防误投机制。 | @@ -133,7 +139,7 @@ flowchart LR ## 4. 交付队列、所有权与并行边界 -所有仓库内变更先进入 `feature/iteration-195-pr-governance-*` 分支并 PR → `dev`。外部设置只在对应仓库 PR 合并且 D 门签署后由管理员执行。每个提交使用显式路径 allowlist;禁止 `git add .`、清理他人改动或把外部设置截图当作代码提交。 +所有仓库内变更先进入 `feature/iteration-195-pr-governance-*` 分支并 PR → `dev`。外部设置只在对应仓库 PR 合并且 D 门签署后由管理员执行。每个提交使用显式路径 allowlist;禁止 `git add .`、清理他人改动或把外部设置截图当作代码提交。所有 `git diff --check`、lint 和暂存命令也必须限制在当前任务 owned paths;若 index 已有他人暂存内容,使用显式 pathspec/`git commit --only` 隔离本任务提交,并在提交后确认原有 index 状态未被改变。 | 工作包 | 主责 | 允许改动的主路径 | 与其他工作包的边界 | |---|---|---|---| @@ -172,7 +178,9 @@ flowchart LR ```bash rg -n 'D[0-7].*(通过|阻塞|例外)' .github/governance/decisions/iteration-195.md -git diff --check +git diff --check -- .github/governance/decisions/iteration-195.md \ + .github/governance/decisions/remote-sync-incident.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/preflight.md ``` **退出条件:** D0-D7 均有明确状态;D1/D2/D3/D4/D6/D7 未通过时,后续相应任务只能准备草案,不得启用阻塞规则或发布路径。 @@ -235,7 +243,7 @@ git commit -m "docs(governance): record iteration 195 decisions and baseline" 2. 中英文在线文档使用 `docs/docs/{zh,en}/development/contributing.md`,并在 `mkdocs.yml` 的双语 Development 导航中加入该页;根 README 只保留简短、互相一致的入口。 3. `AGENTS.md` 增加“常规仓库变更 PR → `dev`;不得直接向 `master` 提交”的协作边界,但不复制整份发布手册。 4. PR 模板增加稳定标题:`## Governance declaration`、目标分支理由、自动/人工风险等级、测试证据、以及仅 `master` 可见的 hotfix 前移或 release 清单。保留现有 i18n 和 bundle-size 区块,不改其语义。 -5. 将 Issue Template 转为 Issue Forms:bug form 采集版本/环境/最小复现/期望与实际;feature form 采集问题、用户、范围与替代方案;`config.yml` 将一般问题指向 Discussions,并把安全问题指向 D7 已确认的私密通道。 +5. 将 Issue Template 转为 Issue Forms:bug form 采集版本/环境/最小复现/期望与实际;feature form 采集问题、用户、范围与替代方案;`config.yml` 将一般问题指向 Discussions。只有 D7 已通过时才加入已验证的私密安全通道;D7 阻塞期间不得写候选 URL、猜测邮箱或公开 issue 替代路径。 6. 仅在 D7 通过后新增根 `SECURITY.md`,写实际报告渠道、支持版本、确认时限和不在公开 issue 披露漏洞的说明。不得凭空承诺邮箱、私信或 SLA。 **Step 4: 验证** @@ -243,16 +251,31 @@ git commit -m "docs(governance): record iteration 195 decisions and baseline" ```bash /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ scripts/ci/tests/test_pr_template_contract.py -q -/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_pr_template.py +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check \ + scripts/ci/check_pr_template.py scripts/ci/tests/test_pr_template_contract.py +( + export PR_BODY=$'## Governance declaration\n- 目标分支: dev\n- 风险等级: R1\n- 测试证据: pytest contract' + export CHANGED_FILES='README.md' + export GOVERNANCE_PR_KIND='normal' + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/check_pr_template.py +) DOCS_ITER195_SITE_DIR="$(mktemp -d)" ( cd docs && /Users/yunjinqi/opt/anaconda3/bin/conda run -n base \ mkdocs build --strict --site-dir "$DOCS_ITER195_SITE_DIR" ) -git diff --check +git diff --check -- CONTRIBUTING.md README.md README.en.md AGENTS.md SECURITY.md \ + docs/docs/zh/development/contributing.md docs/docs/en/development/contributing.md \ + docs/docs/zh/development/index.md docs/docs/en/development/index.md docs/mkdocs.yml \ + .github/PULL_REQUEST_TEMPLATE.md .github/ISSUE_TEMPLATE/config.yml \ + .github/ISSUE_TEMPLATE/bug_report.yml .github/ISSUE_TEMPLATE/feature_request.yml \ + .github/ISSUE_TEMPLATE/bug_report.md .github/ISSUE_TEMPLATE/feature_request.md \ + .github/ISSUE_TEMPLATE/question.md scripts/ci/check_pr_template.py \ + scripts/ci/tests/test_pr_template_contract.py ``` -`check_pr_template.py` 在非 PR 环境可保持其既有“未提供 PR_BODY”退出语义;测试必须显式传入 PR_BODY/CHANGED_FILES,不可把本地空环境当作通过证据。 +`check_pr_template.py` 在非 PR 环境可保持其既有“未提供 PR_BODY”退出码 2;该路径不是通过证据。上面的 smoke 命令和 pytest fixture 必须显式传入 `PR_BODY`/`CHANGED_FILES`,并以退出码 0 证明有效正文被接受。 **退出条件:** 双语文档、README、CONTRIBUTING、AGENTS 和模板对分支语义一致;Issue Forms 在 GitHub UI 演练可用;安全说明不含虚构联系信息;既有 i18n 模板验证未回归。 @@ -264,8 +287,9 @@ git add CONTRIBUTING.md README.md README.en.md AGENTS.md \ docs/docs/zh/development/index.md docs/docs/en/development/index.md docs/mkdocs.yml \ .github/PULL_REQUEST_TEMPLATE.md .github/ISSUE_TEMPLATE/config.yml \ .github/ISSUE_TEMPLATE/bug_report.yml .github/ISSUE_TEMPLATE/feature_request.yml \ - scripts/ci/check_pr_template.py scripts/ci/tests/test_pr_template_contract.py -git add -u .github/ISSUE_TEMPLATE + .github/ISSUE_TEMPLATE/bug_report.md .github/ISSUE_TEMPLATE/feature_request.md \ + .github/ISSUE_TEMPLATE/question.md scripts/ci/check_pr_template.py \ + scripts/ci/tests/test_pr_template_contract.py if test -f SECURITY.md; then git add SECURITY.md; fi git commit -m "docs(governance): clarify contribution and disclosure flow" ``` @@ -313,7 +337,9 @@ git commit -m "docs(governance): clarify contribution and disclosure flow" ```bash /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ scripts/ci/tests/test_workflow_governance_contract.py -q -git diff --check +git diff --check -- .github/workflows/pr-check.yml \ + .github/workflows/pr-governance.yml .github/workflows/ci.yml \ + scripts/ci/tests/test_workflow_governance_contract.py ``` 在 GitHub 中至少用一个 fork 草稿 PR 验证:不受信任 head 的代码仍能在 `pull_request` 门禁中被测试;若治理工作流使用 `pull_request_target`,其日志只显示 base SHA checkout,且不存在写权限、secrets 或 PR head 执行路径。 @@ -333,7 +359,7 @@ git commit -m "fix(ci): remove privileged execution from fork PR checks" ### Task 3:建立可审计的 owner、风险与 Ruleset desired state **Owner:** 仓库管理员 + 领域 owner -**前置:** D1-D4、Task 2 通过 +**前置:** D1/D2 已有决策记录、Task 2 通过;D3 若仍是有效例外,只能交付 desired state 和 shadow verifier。D4 **不是**本任务草案阶段的前置,未解除前不得填入或启用 required-check context。 **Files:** - Modify: `.github/CODEOWNERS` @@ -341,6 +367,7 @@ git commit -m "fix(ci): remove privileged execution from fork PR checks" - Create: `.github/governance/risk-paths.json` - Create: `.github/governance/rulesets/dev.json` - Create: `.github/governance/rulesets/master.json` +- Create: `.github/governance/rulesets/release-tags.json` - Create: `scripts/ci/governance_contract.py` - Create: `scripts/ci/verify_github_governance.py` - Create: `scripts/ci/tests/test_governance_contract.py` @@ -354,7 +381,7 @@ git commit -m "fix(ci): remove privileged execution from fork PR checks" - R0/R1/R2/R3 路径取最高风险; - PR label 不能把 `.github/workflows/**`、认证、DB、router/store 或 `src/bt_api_py/**` 降级; - `master` 的 `feature/*` 被拒绝,`release/vX.Y.Z` 与 `hotfix/master-*` 被按不同证据契约处理; -- manifest 缺一个分支、错误 enforcement、遗漏 required check 或无效 bypass actor 时产生可读 diff; +- manifest 缺少 `dev`、`master` 或 release tag 任一目标、错误 enforcement、遗漏已确认的 required check 或无效 bypass actor 时产生可读 diff; - GitHub API fixture 的无关字段顺序、时间戳和 ID 变化不会造成假漂移。 **Step 2: 运行失败测试** @@ -371,7 +398,7 @@ git commit -m "fix(ci): remove privileged execution from fork PR checks" 1. 保留 `.github/CODEOWNERS` 的 iteration 193 ratchet 条目,在其后新增经过 D2 确认的真实 owner 覆盖。规则从宽到窄排列,至少覆盖 `.github/**`、`scripts/ci/**`、认证/会话/DB/models、frontend router/stores、`src/bt_api_py/**`、Docker/发布脚本和依赖/lock 文件。现有仓库不存在根 `dags/`,本迭代不得添加虚假的 `dags/` owner 规则。 2. `risk-paths.json` 是风险路由的唯一事实来源,内容包含 `version`、R2/R3 glob 列表、默认 R1、R0 文档规则和“取最高等级”的声明。新增目录只有在同一 PR 同时更新 map、CODEOWNERS、测试与文档时才可降为已治理。 -3. 两个 Ruleset JSON 是**归一化的 desired state**,不复制 GitHub API 返回中的 ID、时间戳、actor 数据或 secrets。每个 manifest 必须包含 target ref、enforcement、bypass 原则、PR 审批、conversation resolution、force/delete 防护、code-owner review 与 required-check 占位来源。required check 名称只能在 D4 的真实 Check Run 证据得到后填写。 +3. 三个 Ruleset JSON 是**归一化的 desired state**,不复制 GitHub API 返回中的 ID、时间戳、actor 数据或 secrets。`dev.json` 与 `master.json` 必须包含 target ref、enforcement、bypass 原则、PR 审批、conversation resolution、force/delete 防护、code-owner review 与 required-check 占位来源;`release-tags.json` 覆盖 D6 确认的 `refs/tags/v*` 版本格式,限制 tag 创建、更新和删除,并把可授权主体留给 D3/D6 的真实能力验证。required check 名称只能在 D4 的真实 Check Run 证据得到后填写,actor ID 只能来自 API 读回,不能猜测。 4. `verify_github_governance.py` 只读 GitHub API 或本地 fixture,归一化后比较 manifest,机器输出 JSON、人工输出差异摘要;漂移退出非零。它不创建、更新、删除 Ruleset,也不需要管理员 token。 5. `dev` 静态规则的目标是:PR、至少一位批准、解决 conversation、受覆盖文件的 code-owner review、禁止 force/delete、稳定 required checks。`master` 在此基础上要求两位独立批准和更强的 release checks。R2/R3 的动态审查数由 Task 4 的治理门禁加固,不能假装 Ruleset 会随 label 变化。 @@ -381,16 +408,25 @@ git commit -m "fix(ci): remove privileged execution from fork PR checks" /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ scripts/ci/tests/test_governance_contract.py \ scripts/ci/tests/test_verify_github_governance.py -q -/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check scripts/ci +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check \ + scripts/ci/governance_contract.py scripts/ci/verify_github_governance.py \ + scripts/ci/tests/test_governance_contract.py \ + scripts/ci/tests/test_verify_github_governance.py /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ scripts/ci/verify_github_governance.py --fixture \ scripts/ci/tests/fixtures/github-rulesets.json --manifest-dir .github/governance/rulesets -git diff --check +git diff --check -- .github/CODEOWNERS .github/governance/README.md \ + .github/governance/risk-paths.json .github/governance/rulesets/dev.json \ + .github/governance/rulesets/master.json \ + .github/governance/rulesets/release-tags.json scripts/ci/governance_contract.py \ + scripts/ci/verify_github_governance.py scripts/ci/tests/test_governance_contract.py \ + scripts/ci/tests/test_verify_github_governance.py \ + scripts/ci/tests/fixtures/github-rulesets.json ``` **Step 5: 分阶段应用外部 Ruleset** -先以 `PR Governance` 的 report-only shadow 运行 10 个工作日。若 GitHub 支持 evaluate,则可同步 evaluate;若不支持,禁止把“无 evaluate”当作跳过观察期的理由。观察期无未解释阻断后,由 D3 指定管理员按 manifest 在 GitHub UI/API 应用 `dev`,再用 verifier/API 读回。`master` 只在三类演练完成后应用。外部设置的截图/API 摘要写入本迭代 evidence,不混入 JSON manifest。 +本步骤明确延后到 Task 4 的真实 PR 演练解除 D4 之后。先由 D4 决策人根据演练证据签署实际 check context,再由本任务 owner 回填 `dev.json`/`master.json` 并重新运行 fixture verifier;禁止在 D4 阻塞时猜名称。随后让 `PR Governance` report-only shadow 运行 10 个工作日。若 GitHub 支持 evaluate,则可同步 evaluate;若不支持,禁止把“无 evaluate”当作跳过观察期的理由。观察期无未解释阻断后,由 D3 指定管理员按 manifest 在 GitHub UI/API 应用 `dev`,再用 verifier/API 读回。`master` 只在三类演练完成后应用;`release-tags` 还必须等待 D6 的 tag 权限与 release environment 条件满足。外部设置的截图/API 摘要写入本迭代 evidence,不混入 JSON manifest。 **退出条件:** `CODEOWNERS` 无 GitHub errors、无占位 owner;manifest 和实际 Ruleset 的归一化比较为零差异或有批准且未过期的例外;启用前已验证真实 required check context。 @@ -399,13 +435,16 @@ git diff --check ```bash git add .github/CODEOWNERS .github/governance/README.md \ .github/governance/risk-paths.json .github/governance/rulesets/dev.json \ - .github/governance/rulesets/master.json scripts/ci/governance_contract.py \ + .github/governance/rulesets/master.json .github/governance/rulesets/release-tags.json \ + scripts/ci/governance_contract.py \ scripts/ci/verify_github_governance.py scripts/ci/tests/test_governance_contract.py \ scripts/ci/tests/test_verify_github_governance.py \ scripts/ci/tests/fixtures/github-rulesets.json git commit -m "feat(governance): define owners and ruleset desired state" ``` +D4 解除后的 context 回填必须使用独立 allowlist 提交(仅两个分支 manifest、D4 决策记录和对应 evidence),建议提交信息:`chore(governance): bind verified required check contexts`。tag Ruleset 不承载 PR check context。 + ### Task 4:实现稳定的 PR Governance 聚合门禁 **Owner:** CI owner @@ -418,6 +457,7 @@ git commit -m "feat(governance): define owners and ruleset desired state" - Create: `scripts/ci/tests/test_classify_pr_risk.py` - Create: `scripts/ci/tests/test_check_pr_governance.py` - Modify: `scripts/ci/tests/test_workflow_governance_contract.py` +- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md`(本任务先记录 D4 所需演练;Task 6 继续补全) **Step 1: 写失败测试和 event fixtures** @@ -454,11 +494,20 @@ git commit -m "feat(governance): define owners and ruleset desired state" scripts/ci/tests/test_classify_pr_risk.py \ scripts/ci/tests/test_check_pr_governance.py \ scripts/ci/tests/test_workflow_governance_contract.py -q -/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check scripts/ci -git diff --check +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check \ + scripts/ci/classify_pr_risk.py scripts/ci/check_pr_governance.py \ + scripts/ci/tests/test_classify_pr_risk.py \ + scripts/ci/tests/test_check_pr_governance.py \ + scripts/ci/tests/test_workflow_governance_contract.py +git diff --check -- .github/workflows/pr-governance.yml \ + scripts/ci/classify_pr_risk.py scripts/ci/check_pr_governance.py \ + scripts/ci/tests/test_classify_pr_risk.py \ + scripts/ci/tests/test_check_pr_governance.py \ + scripts/ci/tests/test_workflow_governance_contract.py \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md ``` -在内部草稿 PR 中演练 3 个情形:普通 R1 → `dev`、R2 → `dev`、`hotfix/master-*` → `master`。记录实际 check display name、事件重跑行为、所请求的 review 与可读阻断消息,供 D4 和 Task 3 使用。 +在内部草稿 PR 中演练 3 个情形:普通 R1 → `dev`、R2 → `dev`、`hotfix/master-*` → `master`。把 PR URL、commit SHA、实际 check display name、事件重跑行为、所请求的 review 与可读阻断消息写入 `evidence/dry-run-matrix.md`。D4 决策人据此解除或继续阻塞 D4;只有解除后才能返回 Task 3 Step 5 回填并应用 required checks。 **退出条件:** 没有 PR 因 skipped check 永久等待;目标分支和风险分级不能靠作者字段绕过;fork PR 不需写 token 或 secrets 即可获得治理反馈。 @@ -468,14 +517,15 @@ git diff --check git add .github/workflows/pr-governance.yml scripts/ci/classify_pr_risk.py \ scripts/ci/check_pr_governance.py scripts/ci/tests/test_classify_pr_risk.py \ scripts/ci/tests/test_check_pr_governance.py \ - scripts/ci/tests/test_workflow_governance_contract.py + scripts/ci/tests/test_workflow_governance_contract.py \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md git commit -m "feat(ci): add stable pull request governance gate" ``` ### Task 5:收紧 release、如实处理 preview,并监控镜像同步 **Owner:** 发布/平台 owner -**前置:** D1、D5、D6;Task 2 已消除 fork 特权路径 +**前置:** D1、D5 已通过,D6 已记录明确状态;D6 若仍是有效例外,只能实现 contract、dry-run 与默认禁用的生产 job,不得取得或使用生产发布凭据。Task 2 已消除 fork 特权路径。 **Files:** - Modify: `.github/workflows/docker-publish.yml` @@ -520,14 +570,27 @@ remote sync 测试至少覆盖:完全一致、未列入例外的差异失败 /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ scripts/ci/tests/test_release_workflow_contract.py \ scripts/ci/tests/test_check_remote_sync.py -q +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base ruff check \ + scripts/ci/check_remote_sync.py scripts/ci/tests/test_check_remote_sync.py \ + scripts/ci/tests/test_release_workflow_contract.py +REMOTE_SYNC_EXCEPTION_ARGS=() +if test -f .github/governance/remote-sync-exceptions.json; then + REMOTE_SYNC_EXCEPTION_ARGS=( + --exceptions .github/governance/remote-sync-exceptions.json + ) +fi /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ scripts/ci/check_remote_sync.py --source https://github.com/cloudQuant/backtrader_web.git \ --mirror https://gitee.com/yunjinqi/backtrader_web.git --branches master dev \ - --exceptions .github/governance/remote-sync-exceptions.json -git diff --check + "${REMOTE_SYNC_EXCEPTION_ARGS[@]}" +git diff --check -- .github/workflows/docker-publish.yml \ + .github/workflows/deploy-preview.yml .github/workflows/nightly.yml \ + .github/governance/remote-sync-exceptions.json scripts/ci/check_remote_sync.py \ + scripts/ci/tests/test_check_remote_sync.py \ + scripts/ci/tests/test_release_workflow_contract.py ``` -在非生产 release 演练中,创建一个已合并到 `master` 的测试 release PR 和符合策略的测试 tag;验证 tag 不在 `master` HEAD 时发布被阻断,正确 tag 的 artifact 包含实际 digest。不得用生产 Docker Hub 凭据在未批准环境中演练。 +默认演练使用本地 fixture 和不可 push 的 `workflow_dispatch` dry-run,分别模拟 tag 不在 `master` HEAD(必须在 Docker login 前失败)与 tag 正确(只生成 metadata/build artifact,不登录、不发布);**不得**在 `cloudQuant/backtrader_web` 创建或推送演练 tag。只有 D6 记录了独立 staging 仓库、受保护 staging environment 和明确授权时,才可在该仓库用符合生产格式的测试 tag 做端到端演练。生产正向证据来自下一次真实、已批准的 release,不为验收伪造版本或 digest。 **退出条件:** 镜像只能基于 `master` 当前 HEAD 的受保护 tag 产生;preview 不再虚假部署;镜像远端偏移有可读、可过期、不可自动修复的证据链。 @@ -550,7 +613,7 @@ git commit -m "fix(release): verify tag provenance and mirror drift" **前置:** Task 1-5 已合并到 `dev`;D0-D7 状态有效 **Files:** -- Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md` +- Create or Modify: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md` - Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/ruleset-readback.md` - Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/weekly-metrics-schema.md` - Create: `docs/iterations/迭代195-社区PR治理与双分支协作/evidence/rollback-runbook.md` @@ -571,7 +634,7 @@ git commit -m "fix(release): verify tag provenance and mirror drift" **Step 3: 激活并读回 Ruleset** -先激活 `dev`,复验一轮普通/R2 PR;再激活 `master`。每次外部设置后立即运行: +先激活 `dev`,复验一轮普通/R2 PR;再激活 `master`。D6 的 tag 权限和 release environment 均满足后,最后激活 `release-tags` Ruleset。每次外部设置后立即运行: ```bash gh api repos/cloudQuant/backtrader_web/rulesets --paginate @@ -594,6 +657,16 @@ gh api repos/cloudQuant/backtrader_web/codeowners/errors - CODEOWNERS 错误:修正 owner 后再重新启用 code-owner required review; - 镜像/远端异常:停止发布/创建 incident,不自动覆盖远端。 +**Step 6: 文档证据校验** + +```bash +git diff --check -- \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/ruleset-readback.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/weekly-metrics-schema.md \ + docs/iterations/迭代195-社区PR治理与双分支协作/evidence/rollback-runbook.md +``` + **退出条件:** 观察期无未解释 P0/P1 误阻塞;三类 PR 端到端演练成功;Ruleset 读回与 manifest 一致;四周度量已开始且能复现;所有临时例外有到期日。 **Commit / handoff:** @@ -622,7 +695,7 @@ required check 的最终字符串必须来自 Task 4 演练的实际 Check Run | 分支语义 | 双语文档、CONTRIBUTING、AGENTS、PR 模板、三类 PR 演练 | 所有入口一致说明 `dev` 常规、`master` release/hotfix。 | | 安全 | workflow 契约测试、fork 草稿 PR、权限 diff | 没有特权 `pull_request_target` 执行 PR 代码;没有以 bot label 代替审核。 | | 所有权 | CODEOWNERS errors API、owner 矩阵、review request | 受保护域有真实可用 owner;既有 ratchet owner 仍被保留。 | -| Ruleset | desired manifest、API/界面读回、verifier 输出 | `master`、`dev` 都由 active 规则覆盖,或未启用原因有时限记录。 | +| Ruleset | desired manifest、API/界面读回、verifier 输出 | `master`、`dev` 与 release tag 都由对应 active 规则覆盖,或未启用原因有时限记录。 | | CI | 三类 PR check 记录 | 所有 required context 稳定报告;R2/R3 只能升级而不能降级。 | | 发布 | tag 反例/正例、release metadata artifact、environment 记录 | 非 `master` HEAD tag 被阻断;成功发行含 SHA/tag/digest。 | | 远端镜像 | `check_remote_sync.py` JSON、incident/例外 | 一致或存在未过期、有人负责的显式差异;无自动覆写。 | @@ -650,10 +723,10 @@ required check 的最终字符串必须来自 Task 4 演练的实际 Check Run 2. 贡献者可以从文档、Issue/PR 入口和治理检查一致地得出正确目标分支; 3. fork PR 不再通过具有写权限的 `pull_request_target` 执行不受信任代码; 4. `CODEOWNERS` 既保留现有 ratchet 保护,又覆盖已确认的核心域,且 GitHub API 无 errors; -5. 当默认分支为 `master` 时,安全的治理工作流骨架已先经 `release/governance-bootstrap` PR 进入默认分支;`dev` 与 `master` 的 active Ruleset 已由 API 读回并与 desired manifest 比对,或外部能力/人员阻塞被如实保留; +5. 当默认分支为 `master` 时,安全的治理工作流骨架已先经 `release/governance-bootstrap` PR 进入默认分支;`dev`、`master` 与 release tag 的 active Ruleset 已由 API 读回并与 desired manifest 比对,或外部能力/人员阻塞被如实保留; 6. 普通 R1、核心 R2 和 `master` hotfix/release 三类草稿 PR 均有端到端证据; 7. 镜像仅由指向 `master` 当前 HEAD 的受保护 tag 发布,成功记录 SHA/tag/digest,preview 不再虚构部署; 8. GitHub/Gitee 的 ref 一致性有可查询证据,任何差异均有 owner、incident 和到期日期; 9. 四周治理度量已启动,且没有使用“CI 绿色”“自动标签”或“测试数量”替代人工授权与风险判断。 -在满足上述条件前,本文件仍是待实施的治理设计,不能宣称仓库已具备可规模化、可审计的社区 PR 治理能力。 +在满足上述条件前,本文件仍是实施中的治理计划,不能宣称仓库已具备可规模化、可审计的社区 PR 治理能力。 From daf9d7e0c0d8b97763b284c2f48e03f3db274670 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 01:11:58 +0800 Subject: [PATCH 06/32] docs(governance): clarify contribution and disclosure flow --- .github/ISSUE_TEMPLATE/bug_report.md | 48 ---- .github/ISSUE_TEMPLATE/bug_report.yml | 60 +++++ .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/ISSUE_TEMPLATE/feature_request.md | 29 --- .github/ISSUE_TEMPLATE/feature_request.yml | 51 ++++ .github/ISSUE_TEMPLATE/question.md | 24 -- .github/PULL_REQUEST_TEMPLATE.md | 24 ++ AGENTS.md | 8 + CONTRIBUTING.md | 71 ++++-- README.en.md | 7 +- README.md | 2 +- docs/docs/en/development/contributing.md | 56 +++++ docs/docs/en/development/index.md | 1 + docs/docs/zh/development/contributing.md | 52 ++++ docs/docs/zh/development/index.md | 1 + docs/mkdocs.yml | 2 + scripts/ci/check_pr_template.py | 96 +++++++- scripts/ci/tests/test_pr_template_contract.py | 227 ++++++++++++++++++ 18 files changed, 625 insertions(+), 138 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 docs/docs/en/development/contributing.md create mode 100644 docs/docs/zh/development/contributing.md create mode 100644 scripts/ci/tests/test_pr_template_contract.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index e806b769f..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: Bug Report -about: Report a bug to help us improve -title: '[Bug] ' -labels: bug -assignees: '' ---- - -## Bug Description - -A clear and concise description of what the bug is. - -## Steps to Reproduce - -1. Go to '...' -2. Click on '...' -3. See error - -## Expected Behavior - -What you expected to happen. - -## Actual Behavior - -What actually happened. - -## Screenshots - -If applicable, add screenshots. - -## Environment - -- OS: [e.g. macOS 14, Ubuntu 22.04] -- Browser: [e.g. Chrome 120, Firefox 121] -- Python version: [e.g. 3.11] -- Node version: [e.g. 20] -- Database: [e.g. SQLite, PostgreSQL 16] -- Backtrader Web version: [e.g. v1.0.0] - -## Additional Context - -Any other context about the problem. - -## Logs - -``` -Paste relevant logs here -``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..1643949b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,60 @@ +name: Bug Report +description: Report a reproducible problem in AI for Investor +title: '[Bug] ' +labels: ['bug'] +body: + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Minimal Reproduction Steps + description: | + Smallest steps to reproduce, starting from a fresh state. + Example: + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened, including error messages. + validations: + required: true + - type: input + id: version + attributes: + label: Backtrader Web Version / Commit + placeholder: e.g. v1.x or commit SHA + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: OS, browser, Python, Node, database (e.g. SQLite / PostgreSQL 16 / MySQL). + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: | + Paste relevant logs here. Redact credentials, tokens and API keys first. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bd5d364f2..f521b8006 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: Documentation url: https://github.com/cloudQuant/backtrader_web/tree/master/docs about: Check our documentation before opening an issue - name: Discussions url: https://github.com/cloudQuant/backtrader_web/discussions - about: Ask questions and share ideas in Discussions + about: Ask general questions and share ideas in Discussions diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index aa2d20d13..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Feature Request -about: Suggest a new feature or improvement -title: '[Feature] ' -labels: enhancement -assignees: '' ---- - -## Problem Statement - -What problem does this feature solve? Who benefits from it? - -## Proposed Solution - -A clear description of what you want to happen. - -## Alternatives Considered - -Any alternative solutions or features you've considered. - -## Use Case - -Describe a concrete use case for this feature. - -## Additional Context - -- Is this related to an existing feature? -- Are there similar features in other platforms? -- Would you be willing to contribute a PR? diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..6ba9556d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,51 @@ +name: Feature Request +description: Suggest a new feature or improvement for AI for Investor +title: '[Feature] ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Before requesting a feature, please search existing issues and Discussions + for duplicates. + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? Who benefits from it? + validations: + required: true + - type: textarea + id: users + attributes: + label: Target Users / Scenarios + description: Who would use this and in which workflow? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed Solution + description: A clear description of what you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Alternative solutions or features you've considered, including existing workarounds. + validations: + required: true + - type: checkboxes + id: contribution + attributes: + label: Willingness to Contribute + options: + - label: I am willing to submit a PR for this feature (targeting the `dev` branch) + - type: textarea + id: context + attributes: + label: Additional Context + description: Related features, similar platforms, links. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index ac0185288..000000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Question -about: Ask a question about usage or development -title: '[Question] ' -labels: question -assignees: '' ---- - -## Question - -Your question here. - -## Context - -- What are you trying to achieve? -- What have you already tried? -- Which documentation have you consulted? - -## Environment (if relevant) - -- OS: -- Python version: -- Node version: -- Database: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 167f4288a..93cb04e69 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,11 @@ Iteration 175 §4.7 — the "i18n 变更清单" section is mandatory and is veri by `scripts/ci/check_pr_template.py`. Please leave it in place even if your PR does not touch any string literals (state "无变更" / "no change" in the relevant subfields). + +Iteration 195 — the static "Governance declaration" contract is verified by +`scripts/ci/check_pr_template.py`. Regular changes must target `dev`; +`master` only accepts `release/vX.Y.Z` promotions and `hotfix/master-*` +emergency fixes (see CONTRIBUTING.md, "Branch Model"). --> ## What & Why @@ -13,6 +18,25 @@ relevant subfields). +## Governance declaration + + + +- **目标分支**: +- **风险等级**: +- **测试证据**: + + +## Hotfix 前移计划 (master hotfix PRs only) + +- **前移计划**: + +## Release 清单 (master release promotion PRs only) + +- **Release 清单**: + ## Test Plan diff --git a/AGENTS.md b/AGENTS.md index e46c3b185..98dae8908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,14 @@ Conventional Commits: `(): ` **Examples**: `feat(backtest): add cancel endpoint`, `fix(auth): handle token refresh` +## Pull Request Routing + +- Open regular repository changes (`feature/*`, `fix/*`, `docs/*`, `refactor/*`, and `test/*`) + as PRs targeting `dev`; do not directly commit changes to `master`. +- `master` is reserved for maintainer-owned `release/vX.Y.Z` promotions and + `hotfix/master-*` emergency fixes. Follow [CONTRIBUTING.md](CONTRIBUTING.md) for the + required governance declaration and evidence. + --- ## Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0090e8a15..572cbeec7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,28 +14,51 @@ Thank you for your interest in contributing to AI for Investor! This document pr ## Development Workflow +### 0. Branch Model (iteration 195) + +This repository uses a two-branch model: + +| Branch | Role | Who merges into it | +|---|---|---| +| `dev` | Daily integration branch | All regular PRs (`feature/*`, `fix/*`, `docs/*`, `refactor/*`, `test/*`) | +| `master` | Release branch | Only `release/vX.Y.Z` promotion PRs and `hotfix/master-*` emergency PRs | + +**Regular changes must target `dev`, never `master`.** PRs that target `master` without a +`release/*` or `hotfix/master-*` source branch are rejected by the PR Governance gate. + ### 1. Fork and Clone ```bash -# Fork the repository on GitHub +# 1. Fork the repository on GitHub, then clone YOUR fork: git clone https://github.com/YOUR_USERNAME/backtrader_web.git cd backtrader_web + +# 2. Replace YOUR_USERNAME above with your own GitHub username. + +# 3. Add the upstream remote so you can stay in sync with the canonical repo: +git remote add upstream https://github.com/cloudQuant/backtrader_web.git +git fetch upstream + +# 4. Base your work on dev (see Branch Model above): +git checkout -b feature/your-feature-name upstream/dev ``` ### 2. Create a Feature Branch ```bash -git checkout -b feature/your-feature-name +git checkout -b feature/your-feature-name upstream/dev # or -git checkout -b fix/your-bug-fix +git checkout -b fix/your-bug-fix upstream/dev ``` -Branch naming conventions: -- `feature/` - New features -- `fix/` - Bug fixes -- `refactor/` - Code refactoring -- `docs/` - Documentation changes -- `test/` - Test additions or updates +Branch naming conventions (based on `dev` unless noted): +- `feature/` - New features → PR to `dev` +- `fix/` - Bug fixes → PR to `dev` +- `refactor/` - Code refactoring → PR to `dev` +- `docs/` - Documentation changes → PR to `dev` +- `test/` - Test additions or updates → PR to `dev` +- `release/vX.Y.Z` - Release promotion, based on `dev` → PR to `master` (maintainers) +- `hotfix/master-*` - Production emergency fix, based on `master` → PR to `master` (maintainers, requires an incident reference and a plan to backport the fix to `dev`) ### 3. Make Your Changes @@ -78,7 +101,11 @@ Commit types: git push origin feature/your-feature-name ``` -Then create a pull request on GitHub. +Then create a pull request on GitHub **targeting `dev`** (see [Branch Model](#0-branch-model-iteration-195)). +The PR template asks for a `## Governance declaration` section: declare your target branch, +the risk level (auto-classified from changed paths; labels cannot lower it), and your test +evidence. `master` hotfix PRs additionally require a backport plan; `master` release PRs +additionally require a release checklist. ## Setting Up Development Environment @@ -328,25 +355,25 @@ Before submitting a PR, ensure: ### Pull Request Description Template +Use `.github/PULL_REQUEST_TEMPLATE.md` when you open a PR. Its key sections: + ```markdown -## Summary -Brief description of changes. +## What & Why +Brief description of changes and why they are needed. -## Type of Change -- [ ] Bug fix -- [ ] New feature -- [ ] Breaking change -- [ ] Documentation update +## Governance declaration +- 目标分支: dev(或 master 的 release/hotfix 理由) +- 风险等级: R0/R1/R2/R3 + 命中路径说明 +- 测试证据: 本地/CI 测试输出或链接 -## Testing -Describe testing performed. +## Test Plan +How did you verify the change? -## Screenshots (if applicable) -Add screenshots for UI changes. +## i18n 变更清单 (i18n change manifest, 175 §4.7) +(only mandatory when locale files change — see template) ## Related Issues Fixes #123 -Related to #456 ``` ## Code Review Process diff --git a/README.en.md b/README.en.md index a22179197..859453464 100644 --- a/README.en.md +++ b/README.en.md @@ -275,15 +275,16 @@ backtrader_web/ ## Contributing -We welcome contributions from the community. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. +We welcome contributions from the community. See [CONTRIBUTING.md](CONTRIBUTING.md) or the +[online contributing guide](docs/docs/en/development/contributing.md) for the full guide. **Quick version:** 1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` +2. Create a feature branch **based on `dev`**: `git checkout -b feature/my-feature upstream/dev` 3. Follow [conventional commits](https://www.conventionalcommits.org/): `feat(backtest): add cancel endpoint` 4. Write tests for your changes -5. Submit a Pull Request +5. Submit a Pull Request **targeting `dev`** — `master` only accepts `release/vX.Y.Z` promotions and `hotfix/master-*` fixes, enforced by the PR Governance gate **Development tools:** diff --git a/README.md b/README.md index 91d4a2dc2..0ec84e0cd 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ python scripts/ci/check_doc_links.py | 架构、API、数据库与配置 | [开发文档](docs/docs/zh/development/index.md) | | Docker 与生产运维 | [部署文档](docs/docs/zh/deployment/index.md) | | 文档分层与归档策略 | [文档导航](docs/INDEX.md) | -| 贡献规范 | [CONTRIBUTING.md](CONTRIBUTING.md) | +| 贡献规范 | [CONTRIBUTING.md](CONTRIBUTING.md) · [在线贡献指南](docs/docs/zh/development/contributing.md)。常规变更请基于 `dev` 分支发起 PR;`master` 仅接受 `release/vX.Y.Z` 晋升与 `hotfix/master-*` | 当前处于 `v1.x` 持续演进阶段。旧迭代、已完成方案和专项报告位于 `docs/iterations/`、`docs/archive/` 和 `docs/reports/archive/`,仅用于追溯,不应替代当前操作文档。 diff --git a/docs/docs/en/development/contributing.md b/docs/docs/en/development/contributing.md new file mode 100644 index 000000000..05e50a943 --- /dev/null +++ b/docs/docs/en/development/contributing.md @@ -0,0 +1,56 @@ +# Contributing + +How to contribute code and documentation to AI for Investor. The full local-development, +testing, and style guide lives in the root [CONTRIBUTING.md](https://github.com/cloudQuant/backtrader_web/blob/master/CONTRIBUTING.md). + +## Branch model (iteration 195) + +| Branch | Role | Accepts | +| --- | --- | --- | +| `dev` | Daily integration | All regular PRs (`feature/*`, `fix/*`, `docs/*`, …) | +| `master` | Release branch | Only `release/vX.Y.Z` promotion PRs and `hotfix/master-*` emergency PRs | + +**Regular changes must target `dev`.** A PR into `master` from any other source branch is +rejected by the PR Governance gate. + +## Fork and clone + +1. Fork the repository on GitHub. +2. Clone your fork (replace `YOUR_USERNAME` with your own username): + + ```bash + git clone https://github.com/YOUR_USERNAME/backtrader_web.git + cd backtrader_web + ``` + +3. Add the upstream remote and base your work on `dev`: + + ```bash + git remote add upstream https://github.com/cloudQuant/backtrader_web.git + git fetch upstream + git checkout -b feature/your-feature upstream/dev + ``` + +## Open a pull request + +1. Push your branch and open a PR **targeting `dev`**. +2. Follow `.github/PULL_REQUEST_TEMPLATE.md`: + - `## Governance declaration`: target-branch justification, risk level (auto-classified + from changed paths; labels cannot lower it), and test evidence; + - `master` hotfix PRs additionally declare a dev-backport plan; release promotion PRs + additionally link the release checklist; + - fill in the `i18n change manifest` when locale files change (CI-verified). +3. Wait for maintainer review — passing automation is necessary but not sufficient; merging + requires human approval. + +## Reporting issues + +- Bugs: use the Bug Report form with minimal reproduction steps and environment details. +- Features: use the Feature Request form. +- Questions: ask in [Discussions](https://github.com/cloudQuant/backtrader_web/discussions) + instead of opening a question issue. + +## Code of conduct + +Be constructive and respectful; review feedback targets code, not people. Submitting a +contribution licenses it under the MIT License. diff --git a/docs/docs/en/development/index.md b/docs/docs/en/development/index.md index cc226666b..f66b9cfe4 100644 --- a/docs/docs/en/development/index.md +++ b/docs/docs/en/development/index.md @@ -27,5 +27,6 @@ src/frontend/src/ - [Architecture](./architecture.md) - [API reference](./api.md) - [Database and data boundaries](./database.md) +- [Contributing guide](./contributing.md) (branch model and PR governance declaration) - Engineering coding standards: `docs/reference/CODING_STANDARDS.md` - Engineering test guide: `docs/how-to/TESTING.md` diff --git a/docs/docs/zh/development/contributing.md b/docs/docs/zh/development/contributing.md new file mode 100644 index 000000000..6f217a0d2 --- /dev/null +++ b/docs/docs/zh/development/contributing.md @@ -0,0 +1,52 @@ +# 贡献指南 + +如何为 AI for Investor 贡献代码与文档。本地开发、测试与代码规范的完整说明见根目录 +[CONTRIBUTING.md](https://github.com/cloudQuant/backtrader_web/blob/master/CONTRIBUTING.md)。 + +## 分支模型(iteration 195) + +| 分支 | 定位 | 接受的 PR | +| --- | --- | --- | +| `dev` | 日常集成分支 | 全部常规 PR(`feature/*`、`fix/*`、`docs/*` 等) | +| `master` | 发布主干 | 仅 `release/vX.Y.Z` 发布晋升 PR 与 `hotfix/master-*` 紧急修复 PR | + +**常规变更必须以 `dev` 为目标分支。** 其他来源分支指向 `master` 的 PR 会被 PR Governance +门禁拒绝。 + +## Fork 与克隆 + +1. 在 GitHub 上 Fork 本仓库。 +2. 克隆你自己的 fork(把 `YOUR_USERNAME` 替换成你的用户名): + + ```bash + git clone https://github.com/YOUR_USERNAME/backtrader_web.git + cd backtrader_web + ``` + +3. 添加 upstream 远端,并基于 `dev` 创建分支: + + ```bash + git remote add upstream https://github.com/cloudQuant/backtrader_web.git + git fetch upstream + git checkout -b feature/your-feature upstream/dev + ``` + +## 发起 Pull Request + +1. 推送分支并创建**目标为 `dev`** 的 PR。 +2. 按 `.github/PULL_REQUEST_TEMPLATE.md` 填写: + - `## Governance declaration`:目标分支理由、风险等级(按变更路径自动分类,label 不能下调)、测试证据; + - `master` hotfix PR 需额外填写 dev 前移计划;release promotion PR 需链接发布清单; + - 改动国际化文案时如实填写 `i18n 变更清单`(CI 校验)。 +3. 等待维护者评审——自动化检查通过是必要条件而非充分条件;合并需要人工批准。 + +## 提交 Issue + +- Bug:使用 Bug Report 表单,附最小复现步骤与环境信息。 +- 功能建议:使用 Feature Request 表单。 +- 使用问题:请在 [Discussions](https://github.com/cloudQuant/backtrader_web/discussions) + 提问,不要新建 question issue。 + +## 行为约定 + +评审对事不对人;保持建设性与尊重。提交贡献即表示同意以 MIT License 授权。 diff --git a/docs/docs/zh/development/index.md b/docs/docs/zh/development/index.md index 43ea8b19d..16028a154 100644 --- a/docs/docs/zh/development/index.md +++ b/docs/docs/zh/development/index.md @@ -27,5 +27,6 @@ src/frontend/src/ - [架构设计](./architecture.md) - [API 参考](./api.md) - [数据库与数据边界](./database.md) +- [贡献指南](./contributing.md)(分支模型与 PR 治理声明) - 工程代码规范:`docs/reference/CODING_STANDARDS.md` - 工程测试手册:`docs/how-to/TESTING.md` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 072a416cf..0ea497bec 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -73,6 +73,7 @@ plugins: - zh/features/optimization.md - 开发指南: - zh/development/index.md + - zh/development/contributing.md - zh/development/architecture.md - zh/development/api.md - zh/development/database.md @@ -104,6 +105,7 @@ plugins: - en/features/optimization.md - Development: - en/development/index.md + - en/development/contributing.md - en/development/architecture.md - en/development/api.md - en/development/database.md diff --git a/scripts/ci/check_pr_template.py b/scripts/ci/check_pr_template.py index a445a6fe9..487645057 100755 --- a/scripts/ci/check_pr_template.py +++ b/scripts/ci/check_pr_template.py @@ -31,15 +31,81 @@ "/i18n/locales/", ) +# Iteration 195 — tolerate the repo template's markdown-bold form +# ("- **zh-CN key 数量 (count)**: 12") as well as the historical plain form. REQUIRED_FIELDS = [ - ("zh-CN key 数量", r"zh-CN\s*key\s*数量\s*[\((][^))]*[\))]?\s*[::]\s*([^\n]+)"), - ("en-US key 数量", r"en-US\s*key\s*数量\s*[\((][^))]*[\))]?\s*[::]\s*([^\n]+)"), + ("zh-CN key 数量", r"zh-CN\s*key\s*数量\s*[\((][^))]*[\))]?\s*\*{0,2}\s*[::]\s*([^\n]+)"), + ("en-US key 数量", r"en-US\s*key\s*数量\s*[\((][^))]*[\))]?\s*\*{0,2}\s*[::]\s*([^\n]+)"), ] ADDED_HEADING = re.compile(r"本\s*PR\s*新增\s*key", re.UNICODE) REMOVED_HEADING = re.compile(r"本\s*PR\s*删除\s*key", re.UNICODE) -PLACEHOLDER = re.compile(r"<\s*fill\s*in.*?>|``", re.IGNORECASE) +PLACEHOLDER = re.compile( + r"<\s*fill\s*in.*?>|``|", re.IGNORECASE +) + +# --------------------------------------------------------------------------- +# Iteration 195 — static governance-declaration contract. Dynamic base/head +# branch + review validation lives in scripts/ci/check_pr_governance.py. +# --------------------------------------------------------------------------- + +GOVERNANCE_HEADING = "## Governance declaration" + +_GOVERNANCE_SECTION_RE = re.compile( + r"^##\s+Governance declaration\s*$", re.MULTILINE | re.IGNORECASE +) + +_PR_KINDS = ("normal", "hotfix", "release") + +_COMMON_FIELDS = ("目标分支", "风险等级", "测试证据") +_KIND_FIELDS = { + "normal": (), + "hotfix": ("前移计划",), + "release": ("Release 清单",), +} + +_FIELD_LINE_RE_TEMPLATE = r"^\s*[-*]?\s*\**\s*{label}\s*\**\s*[::]\s*(.*)$" + + +def _field_value(section: str, label: str) -> str | None: + """Return the raw value of ``label`` inside the governance section.""" + pattern = re.compile( + _FIELD_LINE_RE_TEMPLATE.format(label=re.escape(label)), re.UNICODE | re.MULTILINE + ) + m = pattern.search(section) + if not m: + return None + return m.group(1).strip().strip("`").rstrip("。.") + + +def governance_declaration_issues(body: str, pr_kind: str = "normal") -> list[str]: + """Validate the ``## Governance declaration`` section of a PR body. + + ``pr_kind`` is one of ``normal`` (feature/fix/docs → dev), ``hotfix`` + (hotfix/master-* → master) or ``release`` (release/vX.Y.Z → master). + Returns a list of human-readable issues; empty means accepted. + """ + issues: list[str] = [] + if pr_kind not in _PR_KINDS: + return [f"unknown governance pr_kind: {pr_kind!r}"] + + if not _GOVERNANCE_SECTION_RE.search(body): + return [f"missing required section: {GOVERNANCE_HEADING}"] + + start = _GOVERNANCE_SECTION_RE.search(body).end() + rest = body[start:] + next_heading = re.search(r"^##\s+", rest[1:], re.MULTILINE) + section = rest[: next_heading.start() + 1] if next_heading else rest + + labels = list(_COMMON_FIELDS) + list(_KIND_FIELDS.get(pr_kind, ())) + for label in labels: + value = _field_value(section, label) + if value is None: + issues.append(f"governance field missing: {label}") + elif not value or PLACEHOLDER.search(value): + issues.append(f"governance field unfilled: {label} = {value!r}") + return issues def _locale_files_changed() -> bool | None: @@ -53,19 +119,32 @@ def _locale_files_changed() -> bool | None: return any(marker in f for f in files for marker in LOCALE_PATH_MARKERS) +def _report_fail(issues: list[str]) -> int: + print("FAIL: PR description contract incomplete:") + for line in issues: + print(f" - {line}") + return 1 + + def main() -> int: body = os.environ.get("PR_BODY") if body is None or body.strip() == "": print("WARN: PR_BODY env var is empty; skipping check (non-PR context).") return 2 + issues: list[str] = [] + + gov_kind = os.environ.get("GOVERNANCE_PR_KIND", "").strip() + if gov_kind: + issues.extend(governance_declaration_issues(body, gov_kind)) + locale_changed = _locale_files_changed() if locale_changed is False: + if issues: + return _report_fail(issues) print("OK: PR changes no i18n locale files; manifest not required.") return 0 - issues: list[str] = [] - for label, pattern in REQUIRED_FIELDS: m = re.search(pattern, body, re.IGNORECASE) if not m: @@ -90,11 +169,10 @@ def main() -> int: issues.append("placeholder text still present inside i18n section") if issues: - print("FAIL: i18n change manifest incomplete in PR description:") - for line in issues: - print(f" - {line}") - return 1 + return _report_fail(issues) + if gov_kind: + print(f"OK: governance declaration complete for kind {gov_kind!r}.") print("OK: i18n change manifest present and filled.") return 0 diff --git a/scripts/ci/tests/test_pr_template_contract.py b/scripts/ci/tests/test_pr_template_contract.py new file mode 100644 index 000000000..6970acc86 --- /dev/null +++ b/scripts/ci/tests/test_pr_template_contract.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Iteration 195 Task 1 — PR template governance contract tests. + +Validates, via fixtures only (no GitHub API): + +1. A *normal* PR body carrying a complete ``## Governance declaration`` + section is accepted. +2. An R2 PR body is accepted when the declaration names the elevated risk, + and rejected when the risk/test-evidence fields are placeholders. +3. A ``master`` hotfix body is rejected when the hotfix backport plan is + missing, accepted when present. +4. A ``master`` release body is rejected when the release checklist is + missing, accepted when present. +5. The pre-existing iteration 175/179 behaviour is preserved: an i18n + manifest is still mandatory when locale files change, and the non-PR + empty-environment exit semantics are unchanged. + +Actual base/head branch and review-state validation belongs to Task 4 +(``scripts/ci/check_pr_governance.py``); these tests deliberately do not +depend on GitHub event payloads. +""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +CHECKER_PATH = REPO_ROOT / "scripts" / "ci" / "check_pr_template.py" + + +def _load_checker(): + spec = importlib.util.spec_from_file_location("check_pr_template", CHECKER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +checker = _load_checker() + + +def _run_cli(env_extra: dict[str, str]) -> tuple[int, str]: + env = os.environ.copy() + env.update(env_extra) + proc = subprocess.run( + [sys.executable, str(CHECKER_PATH)], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + ) + return proc.returncode, proc.stdout + proc.stderr + + +GOV_COMPLETE = """\ +## Governance declaration + +- 目标分支: dev(常规变更走 dev 集成分支) +- 风险等级: R2(命中 src/bt_api_py/** 路径,不可下调) +- 测试证据: pytest src/backend/tests/test_x.py 通过;手工冒烟记录见下方 Test Plan +""" + +GOV_MISSING_FIELDS = """\ +## Governance declaration + +- 目标分支: +- 风险等级: +""" + +HOTFIX_COMPLETE = """\ +## Governance declaration + +- 目标分支: master(hotfix/master-123 生产事故修复) +- 风险等级: R3(生产 hotfix) +- 测试证据: 回归复现脚本 + 单元测试输出已附 +- 前移计划: 已创建等价修复 issue #456;dev 分支等价 PR #789 或"不受影响"结论见正文 +""" + +HOTFIX_NO_BACKPORT = """\ +## Governance declaration + +- 目标分支: master(hotfix/master-9 紧急修复) +- 风险等级: R3 +- 测试证据: 最小复现已附 +""" + +RELEASE_COMPLETE = """\ +## Governance declaration + +- 目标分支: master(release/v1.3.0 promotion) +- 风险等级: R3(发布晋升) +- 测试证据: 完整 CI + e2e 汇总链接见下 +- Release 清单: docs/releases/v1.3.0-checklist.md(版本、变更日志、回滚点齐备) +""" + +RELEASE_NO_CHECKLIST = """\ +## Governance declaration + +- 目标分支: master(release/v1.4.0 promotion) +- 风险等级: R3 +- 测试证据: CI 绿色截图 +""" + + +class TestGovernanceDeclarationNormalPr: + def test_complete_normal_declaration_accepted(self) -> None: + assert checker.governance_declaration_issues(GOV_COMPLETE, "normal") == [] + + def test_missing_risk_and_placeholder_rejected(self) -> None: + issues = checker.governance_declaration_issues(GOV_MISSING_FIELDS, "normal") + assert any("风险等级" in i for i in issues) + assert any("目标分支" in i for i in issues) + assert any("测试证据" in i for i in issues) + + def test_absent_section_rejected(self) -> None: + issues = checker.governance_declaration_issues( + "## What & Why\n\nsome change\n", "normal" + ) + assert any("Governance declaration" in i for i in issues) + + def test_html_comment_placeholder_is_rejected(self) -> None: + body = """\ +## Governance declaration + +- 目标分支: +- 风险等级: +- 测试证据: +""" + issues = checker.governance_declaration_issues(body, "normal") + assert any("目标分支" in issue for issue in issues) + assert any("风险等级" in issue for issue in issues) + assert any("测试证据" in issue for issue in issues) + + +class TestGovernanceR2Pr: + def test_r2_named_in_declaration_accepted(self) -> None: + body = GOV_COMPLETE.replace("R2", "R2", 1) + assert checker.governance_declaration_issues(body, "normal") == [] + + def test_label_claiming_lower_risk_does_not_satisfy_placeholder(self) -> None: + # A label cannot substitute for the filled-in declaration fields. + body = GOV_MISSING_FIELDS + "\nLabels: risk-low\n" + issues = checker.governance_declaration_issues(body, "normal") + assert issues, "placeholder declaration must be rejected even with a low-risk label" + + +class TestMasterHotfixContract: + def test_hotfix_with_backport_accepted(self) -> None: + assert checker.governance_declaration_issues(HOTFIX_COMPLETE, "hotfix") == [] + + def test_hotfix_without_backport_rejected(self) -> None: + issues = checker.governance_declaration_issues(HOTFIX_NO_BACKPORT, "hotfix") + assert any("前移" in i for i in issues) + + +class TestMasterReleaseContract: + def test_release_with_checklist_accepted(self) -> None: + assert checker.governance_declaration_issues(RELEASE_COMPLETE, "release") == [] + + def test_release_without_checklist_rejected(self) -> None: + issues = checker.governance_declaration_issues(RELEASE_NO_CHECKLIST, "release") + assert any("Release 清单" in i for i in issues) + + +class TestI18nRegression: + def test_locale_change_still_requires_manifest(self) -> None: + body = "## Governance declaration\n\n- 目标分支: dev\n- 风险等级: R1\n- 测试证据: ok\n" + code, _ = _run_cli({"PR_BODY": body, "CHANGED_FILES": "src/frontend/src/i18n/locales/zh-CN.ts"}) + assert code == 1, "locale change without i18n manifest must still fail" + + def test_locale_change_with_manifest_passes(self) -> None: + body = ( + "## Governance declaration\n\n- 目标分支: dev\n- 风险等级: R1\n- 测试证据: ok\n\n" + "## i18n 变更清单 (i18n change manifest, 175 §4.7)\n\n" + "- **zh-CN key 数量 (count)**: 12\n" + "- **en-US key 数量 (count)**: 12\n" + "- **本 PR 新增 key (added)**:\n - nav.newItem\n" + "- **本 PR 删除 key (removed)**:\n - 无 / none\n" + ) + code, _ = _run_cli({"PR_BODY": body, "CHANGED_FILES": "src/frontend/src/i18n/locales/en-US.ts"}) + assert code == 0 + + def test_non_pr_environment_exit_unchanged(self) -> None: + # Empty environment keeps its historical "skip" semantics (exit 2), + # including when governance checking is not requested. + env = {k: v for k, v in os.environ.items() if k not in ("PR_BODY", "CHANGED_FILES")} + proc = subprocess.run( + [sys.executable, str(CHECKER_PATH)], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + ) + assert proc.returncode == 2 + + +class TestCliGovernanceKindEnv: + def test_governance_kind_env_enables_check(self) -> None: + code, out = _run_cli({"PR_BODY": HOTFIX_NO_BACKPORT, "GOVERNANCE_PR_KIND": "hotfix"}) + assert code == 1 + assert "前移" in out + + def test_governance_kind_env_passes_complete_body(self) -> None: + code, _ = _run_cli( + { + "PR_BODY": RELEASE_COMPLETE, + "GOVERNANCE_PR_KIND": "release", + "CHANGED_FILES": "", + } + ) + assert code == 0 + + def test_invalid_governance_on_non_locale_change_reports_contract_error(self) -> None: + code, out = _run_cli( + { + "PR_BODY": GOV_MISSING_FIELDS, + "GOVERNANCE_PR_KIND": "normal", + "CHANGED_FILES": "README.md", + } + ) + assert code == 1 + assert "governance field" in out + assert "Traceback" not in out From 56496920e58d522b7a08520a25af7320a5446a81 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 01:19:48 +0800 Subject: [PATCH 07/32] fix(governance): enforce PR declaration contract --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- scripts/ci/check_pr_template.py | 38 ++++++--- scripts/ci/tests/test_pr_template_contract.py | 77 +++++++++++++++++++ 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 93cb04e69..ad2dc23f9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -27,7 +27,9 @@ emergency fixes (see CONTRIBUTING.md, "Branch Model"). - **测试证据**: ## Hotfix 前移计划 (master hotfix PRs only) diff --git a/scripts/ci/check_pr_template.py b/scripts/ci/check_pr_template.py index 487645057..d2c083830 100755 --- a/scripts/ci/check_pr_template.py +++ b/scripts/ci/check_pr_template.py @@ -12,10 +12,13 @@ changed in the PR). When provided and it contains no i18n locale file, the check passes without requiring a manifest. When unset, the check runs unconditionally (backwards-compatible with the pre-179 behaviour). + - GOVERNANCE_PR_KIND env var (optional): enables the additional ``hotfix`` + or ``release`` declaration field; the common governance declaration is + required for every non-empty PR body. Exit codes: - 0 - manifest present & filled, OR PR changes no locale files - 1 - locale files changed but manifest missing / has placeholders + 0 - governance declaration and, when applicable, i18n manifest are filled + 1 - governance declaration or required i18n manifest is incomplete 2 - PR_BODY env var unset (don't fail in non-PR contexts) """ @@ -55,6 +58,10 @@ _GOVERNANCE_SECTION_RE = re.compile( r"^##\s+Governance declaration\s*$", re.MULTILINE | re.IGNORECASE ) +_KIND_SECTION_RE = { + "hotfix": re.compile(r"^##\s+Hotfix\s+前移计划(?:\s+.*)?$", re.MULTILINE), + "release": re.compile(r"^##\s+Release\s+清单(?:\s+.*)?$", re.MULTILINE), +} _PR_KINDS = ("normal", "hotfix", "release") @@ -68,6 +75,16 @@ _FIELD_LINE_RE_TEMPLATE = r"^\s*[-*]?\s*\**\s*{label}\s*\**\s*[::]\s*(.*)$" +def _section_content(body: str, heading: re.Pattern[str]) -> str | None: + """Return the Markdown content below ``heading``, before the next H2.""" + match = heading.search(body) + if not match: + return None + rest = body[match.end() :] + next_heading = re.search(r"^##\s+", rest[1:], re.MULTILINE) + return rest[: next_heading.start() + 1] if next_heading else rest + + def _field_value(section: str, label: str) -> str | None: """Return the raw value of ``label`` inside the governance section.""" pattern = re.compile( @@ -90,17 +107,19 @@ def governance_declaration_issues(body: str, pr_kind: str = "normal") -> list[st if pr_kind not in _PR_KINDS: return [f"unknown governance pr_kind: {pr_kind!r}"] - if not _GOVERNANCE_SECTION_RE.search(body): + governance_section = _section_content(body, _GOVERNANCE_SECTION_RE) + if governance_section is None: return [f"missing required section: {GOVERNANCE_HEADING}"] - start = _GOVERNANCE_SECTION_RE.search(body).end() - rest = body[start:] - next_heading = re.search(r"^##\s+", rest[1:], re.MULTILINE) - section = rest[: next_heading.start() + 1] if next_heading else rest - labels = list(_COMMON_FIELDS) + list(_KIND_FIELDS.get(pr_kind, ())) for label in labels: + kind_section = None + if label in _KIND_FIELDS.get(pr_kind, ()): + kind_section = _section_content(body, _KIND_SECTION_RE[pr_kind]) + section = kind_section or governance_section value = _field_value(section, label) + if value is None and kind_section is not None: + value = _field_value(governance_section, label) if value is None: issues.append(f"governance field missing: {label}") elif not value or PLACEHOLDER.search(value): @@ -135,8 +154,7 @@ def main() -> int: issues: list[str] = [] gov_kind = os.environ.get("GOVERNANCE_PR_KIND", "").strip() - if gov_kind: - issues.extend(governance_declaration_issues(body, gov_kind)) + issues.extend(governance_declaration_issues(body, gov_kind or "normal")) locale_changed = _locale_files_changed() if locale_changed is False: diff --git a/scripts/ci/tests/test_pr_template_contract.py b/scripts/ci/tests/test_pr_template_contract.py index 6970acc86..cf61aa99c 100644 --- a/scripts/ci/tests/test_pr_template_contract.py +++ b/scripts/ci/tests/test_pr_template_contract.py @@ -105,6 +105,56 @@ def _run_cli(env_extra: dict[str, str]) -> tuple[int, str]: - 测试证据: CI 绿色截图 """ +TEMPLATE_NORMAL_COMPLETE = """\ +## What & Why + +Clarify the contribution flow. + +## Governance declaration + +- **目标分支**: dev(常规变更走 dev 集成分支) +- **风险等级**: R1(文档变更) +- **测试证据**: pytest scripts/ci/tests/test_pr_template_contract.py -q + +## Test Plan + +Contract test passed. +""" + +TEMPLATE_NORMAL_MISSING_DECLARATION = """\ +## What & Why + +Clarify the contribution flow. + +## Test Plan + +Contract test passed. +""" + +HOTFIX_TEMPLATE_LAYOUT = """\ +## Governance declaration + +- **目标分支**: master(hotfix/master-123 生产事故修复) +- **风险等级**: R3(生产 hotfix) +- **测试证据**: 回归复现脚本 + 单元测试输出已附 + +## Hotfix 前移计划 (master hotfix PRs only) + +- **前移计划**: 已创建等价修复 issue #456;dev 分支等价 PR #789 +""" + +RELEASE_TEMPLATE_LAYOUT = """\ +## Governance declaration + +- **目标分支**: master(release/v1.3.0 promotion) +- **风险等级**: R3(发布晋升) +- **测试证据**: 完整 CI + e2e 汇总链接见下 + +## Release 清单 (master release promotion PRs only) + +- **Release 清单**: docs/releases/v1.3.0-checklist.md(版本、变更日志、回滚点齐备) +""" + class TestGovernanceDeclarationNormalPr: def test_complete_normal_declaration_accepted(self) -> None: @@ -156,6 +206,9 @@ def test_hotfix_without_backport_rejected(self) -> None: issues = checker.governance_declaration_issues(HOTFIX_NO_BACKPORT, "hotfix") assert any("前移" in i for i in issues) + def test_hotfix_template_layout_with_plan_in_named_section_accepted(self) -> None: + assert checker.governance_declaration_issues(HOTFIX_TEMPLATE_LAYOUT, "hotfix") == [] + class TestMasterReleaseContract: def test_release_with_checklist_accepted(self) -> None: @@ -165,6 +218,9 @@ def test_release_without_checklist_rejected(self) -> None: issues = checker.governance_declaration_issues(RELEASE_NO_CHECKLIST, "release") assert any("Release 清单" in i for i in issues) + def test_release_template_layout_with_checklist_in_named_section_accepted(self) -> None: + assert checker.governance_declaration_issues(RELEASE_TEMPLATE_LAYOUT, "release") == [] + class TestI18nRegression: def test_locale_change_still_requires_manifest(self) -> None: @@ -199,6 +255,27 @@ def test_non_pr_environment_exit_unchanged(self) -> None: class TestCliGovernanceKindEnv: + def test_current_ci_environment_accepts_template_normal_without_kind(self) -> None: + code, _ = _run_cli( + { + "PR_BODY": TEMPLATE_NORMAL_COMPLETE, + "CHANGED_FILES": "README.md", + "GOVERNANCE_PR_KIND": "", + } + ) + assert code == 0 + + def test_current_ci_environment_rejects_missing_common_declaration(self) -> None: + code, out = _run_cli( + { + "PR_BODY": TEMPLATE_NORMAL_MISSING_DECLARATION, + "CHANGED_FILES": "README.md", + "GOVERNANCE_PR_KIND": "", + } + ) + assert code == 1 + assert "Governance declaration" in out + def test_governance_kind_env_enables_check(self) -> None: code, out = _run_cli({"PR_BODY": HOTFIX_NO_BACKPORT, "GOVERNANCE_PR_KIND": "hotfix"}) assert code == 1 From 202338912da910588b4558108551da54e98dcb21 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 01:29:11 +0800 Subject: [PATCH 08/32] fix(ci): remove privileged execution from fork PR checks --- .github/workflows/pr-check.yml | 230 ++---------------- .github/workflows/pr-governance.yml | 22 ++ .../test_workflow_governance_contract.py | 95 ++++++++ 3 files changed, 139 insertions(+), 208 deletions(-) create mode 100644 .github/workflows/pr-governance.yml create mode 100644 scripts/ci/tests/test_workflow_governance_contract.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4f08c4b46..b9a427fad 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -2,39 +2,25 @@ name: PR - Pull Request Checks on: pull_request: - types: [opened, synchronize, reopened, labeled] - pull_request_target: types: [opened, synchronize, reopened] - workflow_dispatch: +# PR code is checked only with the read-only token GitHub provides to +# pull_request workflows. Fork PRs therefore receive neither write authority +# nor repository secrets. permissions: - pull-requests: write contents: read - -env: - PYTHON_VERSION: "3.11" - NODE_VERSION: "20" + pull-requests: read jobs: - # ======================================== - # PR Validation - # ======================================== pr-validation: name: PR Validation runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check PR description env: PR_BODY: ${{ github.event.pull_request.body }} run: | - DESCRIPTION="$PR_BODY" - if [ -z "$DESCRIPTION" ] || [ "$DESCRIPTION" == "null" ]; then + if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then echo "::warning::PR description is empty. Please provide a description." fi @@ -42,127 +28,55 @@ jobs: env: PR_BODY: ${{ github.event.pull_request.body }} run: | - BODY="$PR_BODY" - if ! echo "$BODY" | grep -qE "(#[0-9]+|Closes|Fixes|Resolves)"; then + if ! printf '%s' "$PR_BODY" | grep -qE '(#[0-9]+|Closes|Fixes|Resolves)'; then echo "::notice::No linked issues found. Consider linking related issues." fi - # ======================================== - # Size Check - # ======================================== size-check: name: Check PR Size runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v44 - with: - files_separator: ' ' - - name: Calculate changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - ADDED=$(git diff --stat origin/${{ github.base_ref }}...HEAD | grep -o '[0-9]* insertion' | grep -o '[0-9]*' | awk '{s+=$1} END {print s+0}') - DELETED=$(git diff --stat origin/${{ github.base_ref }}...HEAD | grep -o '[0-9]* deletion' | grep -o '[0-9]*' | awk '{s+=$1} END {print s+0}') + STATS=$(git diff --shortstat "$BASE_SHA...$HEAD_SHA") + ADDED=$(printf '%s' "$STATS" | sed -n 's/.*\([0-9][0-9]*\) insertion.*/\1/p') + DELETED=$(printf '%s' "$STATS" | sed -n 's/.*\([0-9][0-9]*\) deletion.*/\1/p') + ADDED=${ADDED:-0} + DELETED=${DELETED:-0} TOTAL=$((ADDED + DELETED)) echo "Lines added: $ADDED" echo "Lines deleted: $DELETED" echo "Total changes: $TOTAL" - - if [ $TOTAL -gt 2000 ]; then + if [ "$TOTAL" -gt 2000 ]; then echo "::warning::Large PR detected ($TOTAL lines). Consider splitting into smaller PRs." fi - name: Count changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - FILES=${{ steps.changed-files.outputs.all_changed_files_count }} + FILES=$(git diff --name-only "$BASE_SHA...$HEAD_SHA" | wc -l | tr -d ' ') echo "Changed files: $FILES" - if [ $FILES -gt 50 ]; then + if [ "$FILES" -gt 50 ]; then echo "::warning::Many files changed ($FILES). Consider splitting into smaller PRs." fi - # ======================================== - # Code Review Checklist - # ======================================== - review-checklist: - name: Review Checklist - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Create review checklist - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const hasBotChecklist = comments.some(comment => - comment.user.type === 'Bot' && comment.body.includes('Review Checklist') - ); - - if (!hasBotChecklist) { - const checklist = ` - ## Review Checklist - - ### Code Quality - - [ ] Code follows project style guidelines - - [ ] No console.log or debug statements - - [ ] Proper error handling implemented - - [ ] Tests added/updated for new features - - ### Testing - - [ ] All tests pass locally - - [ ] Unit tests cover new code - - [ ] E2E tests pass for affected areas - - [ ] Manual testing completed - - ### Documentation - - [ ] API documentation updated (if needed) - - [ ] README updated (if needed) - - [ ] Comments explain complex logic - - ### Security - - [ ] No hardcoded credentials - - [ ] Input validation implemented - - [ ] Proper authentication/authorization checks - `; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: checklist - }); - } - - # ======================================== - # Quick Smoke Tests - # ======================================== smoke-tests: name: Smoke Tests runs-on: ubuntu-latest - strategy: matrix: check: [backend-imports, frontend-build] - steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Backend imports check if: matrix.check == 'backend-imports' @@ -170,7 +84,6 @@ jobs: run: | python -m pip install -e ".[dev,backtrader]" || pip install -e . python -c " - import sys import app.main import app.api.router import app.db.database @@ -183,102 +96,3 @@ jobs: run: | npm ci npm run build - - # ======================================== - # Required Labels Check - # ======================================== - label-check: - name: Required Labels - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - name: Check for required labels - run: | - LABELS="${{ toJson(github.event.pull_request.labels.*.name) }}" - - echo "PR Labels: $LABELS" - - # Check if PR has at least one type label - if echo "$LABELS" | grep -qE "(feature|fix|docs|refactor|test|chore|perf|ci)"; then - echo "::notice::PR has a valid type label." - else - echo "::warning::PR missing type label (feature/fix/docs/refactor/test/chore/perf/ci)" - fi - - # ======================================== - # Merge Ready Check - # ======================================== - merge-ready: - name: Merge Ready Check - runs-on: ubuntu-latest - needs: [pr-validation, size-check, review-checklist, smoke-tests] - if: always() - - steps: - - name: Check all jobs - run: | - echo "PR Validation: ${{ needs.pr-validation.result }}" - echo "Size Check: ${{ needs.size-check.result }}" - echo "Review Checklist: ${{ needs.review-checklist.result }}" - echo "Smoke Tests: ${{ needs.smoke-tests.result }}" - - - name: Add merge-ready label - if: | - needs.pr-validation.result == 'success' && - needs.size-check.result == 'success' && - needs.review-checklist.result == 'success' && - needs.smoke-tests.result == 'success' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: ['merge-ready'] - }) - - - name: Remove merge-ready label on failure - if: | - needs.pr-validation.result == 'failure' || - needs.size-check.result == 'failure' || - needs.review-checklist.result == 'failure' || - needs.smoke-tests.result == 'failure' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'merge-ready' - }); - } catch (e) { - // Label doesn't exist, ignore - } - - # ======================================== - # PR Summary - # ======================================== - pr-summary: - name: PR Summary - runs-on: ubuntu-latest - needs: [pr-validation, size-check, smoke-tests] - if: always() - - steps: - - name: Generate summary - run: | - echo "## Pull Request Summary :page_facing_up:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Check Results" >> $GITHUB_STEP_SUMMARY - echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| PR Validation | ${{ needs.pr-validation.result == 'success' && ':white_check_mark:' || ':x:' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Size Check | ${{ needs.size-check.result == 'success' && ':white_check_mark:' || ':warning:' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Smoke Tests | ${{ needs.smoke-tests.result == 'success' && ':white_check_mark:' || ':x:' }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 000000000..89c4da228 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,22 @@ +name: PR Governance (Bootstrap) + +# This read-only bootstrap remains inactive until the equivalent workflow and +# its trusted governance implementation are promoted to the default branch. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +# This skeleton deliberately has no actions. Task 4 will add the trusted-base +# governance check after bootstrap. +jobs: + governance-bootstrap: + name: Governance bootstrap pending default-branch promotion + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.number > 0 }} + steps: + - name: Report bootstrap status + run: echo "Read-only governance bootstrap; awaiting default-branch promotion." diff --git a/scripts/ci/tests/test_workflow_governance_contract.py b/scripts/ci/tests/test_workflow_governance_contract.py new file mode 100644 index 000000000..fcd035fad --- /dev/null +++ b/scripts/ci/tests/test_workflow_governance_contract.py @@ -0,0 +1,95 @@ +"""Iteration 195 Task 2 workflow-security contract tests. + +These tests intentionally inspect workflow text with only the standard +library. They guard the event, permission, side-effect, and action-pinning +boundaries without evaluating untrusted workflow content. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +PR_CHECK = WORKFLOWS / "pr-check.yml" +PR_GOVERNANCE = WORKFLOWS / "pr-governance.yml" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _uses_lines(workflow: str) -> list[str]: + return [line.strip() for line in workflow.splitlines() if "uses:" in line] + + +class TestPrCheckWorkflowContract: + def test_pr_code_checks_use_only_the_unprivileged_pr_event(self) -> None: + workflow = _read(PR_CHECK) + + assert re.search(r"^\s{2}pull_request:\s*$", workflow, re.MULTILINE) + assert "pull_request_target" not in workflow + assert "workflow_dispatch" not in workflow + + def test_pr_check_permissions_are_read_only(self) -> None: + workflow = _read(PR_CHECK) + + assert re.search(r"^permissions:\n\s+contents: read\n\s+pull-requests: read\s*$", workflow, re.MULTILINE) + assert not re.search(r"^\s*[\w-]+:\s*write\s*$", workflow, re.MULTILINE) + + def test_pr_check_has_no_automatic_merge_qualification_side_effects(self) -> None: + workflow = _read(PR_CHECK).lower() + + for forbidden in ( + "merge-ready", + "github-script", + "createcomment", + "addlabels", + "removelabel", + "issues.createcomment", + "issues.addlabels", + "issues.removelabel", + ): + assert forbidden not in workflow + + def test_pr_check_actions_are_sha_pinned_with_version_comments(self) -> None: + workflow = _read(PR_CHECK) + uses_lines = _uses_lines(workflow) + + assert uses_lines, "PR code checks must explicitly pin their checkout action" + for line in uses_lines: + assert re.search(r"uses:\s+[^\s@]+@[0-9a-f]{40}\s+#\s+v\d", line), line + + +class TestPrGovernanceWorkflowContract: + def test_governance_workflow_is_a_read_only_metadata_bootstrap(self) -> None: + workflow = _read(PR_GOVERNANCE) + + assert re.search(r"^\s{2}pull_request_target:\s*$", workflow, re.MULTILINE) + assert re.search(r"^permissions:\n\s+contents: read\n\s+pull-requests: read\s*$", workflow, re.MULTILINE) + assert not re.search(r"^\s*[\w-]+:\s*write\s*$", workflow, re.MULTILINE) + + def test_governance_workflow_does_not_touch_untrusted_pr_code_or_write_state(self) -> None: + workflow = _read(PR_GOVERNANCE).lower() + + for forbidden in ( + "checkout", + "github.event.pull_request.head", + "secrets.", + "pip install", + "npm install", + "npm ci", + "createcomment", + "addlabels", + "removelabel", + "github-script", + "check_pr_governance.py", + ): + assert forbidden not in workflow + + assert ( + 'run: echo "read-only governance bootstrap; awaiting default-branch promotion."' + in workflow + ) From eca78d0fe356b727293bef1bc6ff6b09a62361a6 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 01:54:36 +0800 Subject: [PATCH 09/32] test(ci): harden workflow permission contract --- .../test_workflow_governance_contract.py | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/scripts/ci/tests/test_workflow_governance_contract.py b/scripts/ci/tests/test_workflow_governance_contract.py index fcd035fad..b101f35e5 100644 --- a/scripts/ci/tests/test_workflow_governance_contract.py +++ b/scripts/ci/tests/test_workflow_governance_contract.py @@ -1,8 +1,8 @@ """Iteration 195 Task 2 workflow-security contract tests. -These tests intentionally inspect workflow text with only the standard -library. They guard the event, permission, side-effect, and action-pinning -boundaries without evaluating untrusted workflow content. +These tests parse workflow YAML structurally before enforcing the event, +permission, side-effect, and action-pinning boundaries. They do not evaluate +untrusted workflow content. """ from __future__ import annotations @@ -10,6 +10,7 @@ import re from pathlib import Path +import yaml REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOWS = REPO_ROOT / ".github" / "workflows" @@ -25,19 +26,92 @@ def _uses_lines(workflow: str) -> list[str]: return [line.strip() for line in workflow.splitlines() if "uses:" in line] +def _workflow_mapping(workflow: str) -> dict[str, object]: + """Load workflow YAML without coercing GitHub's ``on`` key to a boolean.""" + parsed = yaml.load(workflow, Loader=yaml.BaseLoader) + assert isinstance(parsed, dict), "workflow must be a YAML mapping" + return parsed + + +def _top_level_permissions(workflow: str) -> dict[str, object]: + permissions = _workflow_mapping(workflow).get("permissions") + assert isinstance(permissions, dict), "workflow must declare permissions as a mapping" + return permissions + + +def _event_issues(workflow: str, expected: set[str]) -> list[str]: + events = _workflow_mapping(workflow).get("on") + if not isinstance(events, dict): + return ["on must be a mapping of workflow events"] + + actual = {str(event) for event in events} + if actual != expected: + return [f"expected events {sorted(expected)!r}, got {sorted(actual)!r}"] + return [] + + +def _permission_issues(workflow: str) -> list[str]: + """Return every write-capable permission at any YAML nesting level.""" + issues: list[str] = [] + + def visit(node: object, path: str) -> None: + if isinstance(node, dict): + for key, value in node.items(): + key_text = str(key) + child_path = f"{path}.{key_text}" if path else key_text + if key_text == "permissions": + issues.extend(_permission_declaration_issues(value, child_path)) + visit(value, child_path) + elif isinstance(node, list): + for index, value in enumerate(node): + visit(value, f"{path}[{index}]") + + visit(_workflow_mapping(workflow), "") + return issues + + +def _permission_declaration_issues(declaration: object, path: str) -> list[str]: + if isinstance(declaration, dict): + return [ + f"{path}.{scope} grants {level}" + for scope, level in declaration.items() + if str(level).strip().lower() in {"write", "write-all"} + ] + if str(declaration).strip().lower() == "write-all": + return [f"{path} grants write-all"] + return [] + + class TestPrCheckWorkflowContract: + def test_pr_check_rejects_job_level_write_all_permissions(self) -> None: + workflow = _read(PR_CHECK).replace( + " pr-validation:\n", " pr-validation:\n permissions: write-all\n", 1 + ) + + assert _permission_issues(workflow) + def test_pr_code_checks_use_only_the_unprivileged_pr_event(self) -> None: workflow = _read(PR_CHECK) - assert re.search(r"^\s{2}pull_request:\s*$", workflow, re.MULTILINE) - assert "pull_request_target" not in workflow - assert "workflow_dispatch" not in workflow + assert _event_issues(workflow, {"pull_request"}) == [] + + def test_pr_check_rejects_additional_trigger(self) -> None: + workflow = _read(PR_CHECK).replace( + " types: [opened, synchronize, reopened]\n", + " types: [opened, synchronize, reopened]\n push:\n", + 1, + ) + + assert _event_issues(workflow, {"pull_request"}) def test_pr_check_permissions_are_read_only(self) -> None: workflow = _read(PR_CHECK) - assert re.search(r"^permissions:\n\s+contents: read\n\s+pull-requests: read\s*$", workflow, re.MULTILINE) - assert not re.search(r"^\s*[\w-]+:\s*write\s*$", workflow, re.MULTILINE) + assert _top_level_permissions(workflow) == { + "contents": "read", + "pull-requests": "read", + } + assert _permission_issues(workflow) == [] def test_pr_check_has_no_automatic_merge_qualification_side_effects(self) -> None: workflow = _read(PR_CHECK).lower() @@ -64,12 +138,36 @@ def test_pr_check_actions_are_sha_pinned_with_version_comments(self) -> None: class TestPrGovernanceWorkflowContract: + def test_governance_uses_only_pull_request_target(self) -> None: + assert _event_issues(_read(PR_GOVERNANCE), {"pull_request_target"}) == [] + + def test_governance_rejects_additional_trigger(self) -> None: + workflow = _read(PR_GOVERNANCE).replace( + " types: [opened, synchronize, reopened]\n", + " types: [opened, synchronize, reopened]\n issue_comment:\n", + 1, + ) + + assert _event_issues(workflow, {"pull_request_target"}) + + def test_governance_rejects_job_level_flow_mapping_write_permission(self) -> None: + workflow = _read(PR_GOVERNANCE).replace( + " governance-bootstrap:\n", + " governance-bootstrap:\n permissions: {contents: write}\n", + 1, + ) + + assert _permission_issues(workflow) + def test_governance_workflow_is_a_read_only_metadata_bootstrap(self) -> None: workflow = _read(PR_GOVERNANCE) assert re.search(r"^\s{2}pull_request_target:\s*$", workflow, re.MULTILINE) - assert re.search(r"^permissions:\n\s+contents: read\n\s+pull-requests: read\s*$", workflow, re.MULTILINE) - assert not re.search(r"^\s*[\w-]+:\s*write\s*$", workflow, re.MULTILINE) + assert _top_level_permissions(workflow) == { + "contents": "read", + "pull-requests": "read", + } + assert _permission_issues(workflow) == [] def test_governance_workflow_does_not_touch_untrusted_pr_code_or_write_state(self) -> None: workflow = _read(PR_GOVERNANCE).lower() From 267bd9d764bc1d136130201b09233e2c00b44a43 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 02:22:37 +0800 Subject: [PATCH 10/32] feat(governance): define owners and ruleset desired state --- .github/CODEOWNERS | 35 ++ .github/governance/README.md | 52 ++ .github/governance/risk-paths.json | 95 ++++ .github/governance/rulesets/dev.json | 46 ++ .github/governance/rulesets/master.json | 52 ++ .github/governance/rulesets/release-tags.json | 36 ++ scripts/ci/governance_contract.py | 468 ++++++++++++++++++ .../ci/tests/fixtures/github-rulesets.json | 95 ++++ scripts/ci/tests/test_governance_contract.py | 213 ++++++++ .../ci/tests/test_verify_github_governance.py | 123 +++++ scripts/ci/verify_github_governance.py | 267 ++++++++++ 11 files changed, 1482 insertions(+) create mode 100644 .github/governance/README.md create mode 100644 .github/governance/risk-paths.json create mode 100644 .github/governance/rulesets/dev.json create mode 100644 .github/governance/rulesets/master.json create mode 100644 .github/governance/rulesets/release-tags.json create mode 100644 scripts/ci/governance_contract.py create mode 100644 scripts/ci/tests/fixtures/github-rulesets.json create mode 100644 scripts/ci/tests/test_governance_contract.py create mode 100644 scripts/ci/tests/test_verify_github_governance.py create mode 100644 scripts/ci/verify_github_governance.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1d922acd..f83137291 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,3 +20,38 @@ /scripts/ci/large_file_ratchet.py @cloudQuant /scripts/ci/mypy_ratchet.py @cloudQuant /scripts/ci/npm_audit_ratchet.py @cloudQuant + +# Iteration 195 governance coverage. D2 confirms @cloudQuant is the only +# currently verified GitHub owner. Rules remain broad-to-narrow so later +# domain owners can be added without replacing the ratchet baseline above. + +# Broad repository control planes +/.github/** @cloudQuant +/scripts/ci/** @cloudQuant +/src/backend/app/** @cloudQuant +/src/frontend/src/** @cloudQuant +/src/bt_api_py/** @cloudQuant +/docker/** @cloudQuant +/Dockerfile* @cloudQuant +/src/backend/Dockerfile @cloudQuant +/src/frontend/Dockerfile @cloudQuant +/scripts/ops/release.sh @cloudQuant +/pyproject.toml @cloudQuant +/uv.lock @cloudQuant +/config/*.lock @cloudQuant +/src/backend/pyproject.toml @cloudQuant +/src/backend/requirements.txt @cloudQuant +/docs/requirements.txt @cloudQuant +/tests/e2e/requirements.txt @cloudQuant +/src/backend/package-lock.json @cloudQuant +/src/frontend/package-lock.json @cloudQuant + +# Narrow R2/R3 domains +/.github/workflows/** @cloudQuant +/.github/governance/** @cloudQuant +/src/backend/app/api/auth* @cloudQuant +/src/backend/app/middleware/** @cloudQuant +/src/backend/app/db/** @cloudQuant +/src/backend/app/models/** @cloudQuant +/src/frontend/src/router/** @cloudQuant +/src/frontend/src/stores/** @cloudQuant diff --git a/.github/governance/README.md b/.github/governance/README.md new file mode 100644 index 000000000..908dd44c6 --- /dev/null +++ b/.github/governance/README.md @@ -0,0 +1,52 @@ +# Iteration 195 governance desired state + +This directory is the repository-side, auditable contract for community PR +governance. It is not proof that a GitHub Ruleset, branch protection setting, +release environment, or security disclosure channel is active. + +## Sources of truth + +- `risk-paths.json` is the only source used to classify changed paths. The + highest matching level wins: `R0` documentation, default `R1`, elevated + `R2`, and highest-risk `R3`. Labels can add review context but cannot lower a + path-derived risk level. +- `rulesets/*.json` are normalized desired-state manifests. They intentionally + exclude GitHub API IDs, timestamps, actor IDs, secrets, and guessed required + check contexts. +- `.github/CODEOWNERS` names the currently verified GitHub owner + `@cloudQuant`; it preserves the Iteration 193 ratchet entries and adds the + governance coverage used by this iteration. + +## Current gate limits + +D2 has one verified owner but no second independent approval pool. The +manifests therefore record code-owner review as desired but disabled; they do +not claim it is remotely enabled. D3 permits only a desired-state draft and a +read-only verifier until Ruleset capability is tested. D4 remains blocked, so +both branch manifests deliberately use an empty `required_checks.contexts` +list with a source pointing to future real Check Run evidence. D6 also keeps +tag-authorized actors empty until release-environment capability is verified. + +`enforcement: "active"` expresses the eventual Ruleset configuration to be +read back after its gates pass. `activation.remote_state: "not_applied"` +records the current truth: this repository has not applied it externally. + +## Read-only verification + +Use a sanitized fixture while developing the contract: + +```bash +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/verify_github_governance.py --fixture \ + scripts/ci/tests/fixtures/github-rulesets.json \ + --manifest-dir .github/governance/rulesets +``` + +After D3 grants a read-only capability check, `--live --repo +cloudQuant/backtrader_web` only invokes GitHub CLI `api` GET endpoints to list +Rulesets and retrieve their details. A difference exits non-zero and produces +both a concise human report and JSON. The verifier never creates, updates, +deletes, applies, pushes, or bypasses a Rule. + +The included fixture represents the normalized shape expected after approved +external application. It is a test fixture, not external activation evidence. diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json new file mode 100644 index 000000000..f820683da --- /dev/null +++ b/.github/governance/risk-paths.json @@ -0,0 +1,95 @@ +{ + "version": 1, + "selection": { + "mode": "highest_matching_risk", + "labels_can_lower_risk": false, + "description": "A pull request receives the highest risk level matched by any changed path. Labels and author-provided declarations may request more review but cannot lower path-derived risk." + }, + "default": { + "level": "R1", + "description": "Unmapped behavior changes receive normal review and affected-test evidence." + }, + "rules": [ + { + "id": "r0-documentation", + "level": "R0", + "globs": [ + "docs/**", + "README*", + "**/README*.md" + ], + "description": "Documentation-only changes require format and link/build evidence." + }, + { + "id": "r2-auth-session", + "level": "R2", + "globs": [ + "src/backend/app/api/auth*", + "src/backend/app/api/**/auth*", + "src/backend/app/middleware/**" + ], + "description": "Authentication and session boundaries require elevated review." + }, + { + "id": "r2-persistence-and-domain-models", + "level": "R2", + "globs": [ + "src/backend/app/db/**", + "src/backend/app/models/**" + ], + "description": "Database access and domain models require elevated review." + }, + { + "id": "r2-client-routing-state", + "level": "R2", + "globs": [ + "src/frontend/src/router/**", + "src/frontend/src/stores/**" + ], + "description": "Client routing and shared state require elevated review." + }, + { + "id": "r2-bt-api", + "level": "R2", + "globs": [ + "src/bt_api_py/**" + ], + "description": "Broker/API adapter changes require elevated review." + }, + { + "id": "r3-workflow-and-governance", + "level": "R3", + "globs": [ + ".github/workflows/**", + ".github/governance/**" + ], + "description": "CI execution and governance boundaries require the highest review." + }, + { + "id": "r3-container-and-release", + "level": "R3", + "globs": [ + "docker/**", + "Dockerfile*", + "**/Dockerfile", + "**/*Dockerfile", + "scripts/ops/release.sh" + ], + "description": "Container and release changes require the highest review." + }, + { + "id": "r3-dependency-and-security-inputs", + "level": "R3", + "globs": [ + "pyproject.toml", + "uv.lock", + "config/*.lock", + "**/package-lock.json", + "**/requirements*.txt", + ".env.example", + "SECURITY.md" + ], + "description": "Dependency, lock, environment-example, and disclosure inputs require the highest review." + } + ] +} diff --git a/.github/governance/rulesets/dev.json b/.github/governance/rulesets/dev.json new file mode 100644 index 000000000..9b5f1e2a9 --- /dev/null +++ b/.github/governance/rulesets/dev.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "name": "Iteration 195: dev", + "target": { + "kind": "branch", + "include": [ + "refs/heads/dev" + ] + }, + "enforcement": "active", + "activation": { + "remote_state": "not_applied", + "gate": "D3", + "reason": "This is desired state only until Ruleset capability and a shadow observation period are verified." + }, + "bypass": { + "normal_policy": "none", + "emergency_policy": { + "incident_required": true, + "reason_required": true, + "postmortem_within_hours": 24 + }, + "actor_source": "GitHub API readback after D3 capability validation", + "actors": [] + }, + "pull_request": { + "required": true, + "required_approvals": 1, + "conversation_resolution": true, + "code_owner_review": { + "desired_when_d2_ready": true, + "enabled": false, + "reason": "D2 has only one verified owner; required code-owner review stays disabled until an independent approval pool is confirmed." + } + }, + "protection": { + "block_force_push": true, + "block_deletion": true + }, + "required_checks": { + "status": "pending_D4", + "contexts": [], + "source": "Task 4 real draft-PR Check Run evidence; names must not be guessed from workflow YAML.", + "strict": true + } +} diff --git a/.github/governance/rulesets/master.json b/.github/governance/rulesets/master.json new file mode 100644 index 000000000..81b83f87e --- /dev/null +++ b/.github/governance/rulesets/master.json @@ -0,0 +1,52 @@ +{ + "schema_version": 1, + "name": "Iteration 195: master", + "target": { + "kind": "branch", + "include": [ + "refs/heads/master" + ] + }, + "enforcement": "active", + "activation": { + "remote_state": "not_applied", + "gate": "D3", + "reason": "This is desired state only until D3 capability validation and the D4 master-PR rehearsal are complete." + }, + "bypass": { + "normal_policy": "none", + "emergency_policy": { + "incident_required": true, + "reason_required": true, + "postmortem_within_hours": 24 + }, + "actor_source": "GitHub API readback after D3 capability validation", + "actors": [] + }, + "pull_request": { + "required": true, + "required_approvals": 2, + "conversation_resolution": true, + "code_owner_review": { + "desired_when_d2_ready": true, + "enabled": false, + "reason": "D2 has not yet established a second independent owner; do not enable required code-owner review before that decision changes." + } + }, + "protection": { + "block_force_push": true, + "block_deletion": true + }, + "required_checks": { + "status": "pending_D4", + "contexts": [], + "source": "Task 4 hotfix/release draft-PR Check Run evidence; names must not be guessed from workflow YAML.", + "strict": true + }, + "release_evidence_contract": { + "release_checklist": true, + "full_validation": true, + "rollback_point": true, + "hotfix_incident_and_forward_port": true + } +} diff --git a/.github/governance/rulesets/release-tags.json b/.github/governance/rulesets/release-tags.json new file mode 100644 index 000000000..f84789fe8 --- /dev/null +++ b/.github/governance/rulesets/release-tags.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "name": "Iteration 195: release tags", + "target": { + "kind": "tag", + "include": [ + "refs/tags/v*" + ] + }, + "enforcement": "active", + "activation": { + "remote_state": "not_applied", + "gate": "D3_D6", + "reason": "Tag protection is desired state only until Ruleset capability, release environment, and authorized actor handling are verified." + }, + "bypass": { + "normal_policy": "none", + "emergency_policy": { + "incident_required": true, + "reason_required": true, + "postmortem_within_hours": 24 + }, + "actor_source": "GitHub API readback after D3/D6 capability validation", + "actors": [] + }, + "tag_protection": { + "block_creation": true, + "block_update": true, + "block_deletion": true, + "authorized_actors": { + "status": "pending_D3_D6", + "actors": [], + "source": "D3 Ruleset capability and D6 protected release-environment evidence" + } + } +} diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py new file mode 100644 index 000000000..fd1080da9 --- /dev/null +++ b/scripts/ci/governance_contract.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +"""Pure policy helpers for the Iteration 195 PR governance contract. + +This module deliberately contains no GitHub write operation. It is shared by +the read-only Ruleset verifier and the later PR Governance gate. +""" + +from __future__ import annotations + +import fnmatch +import json +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + + +RISK_ORDER = {"R0": 0, "R1": 1, "R2": 2, "R3": 3} +REQUIRED_MANIFESTS = { + "dev": { + "name": "Iteration 195: dev", + "target_kind": "branch", + "target_include": ["refs/heads/dev"], + "required_approvals": 1, + }, + "master": { + "name": "Iteration 195: master", + "target_kind": "branch", + "target_include": ["refs/heads/master"], + "required_approvals": 2, + }, + "release-tags": { + "name": "Iteration 195: release tags", + "target_kind": "tag", + "target_include": ["refs/tags/v*"], + }, +} +VALID_BYPASS_ACTOR_TYPES = { + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", +} +RELEASE_BRANCH = re.compile(r"^release/v\d+\.\d+\.\d+(?:-rc\d+)?$") +HOTFIX_BRANCH = re.compile(r"^hotfix/master-[A-Za-z0-9][A-Za-z0-9._-]*$") +DEV_BRANCH_PREFIXES = ("feature/", "fix/", "docs/", "refactor/", "test/") + + +def load_risk_map(path: Path | str) -> dict[str, Any]: + """Load the single source of truth for path risk classification.""" + with Path(path).open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError("risk map must be a JSON object") + return value + + +def _glob_matches(path: str, pattern: str) -> bool: + """Match repository-relative paths using CODEOWNERS-style glob intent.""" + normalized_path = path.lstrip("/") + normalized_pattern = pattern.lstrip("/") + return fnmatch.fnmatchcase(normalized_path, normalized_pattern) + + +def _risk_rank(level: str) -> int: + if level not in RISK_ORDER: + raise ValueError(f"unknown risk level: {level!r}") + return RISK_ORDER[level] + + +def classify_paths( + changed_files: Sequence[str], + risk_map: Mapping[str, Any], + *, + labels: Sequence[str] | None = None, +) -> dict[str, Any]: + """Classify paths and always retain the highest matched risk level. + + Labels are included in the result only as ignored metadata. They can + request more review in a human process, but cannot lower path-derived risk. + """ + default = risk_map.get("default", {}) + default_level = default.get("level", "R1") if isinstance(default, Mapping) else "R1" + _risk_rank(str(default_level)) + + rules = risk_map.get("rules", []) + if not isinstance(rules, list): + raise ValueError("risk map rules must be a list") + + matches: list[dict[str, str]] = [] + highest: str | None = None + for path in changed_files: + if not isinstance(path, str) or not path: + raise ValueError("changed files must be non-empty strings") + for rule in rules: + if not isinstance(rule, Mapping): + raise ValueError("risk map rule must be an object") + level = str(rule.get("level", "")) + patterns = rule.get("globs", []) + if not isinstance(patterns, list): + rule_id = rule.get("id", "") + raise ValueError(f"risk map rule {rule_id} globs must be a list") + for pattern in patterns: + if isinstance(pattern, str) and _glob_matches(path, pattern): + matches.append( + { + "path": path, + "rule_id": str(rule.get("id", "")), + "level": level, + "glob": pattern, + } + ) + if highest is None or _risk_rank(level) > _risk_rank(highest): + highest = level + + selection = risk_map.get("selection", {}) + can_lower = ( + bool(selection.get("labels_can_lower_risk", False)) + if isinstance(selection, Mapping) + else False + ) + return { + "risk": highest or str(default_level), + "matches": matches, + "label_can_lower_risk": can_lower, + "ignored_labels": sorted(set(labels or [])), + } + + +def _has_value(evidence: Mapping[str, Any], key: str) -> bool: + value = evidence.get(key) + return value is True or (isinstance(value, str) and bool(value.strip())) + + +def validate_branch_contract( + base_branch: str, head_branch: str, evidence: Mapping[str, Any] +) -> list[str]: + """Return contributor-actionable routing and evidence issues for a PR.""" + issues: list[str] = [] + if base_branch == "master": + if RELEASE_BRANCH.fullmatch(head_branch): + if not _has_value(evidence, "release_checklist"): + issues.append( + "release/vX.Y.Z -> master requires a completed release checklist" + ) + if not _has_value(evidence, "release_validation"): + issues.append( + "release/vX.Y.Z -> master requires release validation evidence" + ) + elif HOTFIX_BRANCH.fullmatch(head_branch): + if not _has_value(evidence, "incident"): + issues.append( + "hotfix/master-* -> master requires an incident or private disclosure record" + ) + if not _has_value(evidence, "forward_port_plan"): + issues.append( + "hotfix/master-* -> master requires a forward-port plan for dev" + ) + else: + issues.append( + "master only accepts release/vX.Y.Z or hotfix/master-* branches" + ) + elif base_branch == "dev": + if not head_branch.startswith(DEV_BRANCH_PREFIXES): + issues.append( + "dev accepts feature/*, fix/*, docs/*, refactor/*, or test/* branches" + ) + else: + issues.append(f"unsupported target branch: {base_branch}") + return issues + + +def load_manifests(manifest_dir: Path | str) -> dict[str, dict[str, Any]]: + """Load the three normalized Ruleset desired-state files.""" + directory = Path(manifest_dir) + manifests: dict[str, dict[str, Any]] = {} + for key in REQUIRED_MANIFESTS: + path = directory / f"{key}.json" + if not path.is_file(): + continue + with path.open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError(f"manifest {path} must be a JSON object") + manifests[key] = value + return manifests + + +def _issue(path: str, message: str) -> str: + return f"{path}: {message}" + + +def _validate_bypass(key: str, bypass: Any) -> list[str]: + if not isinstance(bypass, Mapping): + return [_issue(f"{key}.bypass", "must be an object")] + + issues: list[str] = [] + if bypass.get("normal_policy") != "none": + issues.append(_issue(f"{key}.bypass.normal_policy", "must be 'none'")) + + emergency = bypass.get("emergency_policy") + if not isinstance(emergency, Mapping): + issues.append(_issue(f"{key}.bypass.emergency_policy", "must be an object")) + elif ( + emergency.get("incident_required") is not True + or emergency.get("reason_required") is not True + or emergency.get("postmortem_within_hours") != 24 + ): + issues.append( + _issue( + f"{key}.bypass.emergency_policy", + "must require incident, reason, and a 24-hour postmortem", + ) + ) + + actors = bypass.get("actors") + if not isinstance(actors, list): + return issues + [_issue(f"{key}.bypass.actors", "must be a list")] + for index, actor in enumerate(actors): + actor_path = f"{key}.bypass.actors[{index}]" + if not isinstance(actor, Mapping): + issues.append(_issue(actor_path, "must be an object from API readback")) + continue + actor_type = actor.get("actor_type") + if actor_type not in VALID_BYPASS_ACTOR_TYPES: + issues.append( + _issue( + f"{actor_path}.actor_type", + "is not a valid GitHub bypass actor type", + ) + ) + actor_id = actor.get("actor_id") + if not isinstance(actor_id, int) or isinstance(actor_id, bool) or actor_id <= 0: + issues.append( + _issue( + f"{actor_path}.actor_id", "must be a positive API-readback integer" + ) + ) + return issues + + +def _validate_required_checks(key: str, required_checks: Any) -> list[str]: + if not isinstance(required_checks, Mapping): + return [_issue(f"{key}.required_checks", "must be an object")] + + status = required_checks.get("status") + contexts = required_checks.get("contexts") + source = required_checks.get("source") + issues: list[str] = [] + if not isinstance(contexts, list) or not all( + isinstance(context, str) for context in contexts + ): + issues.append( + _issue( + f"{key}.required_checks.contexts", "must be a list of check contexts" + ) + ) + return issues + if not isinstance(source, str) or not source.strip(): + issues.append( + _issue(f"{key}.required_checks.source", "must record the evidence source") + ) + if required_checks.get("strict") is not True: + issues.append(_issue(f"{key}.required_checks.strict", "must be true")) + if status == "pending_D4": + if contexts: + issues.append( + _issue( + f"{key}.required_checks.contexts", + "must stay empty while D4 is pending", + ) + ) + elif status == "verified": + if not contexts: + issues.append( + _issue( + f"{key}.required_checks.contexts", + "must list confirmed check contexts when status is verified", + ) + ) + else: + issues.append( + _issue(f"{key}.required_checks.status", "must be pending_D4 or verified") + ) + return issues + + +def _validate_branch_manifest( + key: str, manifest: Mapping[str, Any], expected: Mapping[str, Any] +) -> list[str]: + issues: list[str] = [] + pull_request = manifest.get("pull_request") + if not isinstance(pull_request, Mapping): + return [_issue(f"{key}.pull_request", "must be an object")] + if pull_request.get("required") is not True: + issues.append(_issue(f"{key}.pull_request.required", "must be true")) + if pull_request.get("required_approvals") != expected["required_approvals"]: + issues.append( + _issue( + f"{key}.pull_request.required_approvals", + f"must be {expected['required_approvals']}", + ) + ) + if pull_request.get("conversation_resolution") is not True: + issues.append( + _issue(f"{key}.pull_request.conversation_resolution", "must be true") + ) + + code_owner = pull_request.get("code_owner_review") + if not isinstance(code_owner, Mapping): + issues.append( + _issue(f"{key}.pull_request.code_owner_review", "must be an object") + ) + elif ( + code_owner.get("desired_when_d2_ready") is not True + or code_owner.get("enabled") is not False + ): + issues.append( + _issue( + f"{key}.pull_request.code_owner_review", + "must remain desired-but-disabled until the D2 approval pool is expanded", + ) + ) + + protection = manifest.get("protection") + if not isinstance(protection, Mapping): + issues.append(_issue(f"{key}.protection", "must be an object")) + elif ( + protection.get("block_force_push") is not True + or protection.get("block_deletion") is not True + ): + issues.append( + _issue(f"{key}.protection", "must block force pushes and deletion") + ) + + issues.extend(_validate_required_checks(key, manifest.get("required_checks"))) + return issues + + +def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: + tag_protection = manifest.get("tag_protection") + if not isinstance(tag_protection, Mapping): + return [_issue(f"{key}.tag_protection", "must be an object")] + issues: list[str] = [] + for field in ("block_creation", "block_update", "block_deletion"): + if tag_protection.get(field) is not True: + issues.append(_issue(f"{key}.tag_protection.{field}", "must be true")) + authorized = tag_protection.get("authorized_actors") + if not isinstance(authorized, Mapping): + issues.append( + _issue(f"{key}.tag_protection.authorized_actors", "must be an object") + ) + elif authorized.get("status") != "pending_D3_D6" or authorized.get("actors") != []: + issues.append( + _issue( + f"{key}.tag_protection.authorized_actors", + "must stay empty until D3/D6 actor capability is verified", + ) + ) + return issues + + +def validate_manifests(manifests: Mapping[str, Mapping[str, Any]]) -> list[str]: + """Validate desired-state manifests before they are compared to GitHub.""" + issues: list[str] = [] + for key, expected in REQUIRED_MANIFESTS.items(): + manifest = manifests.get(key) + if manifest is None: + issues.append(f"missing required manifest: {key}") + continue + if manifest.get("schema_version") != 1: + issues.append(_issue(f"{key}.schema_version", "must be 1")) + if manifest.get("name") != expected["name"]: + issues.append(_issue(f"{key}.name", f"must be {expected['name']!r}")) + target = manifest.get("target") + if not isinstance(target, Mapping): + issues.append(_issue(f"{key}.target", "must be an object")) + else: + if target.get("kind") != expected["target_kind"]: + issues.append( + _issue(f"{key}.target.kind", f"must be {expected['target_kind']!r}") + ) + if target.get("include") != expected["target_include"]: + issues.append( + _issue( + f"{key}.target.include", + f"must be {expected['target_include']!r}", + ) + ) + if manifest.get("enforcement") != "active": + issues.append( + _issue(f"{key}.enforcement", "must be 'active' desired state") + ) + issues.extend(_validate_bypass(key, manifest.get("bypass"))) + if key == "release-tags": + issues.extend(_validate_tag_manifest(key, manifest)) + else: + issues.extend(_validate_branch_manifest(key, manifest, expected)) + return issues + + +def _canonical_actors(actors: Any) -> list[dict[str, Any]]: + if not isinstance(actors, list): + return [] + canonical = [ + {"actor_type": actor.get("actor_type"), "actor_id": actor.get("actor_id")} + for actor in actors + if isinstance(actor, Mapping) + ] + return sorted( + canonical, key=lambda actor: (str(actor["actor_type"]), str(actor["actor_id"])) + ) + + +def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: + """Return only the Ruleset fields that GitHub's API can read back.""" + target = manifest.get("target", {}) + bypass = manifest.get("bypass", {}) + target_mapping = target if isinstance(target, Mapping) else {} + canonical: dict[str, Any] = { + "name": manifest.get("name"), + "target": { + "kind": target_mapping.get("kind"), + "include": sorted(target_mapping.get("include", [])), + }, + "enforcement": manifest.get("enforcement"), + "bypass_actors": _canonical_actors( + bypass.get("actors", []) if isinstance(bypass, Mapping) else [] + ), + } + if target_mapping.get("kind") == "branch": + pull_request = manifest.get("pull_request", {}) + protection = manifest.get("protection", {}) + required_checks = manifest.get("required_checks", {}) + pr_mapping = pull_request if isinstance(pull_request, Mapping) else {} + protection_mapping = protection if isinstance(protection, Mapping) else {} + checks_mapping = required_checks if isinstance(required_checks, Mapping) else {} + code_owner = pr_mapping.get("code_owner_review", {}) + code_owner_mapping = code_owner if isinstance(code_owner, Mapping) else {} + canonical.update( + { + "pull_request": { + "required": pr_mapping.get("required"), + "required_approvals": pr_mapping.get("required_approvals"), + "code_owner_review_enabled": code_owner_mapping.get("enabled"), + "conversation_resolution": pr_mapping.get( + "conversation_resolution" + ), + }, + "protection": { + "block_force_push": protection_mapping.get("block_force_push"), + "block_deletion": protection_mapping.get("block_deletion"), + }, + "required_checks": { + "contexts": sorted(checks_mapping.get("contexts", [])), + "strict": checks_mapping.get("strict"), + }, + } + ) + else: + tag_protection = manifest.get("tag_protection", {}) + tag_mapping = tag_protection if isinstance(tag_protection, Mapping) else {} + canonical["tag_protection"] = { + "block_creation": tag_mapping.get("block_creation"), + "block_update": tag_mapping.get("block_update"), + "block_deletion": tag_mapping.get("block_deletion"), + } + return canonical diff --git a/scripts/ci/tests/fixtures/github-rulesets.json b/scripts/ci/tests/fixtures/github-rulesets.json new file mode 100644 index 000000000..498029527 --- /dev/null +++ b/scripts/ci/tests/fixtures/github-rulesets.json @@ -0,0 +1,95 @@ +{ + "rulesets": [ + { + "id": 101, + "node_id": "RRS_dev_fixture", + "name": "Iteration 195: dev", + "target": "branch", + "enforcement": "active", + "created_at": "2026-08-23T10:00:00Z", + "updated_at": "2026-08-23T10:00:00Z", + "conditions": { + "ref_name": { + "include": ["refs/heads/dev"], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "require_code_owner_review": false, + "required_review_thread_resolution": true + } + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [], + "strict_required_status_checks_policy": true + } + } + ] + }, + { + "id": 102, + "node_id": "RRS_master_fixture", + "name": "Iteration 195: master", + "target": "branch", + "enforcement": "active", + "created_at": "2026-08-23T10:00:00Z", + "updated_at": "2026-08-23T10:00:00Z", + "conditions": { + "ref_name": { + "include": ["refs/heads/master"], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "require_code_owner_review": false, + "required_review_thread_resolution": true + } + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [], + "strict_required_status_checks_policy": true + } + } + ] + }, + { + "id": 103, + "node_id": "RRS_tags_fixture", + "name": "Iteration 195: release tags", + "target": "tag", + "enforcement": "active", + "created_at": "2026-08-23T10:00:00Z", + "updated_at": "2026-08-23T10:00:00Z", + "conditions": { + "ref_name": { + "include": ["refs/tags/v*"], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + {"type": "creation"}, + {"type": "update"}, + {"type": "deletion"} + ] + } + ] +} diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py new file mode 100644 index 000000000..fddf49e1e --- /dev/null +++ b/scripts/ci/tests/test_governance_contract.py @@ -0,0 +1,213 @@ +"""Behavioral contracts for Iteration 195 ownership and routing policy. + +The tests intentionally load the repository's source files instead of +re-implementing the policy in test helpers. Before Task 3 implementation the +load helper fails with a normal assertion, making the expected RED state +observable without masking it as an import-collection error. +""" + +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[3] +GOVERNANCE_CONTRACT = REPO_ROOT / "scripts" / "ci" / "governance_contract.py" +RISK_MAP = REPO_ROOT / ".github" / "governance" / "risk-paths.json" +MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" + + +def _load_contract() -> ModuleType: + assert GOVERNANCE_CONTRACT.is_file(), ( + "Task 3 governance contract is not implemented" + ) + spec = importlib.util.spec_from_file_location( + "governance_contract_under_test", GOVERNANCE_CONTRACT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _risk_map() -> dict[str, object]: + assert RISK_MAP.is_file(), "Task 3 risk map is not implemented" + return json.loads(RISK_MAP.read_text(encoding="utf-8")) + + +def _manifests(contract: ModuleType) -> dict[str, dict[str, object]]: + assert MANIFEST_DIR.is_dir(), "Task 3 ruleset manifests are not implemented" + return contract.load_manifests(MANIFEST_DIR) + + +class TestRiskClassification: + def test_highest_matching_risk_wins_across_mixed_paths(self) -> None: + contract = _load_contract() + + result = contract.classify_paths( + [ + "docs/guide.md", + "src/backend/app/db/database.py", + ".github/workflows/ci.yml", + ], + _risk_map(), + ) + + assert result["risk"] == "R3" + assert result["label_can_lower_risk"] is False + assert {match["level"] for match in result["matches"]} == {"R0", "R2", "R3"} + + def test_documentation_only_change_is_r0(self) -> None: + contract = _load_contract() + + result = contract.classify_paths( + ["docs/architecture/community-pr.md"], _risk_map() + ) + + assert result["risk"] == "R0" + + def test_unmapped_behavior_change_uses_default_r1(self) -> None: + contract = _load_contract() + + result = contract.classify_paths( + ["src/backend/app/services/market_data.py"], _risk_map() + ) + + assert result["risk"] == "R1" + + def test_router_change_is_r2_without_an_r3_path(self) -> None: + contract = _load_contract() + + result = contract.classify_paths( + ["src/frontend/src/router/index.ts"], _risk_map() + ) + + assert result["risk"] == "R2" + + def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: + contract = _load_contract() + + result = contract.classify_paths( + [ + ".github/workflows/ci.yml", + "src/backend/app/api/auth.py", + "src/backend/app/db/database.py", + "src/frontend/src/router/index.ts", + "src/frontend/src/stores/auth.ts", + "src/bt_api_py/client.py", + ], + _risk_map(), + labels=["risk:R0", "merge-ready"], + ) + + assert result["risk"] == "R3" + assert result["label_can_lower_risk"] is False + assert result["ignored_labels"] == ["merge-ready", "risk:R0"] + levels_by_path = { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in { + ".github/workflows/ci.yml", + "src/backend/app/api/auth.py", + "src/backend/app/db/database.py", + "src/frontend/src/router/index.ts", + "src/frontend/src/stores/auth.ts", + "src/bt_api_py/client.py", + } + } + assert levels_by_path == { + ".github/workflows/ci.yml": {"R3"}, + "src/backend/app/api/auth.py": {"R2"}, + "src/backend/app/db/database.py": {"R2"}, + "src/frontend/src/router/index.ts": {"R2"}, + "src/frontend/src/stores/auth.ts": {"R2"}, + "src/bt_api_py/client.py": {"R2"}, + } + + +class TestBranchRoutingContract: + def test_master_rejects_regular_feature_branch(self) -> None: + contract = _load_contract() + + issues = contract.validate_branch_contract( + "master", "feature/market-screen", {} + ) + + assert any( + "master only accepts release/vX.Y.Z or hotfix/master-*" in issue + for issue in issues + ) + + def test_release_and_hotfix_require_different_evidence(self) -> None: + contract = _load_contract() + + release_issues = contract.validate_branch_contract( + "master", + "release/v2.4.1", + {"release_checklist": True, "release_validation": "full regression"}, + ) + hotfix_issues = contract.validate_branch_contract( + "master", + "hotfix/master-session-expiry", + {"incident": "INC-195", "forward_port_plan": "PR to dev"}, + ) + wrong_release_evidence = contract.validate_branch_contract( + "master", + "release/v2.4.1", + {"incident": "INC-195", "forward_port_plan": "PR to dev"}, + ) + wrong_hotfix_evidence = contract.validate_branch_contract( + "master", + "hotfix/master-session-expiry", + {"release_checklist": True, "release_validation": "full regression"}, + ) + + assert release_issues == [] + assert hotfix_issues == [] + assert any("release checklist" in issue for issue in wrong_release_evidence) + assert any("incident" in issue for issue in wrong_hotfix_evidence) + + +class TestRulesetManifestContract: + def test_repository_manifests_are_valid_desired_state(self) -> None: + contract = _load_contract() + + assert contract.validate_manifests(_manifests(contract)) == [] + + def test_manifest_validation_reports_missing_target_and_wrong_enforcement( + self, + ) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + del manifests["master"] + manifests["dev"]["enforcement"] = "evaluate" + + issues = contract.validate_manifests(manifests) + + assert "missing required manifest: master" in issues + assert any("dev.enforcement" in issue and "active" in issue for issue in issues) + + def test_manifest_validation_rejects_unbound_confirmed_check_and_invalid_bypass_actor( + self, + ) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["master"]["required_checks"] = { + "status": "verified", + "contexts": [], + "source": "Task 4 evidence", + "strict": True, + } + manifests["dev"]["bypass"]["actors"] = [ + {"actor_type": "Team", "actor_id": "not-a-github-actor-id"} + ] + + issues = contract.validate_manifests(manifests) + + assert any("master.required_checks.contexts" in issue for issue in issues) + assert any("dev.bypass.actors[0].actor_id" in issue for issue in issues) diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py new file mode 100644 index 000000000..d74fae152 --- /dev/null +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -0,0 +1,123 @@ +"""Verifier-level contracts for normalized GitHub Ruleset readback.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[3] +VERIFIER = REPO_ROOT / "scripts" / "ci" / "verify_github_governance.py" +MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" +FIXTURE = Path(__file__).parent / "fixtures" / "github-rulesets.json" + + +def _load_verifier() -> ModuleType: + assert VERIFIER.is_file(), "Task 3 GitHub governance verifier is not implemented" + if str(VERIFIER.parent) not in sys.path: + sys.path.insert(0, str(VERIFIER.parent)) + spec = importlib.util.spec_from_file_location( + "verify_github_governance_under_test", VERIFIER + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _fixture() -> dict[str, object]: + assert FIXTURE.is_file(), "Task 3 GitHub rulesets fixture is not implemented" + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +class TestRulesetNormalization: + def test_api_ids_timestamps_and_rule_order_do_not_create_drift(self) -> None: + verifier = _load_verifier() + actual = _fixture()["rulesets"] + changed_metadata = copy.deepcopy(actual) + changed_metadata.reverse() + for index, ruleset in enumerate(changed_metadata, start=900): + ruleset["id"] = index + ruleset["node_id"] = f"RRS_changed_{index}" + ruleset["created_at"] = "2036-02-03T04:05:06Z" + ruleset["updated_at"] = "2036-02-04T05:06:07Z" + ruleset["rules"].reverse() + + report = verifier.verify_rulesets(MANIFEST_DIR, changed_metadata) + + assert report["ok"] is True + assert report["differences"] == [] + + def test_real_rule_drift_has_a_human_readable_path(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + pull_request = next( + rule for rule in dev_rule["rules"] if rule["type"] == "pull_request" + ) + pull_request["parameters"]["required_approving_review_count"] = 0 + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + "dev.pull_request.required_approvals" in diff + for diff in report["differences"] + ) + + +class TestVerifierCli: + def test_fixture_cli_emits_machine_readable_success(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(VERIFIER), + "--fixture", + str(FIXTURE), + "--manifest-dir", + str(MANIFEST_DIR), + "--json", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"differences": [], "ok": True} + + def test_live_loader_uses_only_read_only_get_endpoints(self) -> None: + verifier = _load_verifier() + commands: list[list[str]] = [] + raw_rulesets = _fixture()["rulesets"] + + def runner(command: list[str]) -> str: + commands.append(command) + if command[-1].endswith("/rulesets"): + return json.dumps([[{"id": item["id"]} for item in raw_rulesets]]) + ruleset_id = int(command[-1].rsplit("/", maxsplit=1)[-1]) + return json.dumps( + next(item for item in raw_rulesets if item["id"] == ruleset_id) + ) + + loaded = verifier.load_live_rulesets("cloudQuant/backtrader_web", runner=runner) + + assert [item["name"] for item in loaded] == [ + item["name"] for item in raw_rulesets + ] + assert commands[0] == [ + "gh", + "api", + "--paginate", + "--slurp", + "repos/cloudQuant/backtrader_web/rulesets", + ] + assert all( + "POST" not in command and "PATCH" not in command for command in commands + ) diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py new file mode 100644 index 000000000..19c491445 --- /dev/null +++ b/scripts/ci/verify_github_governance.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Read-only verifier for Iteration 195 GitHub Ruleset desired state. + +The script only calls ``gh api`` GET endpoints when ``--live`` is selected. +It never creates, updates, deletes, or applies a GitHub Ruleset. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from governance_contract import load_manifests, normalized_manifest, validate_manifests + + +Runner = Callable[[list[str]], str] + + +def _canonical_actors(actors: Any) -> list[dict[str, Any]]: + if not isinstance(actors, list): + return [] + canonical = [ + {"actor_type": actor.get("actor_type"), "actor_id": actor.get("actor_id")} + for actor in actors + if isinstance(actor, Mapping) + ] + return sorted( + canonical, key=lambda actor: (str(actor["actor_type"]), str(actor["actor_id"])) + ) + + +def _rule_by_type(rules: Any, rule_type: str) -> Mapping[str, Any]: + if not isinstance(rules, list): + return {} + for rule in rules: + if isinstance(rule, Mapping) and rule.get("type") == rule_type: + return rule + return {} + + +def _rule_parameters(rules: Any, rule_type: str) -> Mapping[str, Any]: + rule = _rule_by_type(rules, rule_type) + parameters = rule.get("parameters", {}) + return parameters if isinstance(parameters, Mapping) else {} + + +def _has_rule(rules: Any, rule_type: str) -> bool: + return bool(_rule_by_type(rules, rule_type)) + + +def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: + """Normalize API readback, intentionally ignoring IDs, timestamps, and order.""" + conditions = raw_ruleset.get("conditions", {}) + ref_name = conditions.get("ref_name", {}) if isinstance(conditions, Mapping) else {} + include = ref_name.get("include", []) if isinstance(ref_name, Mapping) else [] + rules = raw_ruleset.get("rules", []) + target = raw_ruleset.get("target") + canonical: dict[str, Any] = { + "name": raw_ruleset.get("name"), + "target": { + "kind": target, + "include": sorted(include) if isinstance(include, list) else [], + }, + "enforcement": raw_ruleset.get("enforcement"), + "bypass_actors": _canonical_actors(raw_ruleset.get("bypass_actors", [])), + } + if target == "branch": + pull_request = _rule_parameters(rules, "pull_request") + status_checks = _rule_parameters(rules, "required_status_checks") + raw_contexts = status_checks.get("required_status_checks", []) + contexts = [] + if isinstance(raw_contexts, list): + for context in raw_contexts: + if isinstance(context, str): + contexts.append(context) + elif isinstance(context, Mapping) and isinstance( + context.get("context"), str + ): + contexts.append(context["context"]) + canonical.update( + { + "pull_request": { + "required": _has_rule(rules, "pull_request"), + "required_approvals": pull_request.get( + "required_approving_review_count" + ), + "code_owner_review_enabled": pull_request.get( + "require_code_owner_review" + ), + "conversation_resolution": pull_request.get( + "required_review_thread_resolution" + ), + }, + "protection": { + "block_force_push": _has_rule(rules, "non_fast_forward"), + "block_deletion": _has_rule(rules, "deletion"), + }, + "required_checks": { + "contexts": sorted(contexts), + "strict": status_checks.get("strict_required_status_checks_policy"), + }, + } + ) + elif target == "tag": + canonical["tag_protection"] = { + "block_creation": _has_rule(rules, "creation"), + "block_update": _has_rule(rules, "update"), + "block_deletion": _has_rule(rules, "deletion"), + } + return canonical + + +def _diff(expected: Any, actual: Any, path: str) -> list[str]: + if isinstance(expected, Mapping) and isinstance(actual, Mapping): + differences: list[str] = [] + for key in sorted(set(expected) | set(actual)): + child_path = f"{path}.{key}" if path else str(key) + if key not in expected: + differences.append( + f"{child_path}: unexpected actual value {actual[key]!r}" + ) + elif key not in actual: + differences.append( + f"{child_path}: missing actual value; expected {expected[key]!r}" + ) + else: + differences.extend(_diff(expected[key], actual[key], child_path)) + return differences + if expected != actual: + return [f"{path}: expected {expected!r}, actual {actual!r}"] + return [] + + +def verify_rulesets( + manifest_dir: Path | str, actual_rulesets: Sequence[Mapping[str, Any]] +) -> dict[str, Any]: + """Compare manifest desired state to a Rulesets API readback or fixture.""" + manifests = load_manifests(manifest_dir) + differences = validate_manifests(manifests) + expected_by_name = { + manifest["name"]: (key, normalized_manifest(manifest)) + for key, manifest in manifests.items() + } + actual_by_name = { + str(ruleset.get("name")): normalize_github_ruleset(ruleset) + for ruleset in actual_rulesets + if isinstance(ruleset, Mapping) + } + for name, (key, expected) in sorted(expected_by_name.items()): + actual = actual_by_name.get(name) + if actual is None: + differences.append(f"{key}: missing actual Ruleset named {name!r}") + continue + differences.extend(_diff(expected, actual, key)) + managed_names = set(expected_by_name) + for name in sorted(set(actual_by_name) - managed_names): + differences.append(f"unexpected actual Ruleset: {name!r}") + return {"ok": not differences, "differences": differences} + + +def load_fixture(path: Path | str) -> list[dict[str, Any]]: + """Load a saved, sanitized Rulesets API response.""" + with Path(path).open(encoding="utf-8") as handle: + payload = json.load(handle) + rulesets = payload.get("rulesets") if isinstance(payload, Mapping) else payload + if not isinstance(rulesets, list) or not all( + isinstance(item, dict) for item in rulesets + ): + raise ValueError("fixture must contain a rulesets array") + return rulesets + + +def _run_gh(command: list[str]) -> str: + completed = subprocess.run(command, capture_output=True, text=True, check=False) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or "gh api read failed") + return completed.stdout + + +def _flatten_pages(payload: Any) -> list[dict[str, Any]]: + if not isinstance(payload, list): + raise ValueError("GitHub Rulesets API list response must be an array") + if payload and all(isinstance(page, list) for page in payload): + return [item for page in payload for item in page if isinstance(item, dict)] + return [item for item in payload if isinstance(item, dict)] + + +def load_live_rulesets( + repo: str, *, runner: Runner | None = None +) -> list[dict[str, Any]]: + """Read each Ruleset detail with GitHub CLI GET requests only.""" + execute = runner or _run_gh + listing = json.loads( + execute(["gh", "api", "--paginate", "--slurp", f"repos/{repo}/rulesets"]) + ) + summaries = _flatten_pages(listing) + details: list[dict[str, Any]] = [] + for summary in summaries: + ruleset_id = summary.get("id") + if not isinstance(ruleset_id, int): + raise ValueError("GitHub Rulesets API list response contains an invalid id") + detail = json.loads( + execute(["gh", "api", f"repos/{repo}/rulesets/{ruleset_id}"]) + ) + if not isinstance(detail, dict): + raise ValueError( + f"GitHub Ruleset {ruleset_id} detail response must be an object" + ) + details.append(detail) + return details + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "--fixture", type=Path, help="sanitized local Rulesets API fixture" + ) + source.add_argument( + "--live", action="store_true", help="read Rulesets with gh api GET requests" + ) + parser.add_argument( + "--repo", + default="cloudQuant/backtrader_web", + help="owner/repository for --live", + ) + parser.add_argument( + "--manifest-dir", + type=Path, + required=True, + help="normalized desired-state manifests", + ) + parser.add_argument( + "--json", action="store_true", help="print only the machine-readable report" + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + try: + rulesets = ( + load_fixture(args.fixture) + if args.fixture + else load_live_rulesets(args.repo) + ) + report = verify_rulesets(args.manifest_dir, rulesets) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + report = {"ok": False, "differences": [f"verifier error: {error}"]} + + if args.json: + print(json.dumps(report, ensure_ascii=False, sort_keys=True)) + else: + status = "OK" if report["ok"] else "DRIFT" + print(f"GitHub Ruleset desired-state verification: {status}") + for difference in report["differences"]: + print(f" - {difference}") + print(json.dumps(report, ensure_ascii=False, sort_keys=True)) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 28dc82f75f895a0e941f113937acb04f3168243d Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 02:42:11 +0800 Subject: [PATCH 11/32] fix(governance): harden risk and ruleset contracts --- .github/CODEOWNERS | 13 ++ .github/governance/README.md | 11 + .github/governance/risk-paths.json | 17 +- .github/governance/rulesets/dev.json | 3 +- .github/governance/rulesets/master.json | 3 +- .github/governance/rulesets/release-tags.json | 3 +- scripts/ci/governance_contract.py | 209 +++++++++++++++++- scripts/ci/tests/test_governance_contract.py | 156 +++++++++++++ .../ci/tests/test_verify_github_governance.py | 35 +++ scripts/ci/verify_github_governance.py | 24 +- 10 files changed, 452 insertions(+), 22 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f83137291..2d0a1546c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,9 +36,17 @@ /src/backend/Dockerfile @cloudQuant /src/frontend/Dockerfile @cloudQuant /scripts/ops/release.sh @cloudQuant +/scripts/ops/docker_deploy.sh @cloudQuant +/scripts/ops/deploy_server.sh @cloudQuant +/scripts/ops/generate_lockfiles.sh @cloudQuant +/scripts/ops/validate_docker_env.py @cloudQuant /pyproject.toml @cloudQuant /uv.lock @cloudQuant /config/*.lock @cloudQuant +/**/package.json @cloudQuant +/**/pyproject.toml @cloudQuant +/**/.env.example @cloudQuant +/.env.example @cloudQuant /src/backend/pyproject.toml @cloudQuant /src/backend/requirements.txt @cloudQuant /docs/requirements.txt @cloudQuant @@ -50,6 +58,11 @@ /.github/workflows/** @cloudQuant /.github/governance/** @cloudQuant /src/backend/app/api/auth* @cloudQuant +/src/backend/app/api/_dependencies.py @cloudQuant +/src/backend/app/services/auth* @cloudQuant +/src/backend/app/schemas/auth* @cloudQuant +/src/backend/app/utils/security* @cloudQuant +/src/backend/app/**/session* @cloudQuant /src/backend/app/middleware/** @cloudQuant /src/backend/app/db/** @cloudQuant /src/backend/app/models/** @cloudQuant diff --git a/.github/governance/README.md b/.github/governance/README.md index 908dd44c6..33f85519b 100644 --- a/.github/governance/README.md +++ b/.github/governance/README.md @@ -27,6 +27,17 @@ both branch manifests deliberately use an empty `required_checks.contexts` list with a source pointing to future real Check Run evidence. D6 also keeps tag-authorized actors empty until release-environment capability is verified. +Each target records both its `include` and `exclude` ref arrays. The current +arrays intentionally have no exclusions; the verifier treats an unexpected +API exclusion as drift rather than silently broadening or narrowing a Rule. + +The validator recognizes the future transition only when it is evidenced: +`required_checks.status: "verified"` needs non-empty, non-blank, unique Check +Run contexts plus a source; an applied Ruleset needs a readback reference; +enabled code-owner review needs D2 owner evidence; and verified tag actors need +positive API actor IDs with D3/D6 evidence. These are schema capabilities, not +claims that the current repository has passed those gates. + `enforcement: "active"` expresses the eventual Ruleset configuration to be read back after its gates pass. `activation.remote_state: "not_applied"` records the current truth: this repository has not applied it externally. diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index f820683da..6d7879e55 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -26,6 +26,14 @@ "globs": [ "src/backend/app/api/auth*", "src/backend/app/api/**/auth*", + "src/backend/app/api/_dependencies.py", + "src/backend/app/api/**/_dependencies.py", + "src/backend/app/services/auth*", + "src/backend/app/services/**/auth*", + "src/backend/app/schemas/auth*", + "src/backend/app/schemas/**/auth*", + "src/backend/app/utils/security*", + "src/backend/app/**/session*", "src/backend/app/middleware/**" ], "description": "Authentication and session boundaries require elevated review." @@ -73,7 +81,11 @@ "Dockerfile*", "**/Dockerfile", "**/*Dockerfile", - "scripts/ops/release.sh" + "scripts/ops/release.sh", + "scripts/ops/docker_deploy.sh", + "scripts/ops/deploy_server.sh", + "scripts/ops/generate_lockfiles.sh", + "scripts/ops/validate_docker_env.py" ], "description": "Container and release changes require the highest review." }, @@ -84,8 +96,11 @@ "pyproject.toml", "uv.lock", "config/*.lock", + "**/package.json", "**/package-lock.json", + "**/pyproject.toml", "**/requirements*.txt", + "**/.env.example", ".env.example", "SECURITY.md" ], diff --git a/.github/governance/rulesets/dev.json b/.github/governance/rulesets/dev.json index 9b5f1e2a9..37b51c9cb 100644 --- a/.github/governance/rulesets/dev.json +++ b/.github/governance/rulesets/dev.json @@ -5,7 +5,8 @@ "kind": "branch", "include": [ "refs/heads/dev" - ] + ], + "exclude": [] }, "enforcement": "active", "activation": { diff --git a/.github/governance/rulesets/master.json b/.github/governance/rulesets/master.json index 81b83f87e..45526c2cd 100644 --- a/.github/governance/rulesets/master.json +++ b/.github/governance/rulesets/master.json @@ -5,7 +5,8 @@ "kind": "branch", "include": [ "refs/heads/master" - ] + ], + "exclude": [] }, "enforcement": "active", "activation": { diff --git a/.github/governance/rulesets/release-tags.json b/.github/governance/rulesets/release-tags.json index f84789fe8..532bd51a0 100644 --- a/.github/governance/rulesets/release-tags.json +++ b/.github/governance/rulesets/release-tags.json @@ -5,7 +5,8 @@ "kind": "tag", "include": [ "refs/tags/v*" - ] + ], + "exclude": [] }, "enforcement": "active", "activation": { diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index fd1080da9..fb9c336a9 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -21,18 +21,27 @@ "name": "Iteration 195: dev", "target_kind": "branch", "target_include": ["refs/heads/dev"], + "target_exclude": [], "required_approvals": 1, + "activation_gate": "D3", + "actor_source_tokens": ("GitHub API readback", "D3"), }, "master": { "name": "Iteration 195: master", "target_kind": "branch", "target_include": ["refs/heads/master"], + "target_exclude": [], "required_approvals": 2, + "activation_gate": "D3", + "actor_source_tokens": ("GitHub API readback", "D3"), }, "release-tags": { "name": "Iteration 195: release tags", "target_kind": "tag", "target_include": ["refs/tags/v*"], + "target_exclude": [], + "activation_gate": "D3_D6", + "actor_source_tokens": ("GitHub API readback", "D3", "D6"), }, } VALID_BYPASS_ACTOR_TYPES = { @@ -190,7 +199,7 @@ def _issue(path: str, message: str) -> str: return f"{path}: {message}" -def _validate_bypass(key: str, bypass: Any) -> list[str]: +def _validate_bypass(key: str, bypass: Any, expected: Mapping[str, Any]) -> list[str]: if not isinstance(bypass, Mapping): return [_issue(f"{key}.bypass", "must be an object")] @@ -198,6 +207,26 @@ def _validate_bypass(key: str, bypass: Any) -> list[str]: if bypass.get("normal_policy") != "none": issues.append(_issue(f"{key}.bypass.normal_policy", "must be 'none'")) + actor_source = bypass.get("actor_source") + expected_tokens = expected["actor_source_tokens"] + if not isinstance(actor_source, str) or not actor_source.strip(): + issues.append( + _issue( + f"{key}.bypass.actor_source", + "must cite future GitHub API readback rather than none or empty text", + ) + ) + elif any( + token.casefold() not in actor_source.casefold() for token in expected_tokens + ): + token_text = ", ".join(expected_tokens) + issues.append( + _issue( + f"{key}.bypass.actor_source", + f"must cite {token_text} while actors remain unverified", + ) + ) + emergency = bypass.get("emergency_policy") if not isinstance(emergency, Mapping): issues.append(_issue(f"{key}.bypass.emergency_policy", "must be an object")) @@ -278,10 +307,70 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: "must list confirmed check contexts when status is verified", ) ) + elif any(not context.strip() for context in contexts): + issues.append( + _issue( + f"{key}.required_checks.contexts", + "must not contain blank verified check contexts", + ) + ) + elif len(set(contexts)) != len(contexts): + issues.append( + _issue( + f"{key}.required_checks.contexts", + "must not contain duplicate verified check contexts", + ) + ) else: issues.append( - _issue(f"{key}.required_checks.status", "must be pending_D4 or verified") + _issue( + f"{key}.required_checks.status", + "must be pending_D4 or verified", + ) + ) + return issues + + +def _validate_activation( + key: str, activation: Any, expected: Mapping[str, Any] +) -> list[str]: + """Validate current and evidenced-future Ruleset activation states.""" + if not isinstance(activation, Mapping): + return [_issue(f"{key}.activation", "must be an object")] + + issues: list[str] = [] + remote_state = activation.get("remote_state") + if remote_state not in {"not_applied", "applied"}: + issues.append( + _issue( + f"{key}.activation.remote_state", + "must be not_applied or applied", + ) + ) + if activation.get("gate") != expected["activation_gate"]: + issues.append( + _issue( + f"{key}.activation.gate", + f"must be {expected['activation_gate']!r} for the current gate state", + ) ) + reason = activation.get("reason") + if not isinstance(reason, str) or not reason.strip(): + issues.append( + _issue( + f"{key}.activation.reason", + "must record the activation rationale", + ) + ) + if remote_state == "applied": + readback_evidence = activation.get("readback_evidence") + if not isinstance(readback_evidence, str) or not readback_evidence.strip(): + issues.append( + _issue( + f"{key}.activation.readback_evidence", + "must cite approved external Ruleset readback when remote_state is applied", + ) + ) return issues @@ -311,14 +400,38 @@ def _validate_branch_manifest( issues.append( _issue(f"{key}.pull_request.code_owner_review", "must be an object") ) - elif ( - code_owner.get("desired_when_d2_ready") is not True - or code_owner.get("enabled") is not False - ): + elif code_owner.get("desired_when_d2_ready") is not True: issues.append( _issue( f"{key}.pull_request.code_owner_review", - "must remain desired-but-disabled until the D2 approval pool is expanded", + "must declare the D2 readiness contract", + ) + ) + elif code_owner.get("enabled") is False: + if ( + not isinstance(code_owner.get("reason"), str) + or not code_owner["reason"].strip() + ): + issues.append( + _issue( + f"{key}.pull_request.code_owner_review.reason", + "must explain why D2 keeps required code-owner review disabled", + ) + ) + elif code_owner.get("enabled") is True: + evidence = code_owner.get("evidence") + if not isinstance(evidence, str) or not evidence.strip(): + issues.append( + _issue( + f"{key}.pull_request.code_owner_review.evidence", + "must cite D2 independent-owner evidence when enabled", + ) + ) + else: + issues.append( + _issue( + f"{key}.pull_request.code_owner_review.enabled", + "must be a boolean", ) ) @@ -350,11 +463,76 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: issues.append( _issue(f"{key}.tag_protection.authorized_actors", "must be an object") ) - elif authorized.get("status") != "pending_D3_D6" or authorized.get("actors") != []: + else: + status = authorized.get("status") + actors = authorized.get("actors") + actor_path = f"{key}.tag_protection.authorized_actors.actors" + if status == "pending_D3_D6": + if actors != []: + issues.append( + _issue( + actor_path, + "must stay empty while D3/D6 actor capability is pending", + ) + ) + elif status == "verified": + if not isinstance(actors, list) or not actors: + issues.append( + _issue( + actor_path, + "must contain API-readback actors when status is verified", + ) + ) + else: + for index, actor in enumerate(actors): + individual_path = f"{actor_path}[{index}]" + if not isinstance(actor, Mapping): + issues.append( + _issue( + individual_path, + "must be an API-readback actor object", + ) + ) + continue + if actor.get("actor_type") not in VALID_BYPASS_ACTOR_TYPES: + issues.append( + _issue( + f"{individual_path}.actor_type", + "is not a valid GitHub bypass actor type", + ) + ) + actor_id = actor.get("actor_id") + if ( + not isinstance(actor_id, int) + or isinstance(actor_id, bool) + or actor_id <= 0 + ): + issues.append( + _issue( + f"{individual_path}.actor_id", + "must be a positive API-readback integer", + ) + ) + else: + issues.append( + _issue( + f"{key}.tag_protection.authorized_actors.status", + "must be pending_D3_D6 or verified", + ) + ) + source = authorized.get("source") if isinstance(authorized, Mapping) else None + if not isinstance(source, str) or not source.strip(): + issues.append( + _issue( + f"{key}.tag_protection.authorized_actors.source", + "must record D3/D6 capability evidence", + ) + ) + elif "D3" not in source or "D6" not in source: issues.append( _issue( - f"{key}.tag_protection.authorized_actors", - "must stay empty until D3/D6 actor capability is verified", + f"{key}.tag_protection.authorized_actors.source", + "must cite both D3 and D6 capability evidence", ) ) return issues @@ -387,11 +565,19 @@ def validate_manifests(manifests: Mapping[str, Mapping[str, Any]]) -> list[str]: f"must be {expected['target_include']!r}", ) ) + if target.get("exclude") != expected["target_exclude"]: + issues.append( + _issue( + f"{key}.target.exclude", + f"must be {expected['target_exclude']!r}", + ) + ) if manifest.get("enforcement") != "active": issues.append( _issue(f"{key}.enforcement", "must be 'active' desired state") ) - issues.extend(_validate_bypass(key, manifest.get("bypass"))) + issues.extend(_validate_activation(key, manifest.get("activation"), expected)) + issues.extend(_validate_bypass(key, manifest.get("bypass"), expected)) if key == "release-tags": issues.extend(_validate_tag_manifest(key, manifest)) else: @@ -422,6 +608,7 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: "target": { "kind": target_mapping.get("kind"), "include": sorted(target_mapping.get("include", [])), + "exclude": sorted(target_mapping.get("exclude", [])), }, "enforcement": manifest.get("enforcement"), "bypass_actors": _canonical_actors( diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index fddf49e1e..1fac2f59a 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -88,6 +88,50 @@ def test_router_change_is_r2_without_an_r3_path(self) -> None: assert result["risk"] == "R2" + def test_auth_and_session_boundaries_are_r2_despite_downgrade_labels(self) -> None: + contract = _load_contract() + paths = [ + "src/backend/app/services/auth_service.py", + "src/backend/app/api/_dependencies.py", + "src/backend/app/schemas/auth.py", + "src/backend/app/utils/security.py", + ] + + result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) + + assert result["risk"] == "R2" + assert result["label_can_lower_risk"] is False + assert { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in paths + } == {path: {"R2"} for path in paths} + + def test_dependency_and_release_operational_inputs_are_r3(self) -> None: + contract = _load_contract() + paths = [ + "src/backend/package.json", + "src/frontend/package.json", + "src/backend/pyproject.toml", + "src/backend/.env.example", + "scripts/ops/docker_deploy.sh", + "scripts/ops/deploy_server.sh", + "scripts/ops/generate_lockfiles.sh", + "scripts/ops/validate_docker_env.py", + ] + + result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) + + assert result["risk"] == "R3" + assert result["label_can_lower_risk"] is False + assert { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in paths + } == {path: {"R3"} for path in paths} + def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: contract = _load_contract() @@ -211,3 +255,115 @@ def test_manifest_validation_rejects_unbound_confirmed_check_and_invalid_bypass_ assert any("master.required_checks.contexts" in issue for issue in issues) assert any("dev.bypass.actors[0].actor_id" in issue for issue in issues) + + def test_manifest_validation_rejects_blank_or_duplicate_verified_checks( + self, + ) -> None: + contract = _load_contract() + + for contexts in ( + ["Governance Gate", ""], + ["Governance Gate", "Governance Gate"], + ): + manifests = copy.deepcopy(_manifests(contract)) + manifests["master"]["required_checks"] = { + "status": "verified", + "contexts": contexts, + "source": "D4 approved draft-PR Check Run evidence", + "strict": True, + } + + issues = contract.validate_manifests(manifests) + + assert any("master.required_checks.contexts" in issue for issue in issues) + + def test_repository_manifests_record_current_pending_gate_truth(self) -> None: + contract = _load_contract() + manifests = _manifests(contract) + + for key in ("dev", "master"): + assert manifests[key]["activation"]["remote_state"] == "not_applied" + assert manifests[key]["required_checks"] == { + "status": "pending_D4", + "contexts": [], + "source": manifests[key]["required_checks"]["source"], + "strict": True, + } + assert ( + manifests[key]["pull_request"]["code_owner_review"]["enabled"] is False + ) + assert manifests["release-tags"]["activation"]["remote_state"] == "not_applied" + assert manifests["release-tags"]["tag_protection"]["authorized_actors"] == { + "status": "pending_D3_D6", + "actors": [], + "source": manifests["release-tags"]["tag_protection"]["authorized_actors"][ + "source" + ], + } + + def test_manifest_validation_rejects_future_transitions_without_evidence( + self, + ) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["activation"]["remote_state"] = "applied" + manifests["dev"]["bypass"]["actor_source"] = "none" + manifests["master"]["pull_request"]["code_owner_review"]["enabled"] = True + manifests["release-tags"]["activation"]["gate"] = "D3" + manifests["release-tags"]["tag_protection"]["authorized_actors"]["source"] = "" + + issues = contract.validate_manifests(manifests) + + assert any("dev.activation.readback_evidence" in issue for issue in issues) + assert any("dev.bypass.actor_source" in issue for issue in issues) + assert any("master.pull_request.code_owner_review" in issue for issue in issues) + assert any("release-tags.activation.gate" in issue for issue in issues) + assert any( + "release-tags.tag_protection.authorized_actors.source" in issue + for issue in issues + ) + + def test_manifest_validation_allows_evidenced_future_gate_transitions(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["activation"].update( + { + "remote_state": "applied", + "readback_evidence": "D3 approved Rulesets API readback", + } + ) + manifests["master"]["pull_request"]["code_owner_review"].update( + { + "enabled": True, + "evidence": "D2 approved independent owner matrix", + } + ) + manifests["master"]["required_checks"] = { + "status": "verified", + "contexts": ["Governance Gate"], + "source": "D4 approved draft-PR Check Run evidence", + "strict": True, + } + manifests["release-tags"]["tag_protection"]["authorized_actors"] = { + "status": "verified", + "actors": [{"actor_type": "Team", "actor_id": 123}], + "source": "D3 and D6 approved GitHub API actor readback", + } + + assert contract.validate_manifests(manifests) == [] + + def test_manifest_validation_rejects_invalid_verified_tag_actor(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["release-tags"]["tag_protection"]["authorized_actors"] = { + "status": "verified", + "actors": [{"actor_type": "Team", "actor_id": "not-an-api-id"}], + "source": "D3 and D6 approved GitHub API actor readback", + } + + issues = contract.validate_manifests(manifests) + + assert any( + "release-tags.tag_protection.authorized_actors.actors[0].actor_id" in issue + for issue in issues + ) diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index d74fae152..213b7f92a 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -70,6 +70,41 @@ def test_real_rule_drift_has_a_human_readable_path(self) -> None: for diff in report["differences"] ) + def test_ref_name_exclude_drift_is_not_ignored(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + dev_rule["conditions"]["ref_name"]["exclude"] = ["refs/heads/dev"] + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any("dev.target.exclude" in diff for diff in report["differences"]) + + def test_duplicate_named_actual_rulesets_fail_regardless_of_order(self) -> None: + verifier = _load_verifier() + original = _fixture()["rulesets"] + dev_rule = next( + rule for rule in original if rule["name"] == "Iteration 195: dev" + ) + + for duplicate_first in (True, False): + actual = copy.deepcopy(original) + duplicate = copy.deepcopy(dev_rule) + duplicate["id"] = 999 if duplicate_first else 998 + if duplicate_first: + actual.insert(0, duplicate) + else: + actual.append(duplicate) + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + "duplicate actual Ruleset named 'Iteration 195: dev'" in difference + for difference in report["differences"] + ) + class TestVerifierCli: def test_fixture_cli_emits_machine_readable_success(self) -> None: diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 19c491445..176e828e3 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -57,6 +57,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: conditions = raw_ruleset.get("conditions", {}) ref_name = conditions.get("ref_name", {}) if isinstance(conditions, Mapping) else {} include = ref_name.get("include", []) if isinstance(ref_name, Mapping) else [] + exclude = ref_name.get("exclude", []) if isinstance(ref_name, Mapping) else [] rules = raw_ruleset.get("rules", []) target = raw_ruleset.get("target") canonical: dict[str, Any] = { @@ -64,6 +65,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "target": { "kind": target, "include": sorted(include) if isinstance(include, list) else [], + "exclude": sorted(exclude) if isinstance(exclude, list) else [], }, "enforcement": raw_ruleset.get("enforcement"), "bypass_actors": _canonical_actors(raw_ruleset.get("bypass_actors", [])), @@ -145,16 +147,24 @@ def verify_rulesets( manifest["name"]: (key, normalized_manifest(manifest)) for key, manifest in manifests.items() } - actual_by_name = { - str(ruleset.get("name")): normalize_github_ruleset(ruleset) - for ruleset in actual_rulesets - if isinstance(ruleset, Mapping) - } + actual_by_name: dict[str, list[dict[str, Any]]] = {} + for ruleset in actual_rulesets: + if not isinstance(ruleset, Mapping): + continue + name = str(ruleset.get("name")) + actual_by_name.setdefault(name, []).append(normalize_github_ruleset(ruleset)) + + for name, matching_rulesets in sorted(actual_by_name.items()): + if len(matching_rulesets) > 1: + differences.append( + f"duplicate actual Ruleset named {name!r}: found {len(matching_rulesets)}" + ) for name, (key, expected) in sorted(expected_by_name.items()): - actual = actual_by_name.get(name) - if actual is None: + matching_rulesets = actual_by_name.get(name) + if matching_rulesets is None: differences.append(f"{key}: missing actual Ruleset named {name!r}") continue + actual = matching_rulesets[0] differences.extend(_diff(expected, actual, key)) managed_names = set(expected_by_name) for name in sorted(set(actual_by_name) - managed_names): From b09877b9826e30c69e1f94f50f8830daa27df460 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 02:48:39 +0800 Subject: [PATCH 12/32] fix(governance): align tag actor desired state --- .github/CODEOWNERS | 1 + .github/governance/README.md | 5 +- .github/governance/risk-paths.json | 3 +- scripts/ci/governance_contract.py | 16 +++++ scripts/ci/tests/test_governance_contract.py | 42 ++++++++++++++ .../ci/tests/test_verify_github_governance.py | 58 +++++++++++++++++++ 6 files changed, 122 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2d0a1546c..fdec304b3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -53,6 +53,7 @@ /tests/e2e/requirements.txt @cloudQuant /src/backend/package-lock.json @cloudQuant /src/frontend/package-lock.json @cloudQuant +**/SECURITY.md @cloudQuant # Narrow R2/R3 domains /.github/workflows/** @cloudQuant diff --git a/.github/governance/README.md b/.github/governance/README.md index 33f85519b..1f8b0d7ac 100644 --- a/.github/governance/README.md +++ b/.github/governance/README.md @@ -35,8 +35,9 @@ The validator recognizes the future transition only when it is evidenced: `required_checks.status: "verified"` needs non-empty, non-blank, unique Check Run contexts plus a source; an applied Ruleset needs a readback reference; enabled code-owner review needs D2 owner evidence; and verified tag actors need -positive API actor IDs with D3/D6 evidence. These are schema capabilities, not -claims that the current repository has passed those gates. +positive API actor IDs with D3/D6 evidence and must exactly match the tag +Ruleset's `bypass.actors` API-readback field. These are schema capabilities, +not claims that the current repository has passed those gates. `enforcement: "active"` expresses the eventual Ruleset configuration to be read back after its gates pass. `activation.remote_state: "not_applied"` diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index 6d7879e55..266848e0d 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -102,7 +102,8 @@ "**/requirements*.txt", "**/.env.example", ".env.example", - "SECURITY.md" + "SECURITY.md", + "**/SECURITY.md" ], "description": "Dependency, lock, environment-example, and disclosure inputs require the highest review." } diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index fb9c336a9..99a7fed89 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -454,6 +454,8 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: tag_protection = manifest.get("tag_protection") if not isinstance(tag_protection, Mapping): return [_issue(f"{key}.tag_protection", "must be an object")] + bypass = manifest.get("bypass") + bypass_actors = bypass.get("actors") if isinstance(bypass, Mapping) else None issues: list[str] = [] for field in ("block_creation", "block_update", "block_deletion"): if tag_protection.get(field) is not True: @@ -475,6 +477,13 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: "must stay empty while D3/D6 actor capability is pending", ) ) + if bypass_actors != []: + issues.append( + _issue( + f"{key}.bypass.actors", + "must stay empty while D3/D6 tag actor capability is pending", + ) + ) elif status == "verified": if not isinstance(actors, list) or not actors: issues.append( @@ -513,6 +522,13 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: "must be a positive API-readback integer", ) ) + if _canonical_actors(actors) != _canonical_actors(bypass_actors): + issues.append( + _issue( + actor_path, + "must exactly match bypass.actors as the API-readback actor source", + ) + ) else: issues.append( _issue( diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index 1fac2f59a..0101dff18 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -19,6 +19,7 @@ GOVERNANCE_CONTRACT = REPO_ROOT / "scripts" / "ci" / "governance_contract.py" RISK_MAP = REPO_ROOT / ".github" / "governance" / "risk-paths.json" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" +CODEOWNERS = REPO_ROOT / ".github" / "CODEOWNERS" def _load_contract() -> ModuleType: @@ -132,6 +133,30 @@ def test_dependency_and_release_operational_inputs_are_r3(self) -> None: for path in paths } == {path: {"R3"} for path in paths} + def test_security_guides_are_r3_and_have_governance_owner(self) -> None: + contract = _load_contract() + paths = [ + "docs/reference/SECURITY.md", + "src/backend/docs/SECURITY.md", + ] + + result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) + + assert result["risk"] == "R3" + levels_by_path = { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in paths + } + assert all("R3" in levels for levels in levels_by_path.values()) + entries = { + tuple(line.split()[:2]) + for line in CODEOWNERS.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + assert ("**/SECURITY.md", "@cloudQuant") in entries + def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: contract = _load_contract() @@ -349,6 +374,9 @@ def test_manifest_validation_allows_evidenced_future_gate_transitions(self) -> N "actors": [{"actor_type": "Team", "actor_id": 123}], "source": "D3 and D6 approved GitHub API actor readback", } + manifests["release-tags"]["bypass"]["actors"] = [ + {"actor_type": "Team", "actor_id": 123} + ] assert contract.validate_manifests(manifests) == [] @@ -367,3 +395,17 @@ def test_manifest_validation_rejects_invalid_verified_tag_actor(self) -> None: "release-tags.tag_protection.authorized_actors.actors[0].actor_id" in issue for issue in issues ) + + def test_pending_tag_actors_require_an_empty_bypass_pool(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["release-tags"]["bypass"]["actors"] = [ + {"actor_type": "Team", "actor_id": 123} + ] + + issues = contract.validate_manifests(manifests) + + assert any( + "release-tags.bypass.actors" in issue and "pending" in issue + for issue in issues + ) diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index 213b7f92a..9f33ffb47 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -35,6 +35,27 @@ def _fixture() -> dict[str, object]: return json.loads(FIXTURE.read_text(encoding="utf-8")) +def _future_tag_manifest_dir( + tmp_path: Path, + *, + bypass_actor: dict[str, object], + authorized_actor: dict[str, object], +) -> Path: + """Write an evidenced D3/D6 tag transition without changing repo fixtures.""" + for name in ("dev", "master", "release-tags"): + source = MANIFEST_DIR / f"{name}.json" + manifest = json.loads(source.read_text(encoding="utf-8")) + if name == "release-tags": + manifest["bypass"]["actors"] = [bypass_actor] + manifest["tag_protection"]["authorized_actors"] = { + "status": "verified", + "actors": [authorized_actor], + "source": "D3 and D6 approved GitHub API actor readback", + } + (tmp_path / f"{name}.json").write_text(json.dumps(manifest), encoding="utf-8") + return tmp_path + + class TestRulesetNormalization: def test_api_ids_timestamps_and_rule_order_do_not_create_drift(self) -> None: verifier = _load_verifier() @@ -105,6 +126,43 @@ def test_duplicate_named_actual_rulesets_fail_regardless_of_order(self) -> None: for difference in report["differences"] ) + def test_verified_tag_actor_matches_api_bypass_actor(self, tmp_path: Path) -> None: + verifier = _load_verifier() + actor = {"actor_type": "Team", "actor_id": 123} + manifest_dir = _future_tag_manifest_dir( + tmp_path, bypass_actor=actor, authorized_actor=actor + ) + actual = copy.deepcopy(_fixture()["rulesets"]) + tag_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: release tags" + ) + tag_rule["bypass_actors"] = [actor] + + report = verifier.verify_rulesets(manifest_dir, actual) + + assert report == {"ok": True, "differences": []} + + def test_verified_tag_actor_must_match_bypass_actor(self, tmp_path: Path) -> None: + verifier = _load_verifier() + manifest_dir = _future_tag_manifest_dir( + tmp_path, + bypass_actor={"actor_type": "Team", "actor_id": 123}, + authorized_actor={"actor_type": "Team", "actor_id": 456}, + ) + actual = copy.deepcopy(_fixture()["rulesets"]) + tag_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: release tags" + ) + tag_rule["bypass_actors"] = [{"actor_type": "Team", "actor_id": 123}] + + report = verifier.verify_rulesets(manifest_dir, actual) + + assert report["ok"] is False + assert any( + "release-tags.tag_protection.authorized_actors.actors" in difference + for difference in report["differences"] + ) + class TestVerifierCli: def test_fixture_cli_emits_machine_readable_success(self) -> None: From 4319d9fb481a8237ed33632391afaa5b4681ef4b Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:18:31 +0800 Subject: [PATCH 13/32] fix(governance): preserve bypass actor modes --- .github/CODEOWNERS | 5 + .github/governance/README.md | 32 +- .github/governance/risk-paths.json | 7 +- .github/governance/rulesets/dev.json | 6 +- .github/governance/rulesets/master.json | 6 +- .github/governance/rulesets/release-tags.json | 3 +- scripts/ci/governance_contract.py | 490 +++++++++++++++--- .../ci/tests/fixtures/github-rulesets.json | 6 +- scripts/ci/tests/test_governance_contract.py | 404 ++++++++++++++- .../ci/tests/test_verify_github_governance.py | 247 ++++++++- scripts/ci/verify_github_governance.py | 86 ++- 11 files changed, 1152 insertions(+), 140 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fdec304b3..9c58b56a5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -60,6 +60,7 @@ /.github/governance/** @cloudQuant /src/backend/app/api/auth* @cloudQuant /src/backend/app/api/_dependencies.py @cloudQuant +/src/backend/app/api/deps.py @cloudQuant /src/backend/app/services/auth* @cloudQuant /src/backend/app/schemas/auth* @cloudQuant /src/backend/app/utils/security* @cloudQuant @@ -69,3 +70,7 @@ /src/backend/app/models/** @cloudQuant /src/frontend/src/router/** @cloudQuant /src/frontend/src/stores/** @cloudQuant +/src/frontend/src/api/index.ts @cloudQuant +/src/frontend/src/api/auth.ts @cloudQuant +/src/frontend/src/utils/session.ts @cloudQuant +/src/frontend/src/utils/tokenRef.ts @cloudQuant diff --git a/.github/governance/README.md b/.github/governance/README.md index 1f8b0d7ac..ab01d72d7 100644 --- a/.github/governance/README.md +++ b/.github/governance/README.md @@ -10,9 +10,10 @@ release environment, or security disclosure channel is active. highest matching level wins: `R0` documentation, default `R1`, elevated `R2`, and highest-risk `R3`. Labels can add review context but cannot lower a path-derived risk level. -- `rulesets/*.json` are normalized desired-state manifests. They intentionally - exclude GitHub API IDs, timestamps, actor IDs, secrets, and guessed required - check contexts. +- `rulesets/*.json` are normalized desired-state manifests. Current pending + state intentionally excludes GitHub API IDs, timestamps, actor IDs, secrets, + and guessed required-check contexts; later evidenced transitions may add only + verified actor or integration IDs through their structured contracts. - `.github/CODEOWNERS` names the currently verified GitHub owner `@cloudQuant`; it preserves the Iteration 193 ratchet entries and adds the governance coverage used by this iteration. @@ -35,9 +36,28 @@ The validator recognizes the future transition only when it is evidenced: `required_checks.status: "verified"` needs non-empty, non-blank, unique Check Run contexts plus a source; an applied Ruleset needs a readback reference; enabled code-owner review needs D2 owner evidence; and verified tag actors need -positive API actor IDs with D3/D6 evidence and must exactly match the tag -Ruleset's `bypass.actors` API-readback field. These are schema capabilities, -not claims that the current repository has passed those gates. +GitHub-valid actor identity, `bypass_mode`, D3/D6 evidence, and an exact match +to the tag Ruleset's `bypass.actors` API-readback field. `Integration`, +`RepositoryRole`, `Team`, and `User` need positive integer IDs; +`OrganizationAdmin` IDs are intentionally ignored/canonicalized to `null`; +and `DeployKey` must use `null`. The only accepted modes are `always` and +`pull_request`; the latter is allowed only on branch Rulesets and never for a +DeployKey. GitHub's `exempt` mode is deliberately rejected: a standing +exemption cannot be bound to this repository's incident, reason, expiry, and +24-hour postmortem emergency-bypass contract. Every future bypass actor must +therefore have a matching structured emergency exception with incident, reason, +an ISO-8601 `issued_at` (or `starts_at`), an unexpired `expires_at` no more +than 24 hours after that start, and gate-scoped readback evidence. These are +schema capabilities, not claims that the current repository has passed those +gates. + +Future applied activation, code-owner enablement, verified Check Runs, tag +actors, and emergency exceptions use evidence objects with explicit gate IDs +and a repository-local evidence path or HTTPS URL; free-form strings do not +prove a gate. Required Check Run identities retain both `context` and +`integration_id`, and `do_not_enforce_on_create` is fixed at `false`. The +normalizer fails closed when a Rulesets API readback omits `bypass_actors`: an +explicit empty array is required to prove an empty bypass pool. `enforcement: "active"` expresses the eventual Ruleset configuration to be read back after its gates pass. `activation.remote_state: "not_applied"` diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index 266848e0d..a4d7508e4 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -28,13 +28,18 @@ "src/backend/app/api/**/auth*", "src/backend/app/api/_dependencies.py", "src/backend/app/api/**/_dependencies.py", + "src/backend/app/api/deps.py", "src/backend/app/services/auth*", "src/backend/app/services/**/auth*", "src/backend/app/schemas/auth*", "src/backend/app/schemas/**/auth*", "src/backend/app/utils/security*", "src/backend/app/**/session*", - "src/backend/app/middleware/**" + "src/backend/app/middleware/**", + "src/frontend/src/api/index.ts", + "src/frontend/src/api/auth.ts", + "src/frontend/src/utils/session*", + "src/frontend/src/utils/tokenRef*" ], "description": "Authentication and session boundaries require elevated review." }, diff --git a/.github/governance/rulesets/dev.json b/.github/governance/rulesets/dev.json index 37b51c9cb..c95653e88 100644 --- a/.github/governance/rulesets/dev.json +++ b/.github/governance/rulesets/dev.json @@ -22,7 +22,8 @@ "postmortem_within_hours": 24 }, "actor_source": "GitHub API readback after D3 capability validation", - "actors": [] + "actors": [], + "emergency_exceptions": [] }, "pull_request": { "required": true, @@ -42,6 +43,7 @@ "status": "pending_D4", "contexts": [], "source": "Task 4 real draft-PR Check Run evidence; names must not be guessed from workflow YAML.", - "strict": true + "strict": true, + "do_not_enforce_on_create": false } } diff --git a/.github/governance/rulesets/master.json b/.github/governance/rulesets/master.json index 45526c2cd..12f933ec2 100644 --- a/.github/governance/rulesets/master.json +++ b/.github/governance/rulesets/master.json @@ -22,7 +22,8 @@ "postmortem_within_hours": 24 }, "actor_source": "GitHub API readback after D3 capability validation", - "actors": [] + "actors": [], + "emergency_exceptions": [] }, "pull_request": { "required": true, @@ -42,7 +43,8 @@ "status": "pending_D4", "contexts": [], "source": "Task 4 hotfix/release draft-PR Check Run evidence; names must not be guessed from workflow YAML.", - "strict": true + "strict": true, + "do_not_enforce_on_create": false }, "release_evidence_contract": { "release_checklist": true, diff --git a/.github/governance/rulesets/release-tags.json b/.github/governance/rulesets/release-tags.json index 532bd51a0..ea5fbef02 100644 --- a/.github/governance/rulesets/release-tags.json +++ b/.github/governance/rulesets/release-tags.json @@ -22,7 +22,8 @@ "postmortem_within_hours": 24 }, "actor_source": "GitHub API readback after D3/D6 capability validation", - "actors": [] + "actors": [], + "emergency_exceptions": [] }, "tag_protection": { "block_creation": true, diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index 99a7fed89..64bc94af2 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -11,6 +11,7 @@ import json import re from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -49,7 +50,18 @@ "OrganizationAdmin", "RepositoryRole", "Team", + "DeployKey", + "User", } +POSITIVE_ID_BYPASS_ACTOR_TYPES = { + "Integration", + "RepositoryRole", + "Team", + "User", +} +GITHUB_BYPASS_MODES = {"always", "pull_request", "exempt"} +ALLOWED_BYPASS_MODES = {"always", "pull_request"} +EVIDENCE_PATH_PREFIXES = ("docs/", ".github/", "artifacts/") RELEASE_BRANCH = re.compile(r"^release/v\d+\.\d+\.\d+(?:-rc\d+)?$") HOTFIX_BRANCH = re.compile(r"^hotfix/master-[A-Za-z0-9][A-Za-z0-9._-]*$") DEV_BRANCH_PREFIXES = ("feature/", "fix/", "docs/", "refactor/", "test/") @@ -199,7 +211,155 @@ def _issue(path: str, message: str) -> str: return f"{path}: {message}" -def _validate_bypass(key: str, bypass: Any, expected: Mapping[str, Any]) -> list[str]: +def _is_positive_actor_id(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _gate_tokens(expected: Mapping[str, Any]) -> tuple[str, ...]: + return tuple(str(expected["activation_gate"]).split("_")) + + +def _is_evidence_reference(value: str) -> bool: + if value.startswith("https://"): + return len(value) > len("https://") + normalized = value.replace("\\", "/") + return ( + normalized.startswith(EVIDENCE_PATH_PREFIXES) + and "/../" not in f"/{normalized}/" + and not normalized.endswith("/..") + ) + + +def _validate_gate_evidence( + evidence: Any, path: str, required_gates: Sequence[str] +) -> list[str]: + if not isinstance(evidence, Mapping): + return [ + _issue( + path, + "must be an evidence object with gates and a local path or HTTPS URL", + ) + ] + + issues: list[str] = [] + gates = evidence.get("gates") + if ( + not isinstance(gates, list) + or not all(isinstance(gate, str) for gate in gates) + or set(gates) != set(required_gates) + or len(gates) != len(set(gates)) + ): + gate_text = ", ".join(required_gates) + issues.append(_issue(f"{path}.gates", f"must cite exactly {gate_text}")) + + reference = evidence.get("reference") + if not isinstance(reference, str) or not _is_evidence_reference(reference): + issues.append( + _issue( + f"{path}.reference", + "must be a repository-local evidence path or HTTPS URL", + ) + ) + return issues + + +def _parse_utc_timestamp(value: Any) -> datetime | None: + """Return a timezone-aware timestamp as UTC, or ``None`` when malformed.""" + if not isinstance(value, str) or not value.strip(): + return None + timestamp = value.strip() + if timestamp.endswith("Z"): + timestamp = f"{timestamp[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(timestamp) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(timezone.utc) + + +def _validate_bypass_actor( + actor: Any, actor_path: str, *, target_kind: str +) -> list[str]: + if not isinstance(actor, Mapping): + return [_issue(actor_path, "must be an object from API readback")] + + issues: list[str] = [] + actor_type = actor.get("actor_type") + if actor_type not in VALID_BYPASS_ACTOR_TYPES: + issues.append( + _issue( + f"{actor_path}.actor_type", + "is not a valid GitHub bypass actor type", + ) + ) + + actor_id = actor.get("actor_id") + if actor_type in POSITIVE_ID_BYPASS_ACTOR_TYPES: + if not _is_positive_actor_id(actor_id): + issues.append( + _issue( + f"{actor_path}.actor_id", + "must be a positive API-readback integer for this actor type", + ) + ) + elif actor_type == "OrganizationAdmin": + if actor_id is not None and not _is_positive_actor_id(actor_id): + issues.append( + _issue( + f"{actor_path}.actor_id", + "must be null or a positive integer; OrganizationAdmin IDs are ignored", + ) + ) + elif actor_type == "DeployKey" and actor_id is not None: + issues.append( + _issue( + f"{actor_path}.actor_id", + "must be null for DeployKey", + ) + ) + + bypass_mode = actor.get("bypass_mode") + if bypass_mode not in GITHUB_BYPASS_MODES: + modes = ", ".join(sorted(GITHUB_BYPASS_MODES)) + issues.append( + _issue( + f"{actor_path}.bypass_mode", + f"must be one of {modes}", + ) + ) + elif bypass_mode not in ALLOWED_BYPASS_MODES: + issues.append( + _issue( + f"{actor_path}.bypass_mode", + "'exempt' is prohibited by the incident/reason/24-hour-postmortem emergency policy", + ) + ) + elif bypass_mode == "pull_request" and target_kind != "branch": + issues.append( + _issue( + f"{actor_path}.bypass_mode", + "is valid only for branch Rulesets", + ) + ) + elif bypass_mode == "pull_request" and actor_type == "DeployKey": + issues.append( + _issue( + f"{actor_path}.bypass_mode", + "is not valid for DeployKey", + ) + ) + return issues + + +def _validate_bypass( + key: str, + bypass: Any, + expected: Mapping[str, Any], + *, + now: datetime, +) -> list[str]: if not isinstance(bypass, Mapping): return [_issue(f"{key}.bypass", "must be an object")] @@ -228,6 +388,7 @@ def _validate_bypass(key: str, bypass: Any, expected: Mapping[str, Any]) -> list ) emergency = bypass.get("emergency_policy") + emergency_window_hours = 24 if not isinstance(emergency, Mapping): issues.append(_issue(f"{key}.bypass.emergency_policy", "must be an object")) elif ( @@ -241,30 +402,112 @@ def _validate_bypass(key: str, bypass: Any, expected: Mapping[str, Any]) -> list "must require incident, reason, and a 24-hour postmortem", ) ) + else: + emergency_window_hours = emergency["postmortem_within_hours"] actors = bypass.get("actors") if not isinstance(actors, list): return issues + [_issue(f"{key}.bypass.actors", "must be a list")] for index, actor in enumerate(actors): - actor_path = f"{key}.bypass.actors[{index}]" - if not isinstance(actor, Mapping): - issues.append(_issue(actor_path, "must be an object from API readback")) + issues.extend( + _validate_bypass_actor( + actor, + f"{key}.bypass.actors[{index}]", + target_kind=str(expected["target_kind"]), + ) + ) + + exceptions = bypass.get("emergency_exceptions") + exception_path = f"{key}.bypass.emergency_exceptions" + if not isinstance(exceptions, list): + issues.append(_issue(exception_path, "must be a list")) + return issues + + exception_actors: list[dict[str, Any]] = [] + for index, exception in enumerate(exceptions): + individual_path = f"{exception_path}[{index}]" + if not isinstance(exception, Mapping): + issues.append(_issue(individual_path, "must be an object")) continue - actor_type = actor.get("actor_type") - if actor_type not in VALID_BYPASS_ACTOR_TYPES: + actor = exception.get("actor") + issues.extend( + _validate_bypass_actor( + actor, + f"{individual_path}.actor", + target_kind=str(expected["target_kind"]), + ) + ) + exception_actors.extend(normalize_bypass_actors([actor])) + for field in ("incident", "reason"): + value = exception.get(field) + if not isinstance(value, str) or not value.strip(): + issues.append(_issue(f"{individual_path}.{field}", "must be non-empty")) + has_issued_at = "issued_at" in exception + has_starts_at = "starts_at" in exception + if has_issued_at and has_starts_at: + issues.append( + _issue( + f"{individual_path}.issued_at", + "must not be combined with starts_at; use one unambiguous start timestamp", + ) + ) + start_field = "issued_at" if has_issued_at else "starts_at" + start_at = _parse_utc_timestamp(exception.get(start_field)) + if start_at is None: issues.append( _issue( - f"{actor_path}.actor_type", - "is not a valid GitHub bypass actor type", + f"{individual_path}.{start_field}", + "must be a timezone-aware ISO-8601 start timestamp", ) ) - actor_id = actor.get("actor_id") - if not isinstance(actor_id, int) or isinstance(actor_id, bool) or actor_id <= 0: + + expires_at = _parse_utc_timestamp(exception.get("expires_at")) + if expires_at is None: issues.append( _issue( - f"{actor_path}.actor_id", "must be a positive API-readback integer" + f"{individual_path}.expires_at", + "must be a timezone-aware ISO-8601 expiry timestamp", ) ) + else: + if expires_at <= now: + issues.append( + _issue( + f"{individual_path}.expires_at", + "must be later than the validation time", + ) + ) + if start_at is not None: + if expires_at <= start_at: + issues.append( + _issue( + f"{individual_path}.expires_at", + "must be later than issued_at or starts_at", + ) + ) + elif expires_at > start_at + timedelta(hours=emergency_window_hours): + issues.append( + _issue( + f"{individual_path}.expires_at", + "must be within the emergency policy's 24-hour window", + ) + ) + issues.extend( + _validate_gate_evidence( + exception.get("readback_evidence"), + f"{individual_path}.readback_evidence", + _gate_tokens(expected), + ) + ) + + normalized_actors = normalize_bypass_actors(actors) + if normalized_actors != normalize_bypass_actors(exception_actors): + issues.append( + _issue( + exception_path, + "must provide one auditable emergency exception for every bypass actor", + ) + ) return issues @@ -276,12 +519,11 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: contexts = required_checks.get("contexts") source = required_checks.get("source") issues: list[str] = [] - if not isinstance(contexts, list) or not all( - isinstance(context, str) for context in contexts - ): + if not isinstance(contexts, list): issues.append( _issue( - f"{key}.required_checks.contexts", "must be a list of check contexts" + f"{key}.required_checks.contexts", + "must be a list of check context/integration identities", ) ) return issues @@ -291,6 +533,13 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: ) if required_checks.get("strict") is not True: issues.append(_issue(f"{key}.required_checks.strict", "must be true")) + if required_checks.get("do_not_enforce_on_create") is not False: + issues.append( + _issue( + f"{key}.required_checks.do_not_enforce_on_create", + "must be false", + ) + ) if status == "pending_D4": if contexts: issues.append( @@ -307,20 +556,51 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: "must list confirmed check contexts when status is verified", ) ) - elif any(not context.strip() for context in contexts): - issues.append( - _issue( - f"{key}.required_checks.contexts", - "must not contain blank verified check contexts", - ) - ) - elif len(set(contexts)) != len(contexts): - issues.append( - _issue( - f"{key}.required_checks.contexts", - "must not contain duplicate verified check contexts", + else: + identities: list[tuple[str, int]] = [] + for index, context_entry in enumerate(contexts): + context_path = f"{key}.required_checks.contexts[{index}]" + if not isinstance(context_entry, Mapping): + issues.append( + _issue( + context_path, + "must be an object with context and integration_id", + ) + ) + continue + context = context_entry.get("context") + if not isinstance(context, str) or not context.strip(): + issues.append( + _issue( + f"{context_path}.context", + "must be a non-empty verified check context", + ) + ) + continue + integration_id = context_entry.get("integration_id") + if not _is_positive_actor_id(integration_id): + issues.append( + _issue( + f"{context_path}.integration_id", + "must be a positive Check Run integration ID", + ) + ) + continue + identities.append((context, integration_id)) + if len(identities) != len(set(identities)): + issues.append( + _issue( + f"{key}.required_checks.contexts", + "must not contain duplicate context/integration identities", + ) ) + issues.extend( + _validate_gate_evidence( + required_checks.get("evidence"), + f"{key}.required_checks.evidence", + ("D4",), ) + ) else: issues.append( _issue( @@ -363,14 +643,13 @@ def _validate_activation( ) ) if remote_state == "applied": - readback_evidence = activation.get("readback_evidence") - if not isinstance(readback_evidence, str) or not readback_evidence.strip(): - issues.append( - _issue( - f"{key}.activation.readback_evidence", - "must cite approved external Ruleset readback when remote_state is applied", - ) + issues.extend( + _validate_gate_evidence( + activation.get("readback_evidence"), + f"{key}.activation.readback_evidence", + _gate_tokens(expected), ) + ) return issues @@ -419,14 +698,13 @@ def _validate_branch_manifest( ) ) elif code_owner.get("enabled") is True: - evidence = code_owner.get("evidence") - if not isinstance(evidence, str) or not evidence.strip(): - issues.append( - _issue( - f"{key}.pull_request.code_owner_review.evidence", - "must cite D2 independent-owner evidence when enabled", - ) + issues.extend( + _validate_gate_evidence( + code_owner.get("evidence"), + f"{key}.pull_request.code_owner_review.evidence", + ("D2",), ) + ) else: issues.append( _issue( @@ -450,7 +728,9 @@ def _validate_branch_manifest( return issues -def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: +def _validate_tag_manifest( + key: str, manifest: Mapping[str, Any], expected: Mapping[str, Any] +) -> list[str]: tag_protection = manifest.get("tag_protection") if not isinstance(tag_protection, Mapping): return [_issue(f"{key}.tag_protection", "must be an object")] @@ -494,35 +774,16 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: ) else: for index, actor in enumerate(actors): - individual_path = f"{actor_path}[{index}]" - if not isinstance(actor, Mapping): - issues.append( - _issue( - individual_path, - "must be an API-readback actor object", - ) + issues.extend( + _validate_bypass_actor( + actor, + f"{actor_path}[{index}]", + target_kind="tag", ) - continue - if actor.get("actor_type") not in VALID_BYPASS_ACTOR_TYPES: - issues.append( - _issue( - f"{individual_path}.actor_type", - "is not a valid GitHub bypass actor type", - ) - ) - actor_id = actor.get("actor_id") - if ( - not isinstance(actor_id, int) - or isinstance(actor_id, bool) - or actor_id <= 0 - ): - issues.append( - _issue( - f"{individual_path}.actor_id", - "must be a positive API-readback integer", - ) - ) - if _canonical_actors(actors) != _canonical_actors(bypass_actors): + ) + if normalize_bypass_actors(actors) != normalize_bypass_actors( + bypass_actors + ): issues.append( _issue( actor_path, @@ -551,11 +812,28 @@ def _validate_tag_manifest(key: str, manifest: Mapping[str, Any]) -> list[str]: "must cite both D3 and D6 capability evidence", ) ) + if isinstance(authorized, Mapping) and authorized.get("status") == "verified": + issues.extend( + _validate_gate_evidence( + authorized.get("evidence"), + f"{key}.tag_protection.authorized_actors.evidence", + _gate_tokens(expected), + ) + ) return issues -def validate_manifests(manifests: Mapping[str, Mapping[str, Any]]) -> list[str]: +def validate_manifests( + manifests: Mapping[str, Mapping[str, Any]], *, now: datetime | None = None +) -> list[str]: """Validate desired-state manifests before they are compared to GitHub.""" + if now is None: + validation_now = datetime.now(timezone.utc) + elif not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("now must be a timezone-aware datetime") + else: + validation_now = now.astimezone(timezone.utc) + issues: list[str] = [] for key, expected in REQUIRED_MANIFESTS.items(): manifest = manifests.get(key) @@ -593,24 +871,67 @@ def validate_manifests(manifests: Mapping[str, Mapping[str, Any]]) -> list[str]: _issue(f"{key}.enforcement", "must be 'active' desired state") ) issues.extend(_validate_activation(key, manifest.get("activation"), expected)) - issues.extend(_validate_bypass(key, manifest.get("bypass"), expected)) + issues.extend( + _validate_bypass( + key, + manifest.get("bypass"), + expected, + now=validation_now, + ) + ) if key == "release-tags": - issues.extend(_validate_tag_manifest(key, manifest)) + issues.extend(_validate_tag_manifest(key, manifest, expected)) else: issues.extend(_validate_branch_manifest(key, manifest, expected)) return issues -def _canonical_actors(actors: Any) -> list[dict[str, Any]]: +def normalize_required_check_contexts(contexts: Any) -> list[dict[str, Any]]: + """Normalize Check Run identities without dropping their integration source.""" + if not isinstance(contexts, list): + return [] + canonical: list[dict[str, Any]] = [] + for entry in contexts: + if isinstance(entry, str): + canonical.append({"context": entry, "integration_id": None}) + elif isinstance(entry, Mapping): + canonical.append( + { + "context": entry.get("context"), + "integration_id": entry.get("integration_id"), + } + ) + return sorted( + canonical, + key=lambda entry: (str(entry["context"]), str(entry["integration_id"])), + ) + + +def normalize_bypass_actors(actors: Any) -> list[dict[str, Any]]: + """Normalize GitHub bypass actors while retaining enforcement-relevant mode.""" if not isinstance(actors, list): return [] - canonical = [ - {"actor_type": actor.get("actor_type"), "actor_id": actor.get("actor_id")} - for actor in actors - if isinstance(actor, Mapping) - ] + canonical: list[dict[str, Any]] = [] + for actor in actors: + if not isinstance(actor, Mapping): + continue + actor_type = actor.get("actor_type") + canonical.append( + { + "actor_type": actor_type, + "actor_id": ( + None if actor_type == "OrganizationAdmin" else actor.get("actor_id") + ), + "bypass_mode": actor.get("bypass_mode"), + } + ) return sorted( - canonical, key=lambda actor: (str(actor["actor_type"]), str(actor["actor_id"])) + canonical, + key=lambda actor: ( + str(actor["actor_type"]), + str(actor["actor_id"]), + str(actor["bypass_mode"]), + ), ) @@ -627,7 +948,7 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: "exclude": sorted(target_mapping.get("exclude", [])), }, "enforcement": manifest.get("enforcement"), - "bypass_actors": _canonical_actors( + "bypass_actors": normalize_bypass_actors( bypass.get("actors", []) if isinstance(bypass, Mapping) else [] ), } @@ -655,8 +976,13 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: "block_deletion": protection_mapping.get("block_deletion"), }, "required_checks": { - "contexts": sorted(checks_mapping.get("contexts", [])), + "contexts": normalize_required_check_contexts( + checks_mapping.get("contexts", []) + ), "strict": checks_mapping.get("strict"), + "do_not_enforce_on_create": checks_mapping.get( + "do_not_enforce_on_create" + ), }, } ) diff --git a/scripts/ci/tests/fixtures/github-rulesets.json b/scripts/ci/tests/fixtures/github-rulesets.json index 498029527..4fddfcfd9 100644 --- a/scripts/ci/tests/fixtures/github-rulesets.json +++ b/scripts/ci/tests/fixtures/github-rulesets.json @@ -30,7 +30,8 @@ "type": "required_status_checks", "parameters": { "required_status_checks": [], - "strict_required_status_checks_policy": true + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false } } ] @@ -65,7 +66,8 @@ "type": "required_status_checks", "parameters": { "required_status_checks": [], - "strict_required_status_checks_policy": true + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false } } ] diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index 0101dff18..5c12ed5e8 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -11,6 +11,7 @@ import copy import importlib.util import json +from datetime import datetime, timezone from pathlib import Path from types import ModuleType @@ -20,6 +21,7 @@ RISK_MAP = REPO_ROOT / ".github" / "governance" / "risk-paths.json" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" CODEOWNERS = REPO_ROOT / ".github" / "CODEOWNERS" +VALIDATION_NOW = datetime(2026, 8, 24, 0, 30, tzinfo=timezone.utc) def _load_contract() -> ModuleType: @@ -45,6 +47,39 @@ def _manifests(contract: ModuleType) -> dict[str, dict[str, object]]: return contract.load_manifests(MANIFEST_DIR) +def _evidence(*gates: str) -> dict[str, object]: + return { + "gates": list(gates), + "reference": f"docs/governance/evidence/{'-'.join(gates)}-readback.md", + } + + +def _emergency_exception(actor: dict[str, object], *gates: str) -> dict[str, object]: + return { + "actor": copy.deepcopy(actor), + "incident": "INC-195", + "reason": "documented emergency recovery", + "issued_at": "2099-01-02T02:04:05Z", + "expires_at": "2099-01-02T03:04:05Z", + "readback_evidence": _evidence(*gates), + } + + +def _set_verified_tag_actors( + manifests: dict[str, dict[str, object]], actors: list[dict[str, object]] +) -> None: + manifests["release-tags"]["bypass"]["actors"] = copy.deepcopy(actors) + manifests["release-tags"]["bypass"]["emergency_exceptions"] = [ + _emergency_exception(actor, "D3", "D6") for actor in actors + ] + manifests["release-tags"]["tag_protection"]["authorized_actors"] = { + "status": "verified", + "actors": copy.deepcopy(actors), + "source": "D3 and D6 approved GitHub API actor readback", + "evidence": _evidence("D3", "D6"), + } + + class TestRiskClassification: def test_highest_matching_risk_wins_across_mixed_paths(self) -> None: contract = _load_contract() @@ -94,8 +129,13 @@ def test_auth_and_session_boundaries_are_r2_despite_downgrade_labels(self) -> No paths = [ "src/backend/app/services/auth_service.py", "src/backend/app/api/_dependencies.py", + "src/backend/app/api/deps.py", "src/backend/app/schemas/auth.py", "src/backend/app/utils/security.py", + "src/frontend/src/api/index.ts", + "src/frontend/src/api/auth.ts", + "src/frontend/src/utils/session.ts", + "src/frontend/src/utils/tokenRef.ts", ] result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) @@ -271,6 +311,7 @@ def test_manifest_validation_rejects_unbound_confirmed_check_and_invalid_bypass_ "contexts": [], "source": "Task 4 evidence", "strict": True, + "do_not_enforce_on_create": False, } manifests["dev"]["bypass"]["actors"] = [ {"actor_type": "Team", "actor_id": "not-a-github-actor-id"} @@ -287,8 +328,14 @@ def test_manifest_validation_rejects_blank_or_duplicate_verified_checks( contract = _load_contract() for contexts in ( - ["Governance Gate", ""], - ["Governance Gate", "Governance Gate"], + [ + {"context": "Governance Gate", "integration_id": 195}, + {"context": "", "integration_id": 196}, + ], + [ + {"context": "Governance Gate", "integration_id": 195}, + {"context": "Governance Gate", "integration_id": 195}, + ], ): manifests = copy.deepcopy(_manifests(contract)) manifests["master"]["required_checks"] = { @@ -296,6 +343,8 @@ def test_manifest_validation_rejects_blank_or_duplicate_verified_checks( "contexts": contexts, "source": "D4 approved draft-PR Check Run evidence", "strict": True, + "do_not_enforce_on_create": False, + "evidence": _evidence("D4"), } issues = contract.validate_manifests(manifests) @@ -313,11 +362,14 @@ def test_repository_manifests_record_current_pending_gate_truth(self) -> None: "contexts": [], "source": manifests[key]["required_checks"]["source"], "strict": True, + "do_not_enforce_on_create": False, } assert ( manifests[key]["pull_request"]["code_owner_review"]["enabled"] is False ) assert manifests["release-tags"]["activation"]["remote_state"] == "not_applied" + assert manifests["release-tags"]["bypass"]["actors"] == [] + assert manifests["release-tags"]["bypass"]["emergency_exceptions"] == [] assert manifests["release-tags"]["tag_protection"]["authorized_actors"] == { "status": "pending_D3_D6", "actors": [], @@ -354,58 +406,386 @@ def test_manifest_validation_allows_evidenced_future_gate_transitions(self) -> N manifests["dev"]["activation"].update( { "remote_state": "applied", - "readback_evidence": "D3 approved Rulesets API readback", + "readback_evidence": _evidence("D3"), } ) manifests["master"]["pull_request"]["code_owner_review"].update( { "enabled": True, - "evidence": "D2 approved independent owner matrix", + "evidence": _evidence("D2"), } ) manifests["master"]["required_checks"] = { "status": "verified", - "contexts": ["Governance Gate"], + "contexts": [{"context": "Governance Gate", "integration_id": 195}], "source": "D4 approved draft-PR Check Run evidence", "strict": True, + "do_not_enforce_on_create": False, + "evidence": _evidence("D4"), } + _set_verified_tag_actors( + manifests, + [{"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"}], + ) + + assert contract.validate_manifests(manifests) == [] + + def test_manifest_validation_rejects_invalid_verified_tag_actor(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) manifests["release-tags"]["tag_protection"]["authorized_actors"] = { "status": "verified", - "actors": [{"actor_type": "Team", "actor_id": 123}], + "actors": [ + { + "actor_type": "Team", + "actor_id": "not-an-api-id", + "bypass_mode": "always", + } + ], "source": "D3 and D6 approved GitHub API actor readback", + "evidence": _evidence("D3", "D6"), } + + issues = contract.validate_manifests(manifests) + + assert any( + "release-tags.tag_protection.authorized_actors.actors[0].actor_id" in issue + for issue in issues + ) + + def test_pending_tag_actors_require_an_empty_bypass_pool(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) manifests["release-tags"]["bypass"]["actors"] = [ {"actor_type": "Team", "actor_id": 123} ] + issues = contract.validate_manifests(manifests) + + assert any( + "release-tags.bypass.actors" in issue and "pending" in issue + for issue in issues + ) + + def test_manifest_validation_allows_future_user_bypass_actor(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["bypass"]["actors"] = [ + {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + ] + manifests["dev"]["bypass"]["emergency_exceptions"] = [ + _emergency_exception( + { + "actor_type": "User", + "actor_id": 123, + "bypass_mode": "pull_request", + }, + "D3", + ) + ] + assert contract.validate_manifests(manifests) == [] - def test_manifest_validation_rejects_invalid_verified_tag_actor(self) -> None: + def test_branch_bypass_actor_requires_auditable_emergency_exception(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["bypass"]["actors"] = [ + {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + ] + + issues = contract.validate_manifests(manifests) + + assert any("dev.bypass.emergency_exceptions" in issue for issue in issues) + + def test_emergency_exception_requires_auditable_fields(self) -> None: + contract = _load_contract() + actor = {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + cases = [ + ("incident", ""), + ("reason", ""), + ("expires_at", "not-a-timestamp"), + ("expires_at", "2020-01-02T03:04:05Z"), + ("readback_evidence", "x"), + ] + + for field, value in cases: + manifests = copy.deepcopy(_manifests(contract)) + exception = _emergency_exception(actor, "D3") + exception[field] = value + manifests["dev"]["bypass"]["actors"] = [actor] + manifests["dev"]["bypass"]["emergency_exceptions"] = [exception] + + issues = contract.validate_manifests(manifests) + + assert any( + f"dev.bypass.emergency_exceptions[0].{field}" in issue + for issue in issues + ) + + def test_emergency_exception_requires_unexpired_bounded_lifetime(self) -> None: + contract = _load_contract() + actor = {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + cases = [ + ("issued_at", "not-a-timestamp", "issued_at"), + ("expires_at", "2026-08-24T00:00:00Z", "expires_at"), + ("expires_at", "2026-08-24T00:15:00Z", "expires_at"), + ("expires_at", "2026-08-25T00:00:01Z", "expires_at"), + ] + + for field, value, expected_field in cases: + manifests = copy.deepcopy(_manifests(contract)) + exception = _emergency_exception(actor, "D3") + exception.update( + { + "issued_at": "2026-08-24T00:00:00Z", + field: value, + } + ) + manifests["dev"]["bypass"]["actors"] = [actor] + manifests["dev"]["bypass"]["emergency_exceptions"] = [exception] + + issues = contract.validate_manifests(manifests, now=VALIDATION_NOW) + + assert any( + f"dev.bypass.emergency_exceptions[0].{expected_field}" in issue + for issue in issues + ) + + def test_emergency_exception_allows_bounded_unexpired_start_time_alias( + self, + ) -> None: + contract = _load_contract() + actor = {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + manifests = copy.deepcopy(_manifests(contract)) + exception = _emergency_exception(actor, "D3") + exception.pop("issued_at") + exception.update( + { + "starts_at": "2026-08-24T00:00:00Z", + "expires_at": "2026-08-24T01:00:00Z", + } + ) + manifests["dev"]["bypass"]["actors"] = [actor] + manifests["dev"]["bypass"]["emergency_exceptions"] = [exception] + + assert contract.validate_manifests(manifests, now=VALIDATION_NOW) == [] + + def test_manifest_validation_rejects_unstructured_future_gate_evidence( + self, + ) -> None: contract = _load_contract() manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["activation"].update( + {"remote_state": "applied", "readback_evidence": "x"} + ) + manifests["master"]["pull_request"]["code_owner_review"].update( + {"enabled": True, "evidence": "x"} + ) + manifests["master"]["required_checks"] = { + "status": "verified", + "contexts": [{"context": "Governance Gate", "integration_id": 195}], + "source": "D4 approved draft-PR Check Run evidence", + "strict": True, + "do_not_enforce_on_create": False, + "evidence": "x", + } + _set_verified_tag_actors( + manifests, + [{"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"}], + ) + manifests["release-tags"]["tag_protection"]["authorized_actors"]["evidence"] = ( + "x" + ) + + issues = contract.validate_manifests(manifests) + + assert any("dev.activation.readback_evidence" in issue for issue in issues) + assert any( + "master.pull_request.code_owner_review.evidence" in issue + for issue in issues + ) + assert any("master.required_checks.evidence" in issue for issue in issues) + assert any( + "release-tags.tag_protection.authorized_actors.evidence" in issue + for issue in issues + ) + + def test_structured_future_evidence_requires_the_corresponding_gate(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["activation"].update( + {"remote_state": "applied", "readback_evidence": _evidence("D4")} + ) + + issues = contract.validate_manifests(manifests) + + assert any( + "dev.activation.readback_evidence.gates" in issue for issue in issues + ) + + def test_required_checks_disallow_enforcement_on_create(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["required_checks"]["do_not_enforce_on_create"] = True + + issues = contract.validate_manifests(manifests) + + assert any( + "dev.required_checks.do_not_enforce_on_create" in issue for issue in issues + ) + + def test_bypass_actor_normalization_handles_organization_admin_and_deploy_key( + self, + ) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["release-tags"]["bypass"]["actors"] = [ + { + "actor_type": "OrganizationAdmin", + "actor_id": 321, + "bypass_mode": "always", + }, + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "always", + }, + ] + manifests["release-tags"]["bypass"]["emergency_exceptions"] = [ + _emergency_exception(actor, "D3", "D6") + for actor in manifests["release-tags"]["bypass"]["actors"] + ] manifests["release-tags"]["tag_protection"]["authorized_actors"] = { "status": "verified", - "actors": [{"actor_type": "Team", "actor_id": "not-an-api-id"}], + "actors": [ + { + "actor_type": "OrganizationAdmin", + "actor_id": None, + "bypass_mode": "always", + }, + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "always", + }, + ], "source": "D3 and D6 approved GitHub API actor readback", + "evidence": _evidence("D3", "D6"), + } + + assert contract.validate_manifests(manifests) == [] + assert contract.normalized_manifest(manifests["release-tags"])[ + "bypass_actors" + ] == [ + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "always", + }, + { + "actor_type": "OrganizationAdmin", + "actor_id": None, + "bypass_mode": "always", + }, + ] + + def test_manifest_validation_rejects_invalid_bypass_actor_schema(self) -> None: + contract = _load_contract() + cases = [ + ( + {"actor_type": "Unknown", "actor_id": 123, "bypass_mode": "always"}, + "actor_type", + ), + ( + {"actor_type": "User", "actor_id": None, "bypass_mode": "always"}, + "actor_id", + ), + ( + {"actor_type": "DeployKey", "actor_id": 123, "bypass_mode": "always"}, + "actor_id", + ), + ( + {"actor_type": "User", "actor_id": 123, "bypass_mode": "exempt"}, + "bypass_mode", + ), + ( + { + "actor_type": "User", + "actor_id": 123, + "bypass_mode": "pull_request", + }, + "bypass_mode", + ), + ( + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "pull_request", + }, + "bypass_mode", + ), + ] + + for actor, field in cases: + manifests = copy.deepcopy(_manifests(contract)) + _set_verified_tag_actors(manifests, [actor]) + + issues = contract.validate_manifests(manifests) + + assert any( + f"release-tags.bypass.actors[0].{field}" in issue for issue in issues + ) + + def test_branch_deploy_key_rejects_pull_request_mode(self) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + actor = { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "pull_request", } + manifests["dev"]["bypass"]["actors"] = [actor] + manifests["dev"]["bypass"]["emergency_exceptions"] = [ + _emergency_exception(actor, "D3") + ] issues = contract.validate_manifests(manifests) assert any( - "release-tags.tag_protection.authorized_actors.actors[0].actor_id" in issue + "dev.bypass.actors[0].bypass_mode" in issue and "DeployKey" in issue for issue in issues ) - def test_pending_tag_actors_require_an_empty_bypass_pool(self) -> None: + def test_verified_tag_actor_mode_must_match_bypass_actor(self) -> None: contract = _load_contract() manifests = copy.deepcopy(_manifests(contract)) manifests["release-tags"]["bypass"]["actors"] = [ - {"actor_type": "Team", "actor_id": 123} + {"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"} ] + manifests["release-tags"]["bypass"]["emergency_exceptions"] = [ + _emergency_exception( + {"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"}, + "D3", + "D6", + ) + ] + manifests["release-tags"]["tag_protection"]["authorized_actors"] = { + "status": "verified", + "actors": [ + { + "actor_type": "Team", + "actor_id": 123, + "bypass_mode": "pull_request", + } + ], + "source": "D3 and D6 approved GitHub API actor readback", + "evidence": _evidence("D3", "D6"), + } issues = contract.validate_manifests(manifests) assert any( - "release-tags.bypass.actors" in issue and "pending" in issue + "release-tags.tag_protection.authorized_actors.actors" in issue + and "exactly match" in issue for issue in issues ) diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index 9f33ffb47..4dcfa91e0 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -47,10 +47,50 @@ def _future_tag_manifest_dir( manifest = json.loads(source.read_text(encoding="utf-8")) if name == "release-tags": manifest["bypass"]["actors"] = [bypass_actor] + manifest["bypass"]["emergency_exceptions"] = [ + { + "actor": copy.deepcopy(bypass_actor), + "incident": "INC-195", + "reason": "documented emergency recovery", + "issued_at": "2099-01-02T02:04:05Z", + "expires_at": "2099-01-02T03:04:05Z", + "readback_evidence": { + "gates": ["D3", "D6"], + "reference": "docs/governance/evidence/D3-D6-readback.md", + }, + } + ] manifest["tag_protection"]["authorized_actors"] = { "status": "verified", "actors": [authorized_actor], "source": "D3 and D6 approved GitHub API actor readback", + "evidence": { + "gates": ["D3", "D6"], + "reference": "docs/governance/evidence/D3-D6-readback.md", + }, + } + (tmp_path / f"{name}.json").write_text(json.dumps(manifest), encoding="utf-8") + return tmp_path + + +def _future_branch_manifest_dir(tmp_path: Path, *, integration_id: int) -> Path: + """Write a D4-evidenced branch transition with a pinned Check Run source.""" + for name in ("dev", "master", "release-tags"): + source = MANIFEST_DIR / f"{name}.json" + manifest = json.loads(source.read_text(encoding="utf-8")) + if name == "dev": + manifest["required_checks"] = { + "status": "verified", + "contexts": [ + {"context": "Governance Gate", "integration_id": integration_id} + ], + "source": "D4 approved draft-PR Check Run evidence", + "strict": True, + "do_not_enforce_on_create": False, + "evidence": { + "gates": ["D4"], + "reference": "docs/governance/evidence/D4-readback.md", + }, } (tmp_path / f"{name}.json").write_text(json.dumps(manifest), encoding="utf-8") return tmp_path @@ -128,7 +168,7 @@ def test_duplicate_named_actual_rulesets_fail_regardless_of_order(self) -> None: def test_verified_tag_actor_matches_api_bypass_actor(self, tmp_path: Path) -> None: verifier = _load_verifier() - actor = {"actor_type": "Team", "actor_id": 123} + actor = {"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"} manifest_dir = _future_tag_manifest_dir( tmp_path, bypass_actor=actor, authorized_actor=actor ) @@ -146,14 +186,24 @@ def test_verified_tag_actor_must_match_bypass_actor(self, tmp_path: Path) -> Non verifier = _load_verifier() manifest_dir = _future_tag_manifest_dir( tmp_path, - bypass_actor={"actor_type": "Team", "actor_id": 123}, - authorized_actor={"actor_type": "Team", "actor_id": 456}, + bypass_actor={ + "actor_type": "Team", + "actor_id": 123, + "bypass_mode": "always", + }, + authorized_actor={ + "actor_type": "Team", + "actor_id": 456, + "bypass_mode": "always", + }, ) actual = copy.deepcopy(_fixture()["rulesets"]) tag_rule = next( rule for rule in actual if rule["name"] == "Iteration 195: release tags" ) - tag_rule["bypass_actors"] = [{"actor_type": "Team", "actor_id": 123}] + tag_rule["bypass_actors"] = [ + {"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"} + ] report = verifier.verify_rulesets(manifest_dir, actual) @@ -163,6 +213,175 @@ def test_verified_tag_actor_must_match_bypass_actor(self, tmp_path: Path) -> Non for difference in report["differences"] ) + def test_bypass_mode_drift_is_not_ignored(self, tmp_path: Path) -> None: + verifier = _load_verifier() + expected_actor = { + "actor_type": "Team", + "actor_id": 123, + "bypass_mode": "always", + } + manifest_dir = _future_tag_manifest_dir( + tmp_path, + bypass_actor=expected_actor, + authorized_actor=expected_actor, + ) + actual = copy.deepcopy(_fixture()["rulesets"]) + tag_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: release tags" + ) + tag_rule["bypass_actors"] = [ + {"actor_type": "Team", "actor_id": 123, "bypass_mode": "exempt"} + ] + + report = verifier.verify_rulesets(manifest_dir, actual) + + assert report["ok"] is False + assert any( + "release-tags.bypass_actors[0].bypass_mode" in difference + for difference in report["differences"] + ) + + def test_missing_api_bypass_actor_readback_fails_closed(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + for ruleset in actual: + del ruleset["bypass_actors"] + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert all( + verifier.normalize_github_ruleset(ruleset)["bypass_actors"] is None + for ruleset in actual + ) + assert any( + ".bypass_actors: unavailable in API readback" in difference + for difference in report["differences"] + ) + + def test_explicit_empty_api_bypass_actor_readback_matches_empty_manifest( + self, + ) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + for ruleset in actual: + ruleset["bypass_actors"] = [] + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report == {"ok": True, "differences": []} + + def test_required_check_integration_id_is_preserved_for_identity_matching( + self, + ) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + status_checks = next( + rule + for rule in dev_rule["rules"] + if rule["type"] == "required_status_checks" + ) + status_checks["parameters"]["required_status_checks"] = [ + {"context": "Governance Gate", "integration_id": 195} + ] + + normalized = verifier.normalize_github_ruleset(dev_rule) + + assert normalized["required_checks"]["contexts"] == [ + {"context": "Governance Gate", "integration_id": 195} + ] + + differences = verifier._diff( + {"contexts": [{"context": "Governance Gate", "integration_id": 196}]}, + {"contexts": normalized["required_checks"]["contexts"]}, + "dev.required_checks", + ) + + assert any( + "dev.required_checks.contexts[0].integration_id" in difference + for difference in differences + ) + + def test_do_not_enforce_on_create_drift_is_not_ignored(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + status_checks = next( + rule + for rule in dev_rule["rules"] + if rule["type"] == "required_status_checks" + ) + status_checks["parameters"]["do_not_enforce_on_create"] = True + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + "dev.required_checks.do_not_enforce_on_create" in difference + for difference in report["differences"] + ) + + def test_required_check_integration_id_drift_is_reported( + self, tmp_path: Path + ) -> None: + verifier = _load_verifier() + manifest_dir = _future_branch_manifest_dir(tmp_path, integration_id=195) + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + status_checks = next( + rule + for rule in dev_rule["rules"] + if rule["type"] == "required_status_checks" + ) + status_checks["parameters"]["required_status_checks"] = [ + {"context": "Governance Gate", "integration_id": 196} + ] + + report = verifier.verify_rulesets(manifest_dir, actual) + + assert report["ok"] is False + assert any( + "dev.required_checks.contexts[0].integration_id" in difference + for difference in report["differences"] + ) + + def test_api_actor_normalization_preserves_mode_and_ignores_org_admin_id( + self, + ) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + tag_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: release tags" + ) + tag_rule["bypass_actors"] = [ + { + "actor_type": "OrganizationAdmin", + "actor_id": 321, + "bypass_mode": "always", + }, + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "always", + }, + ] + + normalized = verifier.normalize_github_ruleset(tag_rule) + + assert normalized["bypass_actors"] == [ + { + "actor_type": "DeployKey", + "actor_id": None, + "bypass_mode": "always", + }, + { + "actor_type": "OrganizationAdmin", + "actor_id": None, + "bypass_mode": "always", + }, + ] + class TestVerifierCli: def test_fixture_cli_emits_machine_readable_success(self) -> None: @@ -207,10 +426,28 @@ def runner(command: list[str]) -> str: assert commands[0] == [ "gh", "api", + "--method", + "GET", "--paginate", "--slurp", "repos/cloudQuant/backtrader_web/rulesets", ] assert all( - "POST" not in command and "PATCH" not in command for command in commands + command[:4] == ["gh", "api", "--method", "GET"] for command in commands + ) + payload_flags = { + "-d", + "-f", + "-F", + "--body", + "--data", + "--data-binary", + "--field", + "--input", + "--raw-field", + } + assert all(not payload_flags.intersection(command) for command in commands) + assert all( + not {"POST", "PATCH", "PUT", "DELETE"}.intersection(command) + for command in commands ) diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 176e828e3..633dd7797 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -14,25 +14,18 @@ from pathlib import Path from typing import Any -from governance_contract import load_manifests, normalized_manifest, validate_manifests +from governance_contract import ( + load_manifests, + normalize_bypass_actors, + normalize_required_check_contexts, + normalized_manifest, + validate_manifests, +) Runner = Callable[[list[str]], str] -def _canonical_actors(actors: Any) -> list[dict[str, Any]]: - if not isinstance(actors, list): - return [] - canonical = [ - {"actor_type": actor.get("actor_type"), "actor_id": actor.get("actor_id")} - for actor in actors - if isinstance(actor, Mapping) - ] - return sorted( - canonical, key=lambda actor: (str(actor["actor_type"]), str(actor["actor_id"])) - ) - - def _rule_by_type(rules: Any, rule_type: str) -> Mapping[str, Any]: if not isinstance(rules, list): return {} @@ -60,6 +53,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: exclude = ref_name.get("exclude", []) if isinstance(ref_name, Mapping) else [] rules = raw_ruleset.get("rules", []) target = raw_ruleset.get("target") + raw_bypass_actors = raw_ruleset.get("bypass_actors") canonical: dict[str, Any] = { "name": raw_ruleset.get("name"), "target": { @@ -68,21 +62,17 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "exclude": sorted(exclude) if isinstance(exclude, list) else [], }, "enforcement": raw_ruleset.get("enforcement"), - "bypass_actors": _canonical_actors(raw_ruleset.get("bypass_actors", [])), + "bypass_actors": ( + normalize_bypass_actors(raw_bypass_actors) + if isinstance(raw_bypass_actors, list) + else None + ), } if target == "branch": pull_request = _rule_parameters(rules, "pull_request") status_checks = _rule_parameters(rules, "required_status_checks") raw_contexts = status_checks.get("required_status_checks", []) - contexts = [] - if isinstance(raw_contexts, list): - for context in raw_contexts: - if isinstance(context, str): - contexts.append(context) - elif isinstance(context, Mapping) and isinstance( - context.get("context"), str - ): - contexts.append(context["context"]) + contexts = normalize_required_check_contexts(raw_contexts) canonical.update( { "pull_request": { @@ -102,8 +92,11 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "block_deletion": _has_rule(rules, "deletion"), }, "required_checks": { - "contexts": sorted(contexts), + "contexts": contexts, "strict": status_checks.get("strict_required_status_checks_policy"), + "do_not_enforce_on_create": status_checks.get( + "do_not_enforce_on_create" + ), }, } ) @@ -132,6 +125,21 @@ def _diff(expected: Any, actual: Any, path: str) -> list[str]: else: differences.extend(_diff(expected[key], actual[key], child_path)) return differences + if isinstance(expected, list) and isinstance(actual, list): + differences = [] + for index in range(max(len(expected), len(actual))): + child_path = f"{path}[{index}]" + if index >= len(expected): + differences.append( + f"{child_path}: unexpected actual value {actual[index]!r}" + ) + elif index >= len(actual): + differences.append( + f"{child_path}: missing actual value; expected {expected[index]!r}" + ) + else: + differences.extend(_diff(expected[index], actual[index], child_path)) + return differences if expected != actual: return [f"{path}: expected {expected!r}, actual {actual!r}"] return [] @@ -165,6 +173,18 @@ def verify_rulesets( differences.append(f"{key}: missing actual Ruleset named {name!r}") continue actual = matching_rulesets[0] + if actual.get("bypass_actors") is None: + differences.append( + f"{key}.bypass_actors: unavailable in API readback; cannot verify an explicit empty bypass pool" + ) + expected = { + name: value + for name, value in expected.items() + if name != "bypass_actors" + } + actual = { + name: value for name, value in actual.items() if name != "bypass_actors" + } differences.extend(_diff(expected, actual, key)) managed_names = set(expected_by_name) for name in sorted(set(actual_by_name) - managed_names): @@ -205,7 +225,17 @@ def load_live_rulesets( """Read each Ruleset detail with GitHub CLI GET requests only.""" execute = runner or _run_gh listing = json.loads( - execute(["gh", "api", "--paginate", "--slurp", f"repos/{repo}/rulesets"]) + execute( + [ + "gh", + "api", + "--method", + "GET", + "--paginate", + "--slurp", + f"repos/{repo}/rulesets", + ] + ) ) summaries = _flatten_pages(listing) details: list[dict[str, Any]] = [] @@ -214,7 +244,9 @@ def load_live_rulesets( if not isinstance(ruleset_id, int): raise ValueError("GitHub Rulesets API list response contains an invalid id") detail = json.loads( - execute(["gh", "api", f"repos/{repo}/rulesets/{ruleset_id}"]) + execute( + ["gh", "api", "--method", "GET", f"repos/{repo}/rulesets/{ruleset_id}"] + ) ) if not isinstance(detail, dict): raise ValueError( From 430a5e2f4b181630a1e210de3ab148905b860f5f Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:34:33 +0800 Subject: [PATCH 14/32] fix(governance): harden ruleset readback contracts --- .github/CODEOWNERS | 9 +++ .github/governance/README.md | 12 ++- .github/governance/risk-paths.json | 20 ++++- scripts/ci/governance_contract.py | 34 +++++++- scripts/ci/tests/test_governance_contract.py | 77 ++++++++++++++++++- .../ci/tests/test_verify_github_governance.py | 74 +++++++++++++++++- scripts/ci/verify_github_governance.py | 51 ++++++++++-- 7 files changed, 256 insertions(+), 21 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9c58b56a5..16bdaa40c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,9 +58,16 @@ # Narrow R2/R3 domains /.github/workflows/** @cloudQuant /.github/governance/** @cloudQuant +/.github/dependabot.yml @cloudQuant +/scripts/ci/governance_contract.py @cloudQuant +/scripts/ci/verify_github_governance.py @cloudQuant +/scripts/ci/security_scan.sh @cloudQuant +/.pre-commit-config.yaml @cloudQuant +/.dockerignore @cloudQuant /src/backend/app/api/auth* @cloudQuant /src/backend/app/api/_dependencies.py @cloudQuant /src/backend/app/api/deps.py @cloudQuant +/src/backend/app/api/**/deps.py @cloudQuant /src/backend/app/services/auth* @cloudQuant /src/backend/app/schemas/auth* @cloudQuant /src/backend/app/utils/security* @cloudQuant @@ -72,5 +79,7 @@ /src/frontend/src/stores/** @cloudQuant /src/frontend/src/api/index.ts @cloudQuant /src/frontend/src/api/auth.ts @cloudQuant +/src/frontend/src/api/*Topics.ts @cloudQuant /src/frontend/src/utils/session.ts @cloudQuant /src/frontend/src/utils/tokenRef.ts @cloudQuant +/src/frontend/src/composables/use*Runtime.ts @cloudQuant diff --git a/.github/governance/README.md b/.github/governance/README.md index ab01d72d7..92dee5084 100644 --- a/.github/governance/README.md +++ b/.github/governance/README.md @@ -47,9 +47,10 @@ exemption cannot be bound to this repository's incident, reason, expiry, and 24-hour postmortem emergency-bypass contract. Every future bypass actor must therefore have a matching structured emergency exception with incident, reason, an ISO-8601 `issued_at` (or `starts_at`), an unexpired `expires_at` no more -than 24 hours after that start, and gate-scoped readback evidence. These are -schema capabilities, not claims that the current repository has passed those -gates. +than 24 hours after that start, and gate-scoped readback evidence. The start +must be at or before validation/readback time, so a future-issued exception +cannot pre-authorize an immediately active Ruleset bypass. These are schema +capabilities, not claims that the current repository has passed those gates. Future applied activation, code-owner enablement, verified Check Runs, tag actors, and emergency exceptions use evidence objects with explicit gate IDs @@ -57,7 +58,10 @@ and a repository-local evidence path or HTTPS URL; free-form strings do not prove a gate. Required Check Run identities retain both `context` and `integration_id`, and `do_not_enforce_on_create` is fixed at `false`. The normalizer fails closed when a Rulesets API readback omits `bypass_actors`: an -explicit empty array is required to prove an empty bypass pool. +explicit empty array is required to prove an empty bypass pool. It likewise +preserves malformed bypass entries, malformed ref/check collections, and +duplicate required-status-check rules as readable drift rather than treating +them as empty pending fields. `enforcement: "active"` expresses the eventual Ruleset configuration to be read back after its gates pass. `activation.remote_state: "not_applied"` diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index a4d7508e4..4286da2d6 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -29,6 +29,7 @@ "src/backend/app/api/_dependencies.py", "src/backend/app/api/**/_dependencies.py", "src/backend/app/api/deps.py", + "src/backend/app/api/**/deps.py", "src/backend/app/services/auth*", "src/backend/app/services/**/auth*", "src/backend/app/schemas/auth*", @@ -38,8 +39,10 @@ "src/backend/app/middleware/**", "src/frontend/src/api/index.ts", "src/frontend/src/api/auth.ts", + "src/frontend/src/api/*Topics.ts", "src/frontend/src/utils/session*", - "src/frontend/src/utils/tokenRef*" + "src/frontend/src/utils/tokenRef*", + "src/frontend/src/composables/use*Runtime.ts" ], "description": "Authentication and session boundaries require elevated review." }, @@ -78,6 +81,21 @@ ], "description": "CI execution and governance boundaries require the highest review." }, + { + "id": "r3-ci-and-security-controls", + "level": "R3", + "globs": [ + "scripts/ci/governance_contract.py", + "scripts/ci/verify_github_governance.py", + "scripts/ci/security_scan.sh", + "scripts/ci/governance_*.py", + "scripts/ci/security_*.sh", + ".github/dependabot.yml", + ".pre-commit-config.yaml", + ".dockerignore" + ], + "description": "Governance verification, security scanning, dependency automation, and root admission controls require the highest review." + }, { "id": "r3-container-and-release", "level": "R3", diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index 64bc94af2..35e50ebe8 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -62,6 +62,8 @@ GITHUB_BYPASS_MODES = {"always", "pull_request", "exempt"} ALLOWED_BYPASS_MODES = {"always", "pull_request"} EVIDENCE_PATH_PREFIXES = ("docs/", ".github/", "artifacts/") +MALFORMED_BYPASS_ACTOR_TYPE = "__malformed_non_mapping__" +MALFORMED_REQUIRED_CHECK_CONTEXT = "__malformed_required_check_context__" RELEASE_BRANCH = re.compile(r"^release/v\d+\.\d+\.\d+(?:-rc\d+)?$") HOTFIX_BRANCH = re.compile(r"^hotfix/master-[A-Za-z0-9][A-Za-z0-9._-]*$") DEV_BRANCH_PREFIXES = ("feature/", "fix/", "docs/", "refactor/", "test/") @@ -460,6 +462,13 @@ def _validate_bypass( "must be a timezone-aware ISO-8601 start timestamp", ) ) + elif start_at > now: + issues.append( + _issue( + f"{individual_path}.{start_field}", + "must be at or before the validation time to authorize a current emergency", + ) + ) expires_at = _parse_utc_timestamp(exception.get("expires_at")) if expires_at is None: @@ -889,18 +898,28 @@ def validate_manifests( def normalize_required_check_contexts(contexts: Any) -> list[dict[str, Any]]: """Normalize Check Run identities without dropping their integration source.""" if not isinstance(contexts, list): - return [] + return [ + { + "context": MALFORMED_REQUIRED_CHECK_CONTEXT, + "integration_id": None, + } + ] canonical: list[dict[str, Any]] = [] for entry in contexts: - if isinstance(entry, str): - canonical.append({"context": entry, "integration_id": None}) - elif isinstance(entry, Mapping): + if isinstance(entry, Mapping): canonical.append( { "context": entry.get("context"), "integration_id": entry.get("integration_id"), } ) + else: + canonical.append( + { + "context": MALFORMED_REQUIRED_CHECK_CONTEXT, + "integration_id": None, + } + ) return sorted( canonical, key=lambda entry: (str(entry["context"]), str(entry["integration_id"])), @@ -914,6 +933,13 @@ def normalize_bypass_actors(actors: Any) -> list[dict[str, Any]]: canonical: list[dict[str, Any]] = [] for actor in actors: if not isinstance(actor, Mapping): + canonical.append( + { + "actor_type": MALFORMED_BYPASS_ACTOR_TYPE, + "actor_id": None, + "bypass_mode": None, + } + ) continue actor_type = actor.get("actor_type") canonical.append( diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index 5c12ed5e8..7f63da7ad 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -11,7 +11,7 @@ import copy import importlib.util import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from types import ModuleType @@ -55,12 +55,14 @@ def _evidence(*gates: str) -> dict[str, object]: def _emergency_exception(actor: dict[str, object], *gates: str) -> dict[str, object]: + issued_at = datetime.now(timezone.utc).replace(microsecond=0) + expires_at = issued_at + timedelta(hours=1) return { "actor": copy.deepcopy(actor), "incident": "INC-195", "reason": "documented emergency recovery", - "issued_at": "2099-01-02T02:04:05Z", - "expires_at": "2099-01-02T03:04:05Z", + "issued_at": issued_at.isoformat().replace("+00:00", "Z"), + "expires_at": expires_at.isoformat().replace("+00:00", "Z"), "readback_evidence": _evidence(*gates), } @@ -130,12 +132,16 @@ def test_auth_and_session_boundaries_are_r2_despite_downgrade_labels(self) -> No "src/backend/app/services/auth_service.py", "src/backend/app/api/_dependencies.py", "src/backend/app/api/deps.py", + "src/backend/app/api/data/deps.py", "src/backend/app/schemas/auth.py", "src/backend/app/utils/security.py", "src/frontend/src/api/index.ts", "src/frontend/src/api/auth.ts", + "src/frontend/src/api/dataTopics.ts", "src/frontend/src/utils/session.ts", "src/frontend/src/utils/tokenRef.ts", + "src/frontend/src/composables/useBacktestRuntime.ts", + "src/frontend/src/composables/useOverfittingRuntime.ts", ] result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) @@ -148,6 +154,16 @@ def test_auth_and_session_boundaries_are_r2_despite_downgrade_labels(self) -> No } for path in paths } == {path: {"R2"} for path in paths} + entries = { + tuple(line.split()[:2]) + for line in CODEOWNERS.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + assert { + ("/src/backend/app/api/**/deps.py", "@cloudQuant"), + ("/src/frontend/src/api/*Topics.ts", "@cloudQuant"), + ("/src/frontend/src/composables/use*Runtime.ts", "@cloudQuant"), + }.issubset(entries) def test_dependency_and_release_operational_inputs_are_r3(self) -> None: contract = _load_contract() @@ -197,6 +213,37 @@ def test_security_guides_are_r3_and_have_governance_owner(self) -> None: } assert ("**/SECURITY.md", "@cloudQuant") in entries + def test_governance_and_security_controls_are_r3_despite_labels(self) -> None: + contract = _load_contract() + paths = [ + "scripts/ci/governance_contract.py", + "scripts/ci/verify_github_governance.py", + "scripts/ci/security_scan.sh", + ".github/dependabot.yml", + ".pre-commit-config.yaml", + ".dockerignore", + ] + + result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) + + assert result["risk"] == "R3" + assert result["label_can_lower_risk"] is False + assert { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in paths + } == {path: {"R3"} for path in paths} + entries = { + tuple(line.split()[:2]) + for line in CODEOWNERS.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + assert { + ("/.pre-commit-config.yaml", "@cloudQuant"), + ("/.dockerignore", "@cloudQuant"), + }.issubset(entries) + def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: contract = _load_contract() @@ -570,6 +617,30 @@ def test_emergency_exception_allows_bounded_unexpired_start_time_alias( assert contract.validate_manifests(manifests, now=VALIDATION_NOW) == [] + def test_emergency_exception_cannot_be_issued_in_the_future(self) -> None: + contract = _load_contract() + actor = {"actor_type": "User", "actor_id": 123, "bypass_mode": "pull_request"} + + for start_field in ("issued_at", "starts_at"): + manifests = copy.deepcopy(_manifests(contract)) + exception = _emergency_exception(actor, "D3") + exception.pop("issued_at") + exception.update( + { + start_field: "2026-08-24T00:31:00Z", + "expires_at": "2026-08-24T01:00:00Z", + } + ) + manifests["dev"]["bypass"]["actors"] = [actor] + manifests["dev"]["bypass"]["emergency_exceptions"] = [exception] + + issues = contract.validate_manifests(manifests, now=VALIDATION_NOW) + + assert any( + f"dev.bypass.emergency_exceptions[0].{start_field}" in issue + for issue in issues + ) + def test_manifest_validation_rejects_unstructured_future_gate_evidence( self, ) -> None: diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index 4dcfa91e0..155a43918 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -7,6 +7,7 @@ import json import subprocess import sys +from datetime import datetime, timedelta, timezone from pathlib import Path from types import ModuleType @@ -42,6 +43,8 @@ def _future_tag_manifest_dir( authorized_actor: dict[str, object], ) -> Path: """Write an evidenced D3/D6 tag transition without changing repo fixtures.""" + issued_at = datetime.now(timezone.utc).replace(microsecond=0) + expires_at = issued_at + timedelta(hours=1) for name in ("dev", "master", "release-tags"): source = MANIFEST_DIR / f"{name}.json" manifest = json.loads(source.read_text(encoding="utf-8")) @@ -52,8 +55,8 @@ def _future_tag_manifest_dir( "actor": copy.deepcopy(bypass_actor), "incident": "INC-195", "reason": "documented emergency recovery", - "issued_at": "2099-01-02T02:04:05Z", - "expires_at": "2099-01-02T03:04:05Z", + "issued_at": issued_at.isoformat().replace("+00:00", "Z"), + "expires_at": expires_at.isoformat().replace("+00:00", "Z"), "readback_evidence": { "gates": ["D3", "D6"], "reference": "docs/governance/evidence/D3-D6-readback.md", @@ -271,6 +274,73 @@ def test_explicit_empty_api_bypass_actor_readback_matches_empty_manifest( assert report == {"ok": True, "differences": []} + def test_malformed_api_bypass_actor_readback_fails_closed(self) -> None: + verifier = _load_verifier() + for malformed_entry in (None, "unexpected-non-object", 7): + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: dev" + ) + dev_rule["bypass_actors"] = [malformed_entry] + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + "dev.bypass_actors[0]" in difference + for difference in report["differences"] + ) + + def test_malformed_empty_critical_readback_fields_fail_closed(self) -> None: + verifier = _load_verifier() + + for field_path, malformed_value in ( + ("target.exclude", "not-a-list"), + ("required_checks.contexts", "not-a-list"), + ("required_checks.contexts", [None]), + ): + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next( + rule for rule in actual if rule["name"] == "Iteration 195: dev" + ) + if field_path == "target.exclude": + dev_rule["conditions"]["ref_name"]["exclude"] = malformed_value + else: + status_checks = next( + rule + for rule in dev_rule["rules"] + if rule["type"] == "required_status_checks" + ) + status_checks["parameters"]["required_status_checks"] = malformed_value + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + f"dev.{field_path}" in difference + for difference in report["differences"] + ) + + def test_duplicate_required_status_check_rules_fail_closed(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + status_checks = next( + rule + for rule in dev_rule["rules"] + if rule["type"] == "required_status_checks" + ) + dev_rule["rules"].append(copy.deepcopy(status_checks)) + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report["ok"] is False + assert any( + "dev.readback_errors" in difference + and "duplicate required_status_checks" in difference + for difference in report["differences"] + ) + def test_required_check_integration_id_is_preserved_for_identity_matching( self, ) -> None: diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 633dd7797..95b077aac 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -45,21 +45,54 @@ def _has_rule(rules: Any, rule_type: str) -> bool: return bool(_rule_by_type(rules, rule_type)) +def _count_rules(rules: Any, rule_type: str) -> int: + if not isinstance(rules, list): + return 0 + return sum( + 1 + for rule in rules + if isinstance(rule, Mapping) and rule.get("type") == rule_type + ) + + +def _normalize_ref_name_patterns(value: Any, *, field: str) -> list[str]: + """Preserve malformed ref collections as drift rather than empty lists.""" + if not isinstance(value, list): + return [f"__malformed_{field}_non_list__"] + if not all(isinstance(pattern, str) for pattern in value): + return [f"__malformed_{field}_entry__"] + return sorted(value) + + def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: """Normalize API readback, intentionally ignoring IDs, timestamps, and order.""" - conditions = raw_ruleset.get("conditions", {}) - ref_name = conditions.get("ref_name", {}) if isinstance(conditions, Mapping) else {} - include = ref_name.get("include", []) if isinstance(ref_name, Mapping) else [] - exclude = ref_name.get("exclude", []) if isinstance(ref_name, Mapping) else [] - rules = raw_ruleset.get("rules", []) + conditions = raw_ruleset.get("conditions") + ref_name = conditions.get("ref_name") if isinstance(conditions, Mapping) else None + if isinstance(ref_name, Mapping): + include = _normalize_ref_name_patterns( + ref_name.get("include", []), field="target_include" + ) + exclude = _normalize_ref_name_patterns( + ref_name.get("exclude", []), field="target_exclude" + ) + else: + include = ["__malformed_ref_name__"] + exclude = ["__malformed_ref_name__"] + raw_rules = raw_ruleset.get("rules") + rules = raw_rules if isinstance(raw_rules, list) else [] + readback_errors: list[str] = [] + if not isinstance(raw_rules, list): + readback_errors.append("rules must be a list in API readback") + elif not all(isinstance(rule, Mapping) for rule in raw_rules): + readback_errors.append("rules contains a non-object entry in API readback") target = raw_ruleset.get("target") raw_bypass_actors = raw_ruleset.get("bypass_actors") canonical: dict[str, Any] = { "name": raw_ruleset.get("name"), "target": { "kind": target, - "include": sorted(include) if isinstance(include, list) else [], - "exclude": sorted(exclude) if isinstance(exclude, list) else [], + "include": include, + "exclude": exclude, }, "enforcement": raw_ruleset.get("enforcement"), "bypass_actors": ( @@ -69,6 +102,8 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: ), } if target == "branch": + if _count_rules(rules, "required_status_checks") > 1: + readback_errors.append("duplicate required_status_checks rules") pull_request = _rule_parameters(rules, "pull_request") status_checks = _rule_parameters(rules, "required_status_checks") raw_contexts = status_checks.get("required_status_checks", []) @@ -106,6 +141,8 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "block_update": _has_rule(rules, "update"), "block_deletion": _has_rule(rules, "deletion"), } + if readback_errors: + canonical["readback_errors"] = sorted(readback_errors) return canonical From 761e1f38f359a7bed8eeb5e089644f8b24c2cac1 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:37:01 +0800 Subject: [PATCH 15/32] fix(governance): cover secret control paths --- .github/CODEOWNERS | 3 ++ .github/governance/risk-paths.json | 10 ++++++ scripts/ci/tests/test_governance_contract.py | 32 ++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 16bdaa40c..d1d8e37f7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -68,6 +68,9 @@ /src/backend/app/api/_dependencies.py @cloudQuant /src/backend/app/api/deps.py @cloudQuant /src/backend/app/api/**/deps.py @cloudQuant +/src/backend/app/api/live_trading/credentials.py @cloudQuant +/src/backend/app/config.py @cloudQuant +/src/backend/app/services/ai_router/provider_config_store.py @cloudQuant /src/backend/app/services/auth* @cloudQuant /src/backend/app/schemas/auth* @cloudQuant /src/backend/app/utils/security* @cloudQuant diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index 4286da2d6..ddd114a4c 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -96,6 +96,16 @@ ], "description": "Governance verification, security scanning, dependency automation, and root admission controls require the highest review." }, + { + "id": "r3-secret-configuration-controls", + "level": "R3", + "globs": [ + "src/backend/app/api/live_trading/credentials.py", + "src/backend/app/config.py", + "src/backend/app/services/ai_router/provider_config_store.py" + ], + "description": "Broker credential redaction, exchange/JWT configuration guards, and provider-key encryption require the highest review." + }, { "id": "r3-container-and-release", "level": "R3", diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index 7f63da7ad..38aa047b5 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -244,6 +244,38 @@ def test_governance_and_security_controls_are_r3_despite_labels(self) -> None: ("/.dockerignore", "@cloudQuant"), }.issubset(entries) + def test_backend_secret_and_redaction_controls_are_r3_despite_labels(self) -> None: + contract = _load_contract() + paths = [ + "src/backend/app/api/live_trading/credentials.py", + "src/backend/app/config.py", + "src/backend/app/services/ai_router/provider_config_store.py", + ] + + result = contract.classify_paths(paths, _risk_map(), labels=["risk:R0"]) + + assert result["risk"] == "R3" + assert result["label_can_lower_risk"] is False + assert { + path: { + match["level"] for match in result["matches"] if match["path"] == path + } + for path in paths + } == {path: {"R3"} for path in paths} + entries = { + tuple(line.split()[:2]) + for line in CODEOWNERS.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + assert { + ("/src/backend/app/api/live_trading/credentials.py", "@cloudQuant"), + ("/src/backend/app/config.py", "@cloudQuant"), + ( + "/src/backend/app/services/ai_router/provider_config_store.py", + "@cloudQuant", + ), + }.issubset(entries) + def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: contract = _load_contract() From d7575a6a2143792707c25b5516508f3f32ea6987 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:41:23 +0800 Subject: [PATCH 16/32] style(governance): format ruleset contracts --- scripts/ci/governance_contract.py | 86 ++++--------- scripts/ci/tests/test_governance_contract.py | 118 +++++------------- .../ci/tests/test_verify_github_governance.py | 98 ++++----------- scripts/ci/verify_github_governance.py | 71 +++-------- 4 files changed, 91 insertions(+), 282 deletions(-) diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index 35e50ebe8..2d40ea0c6 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -15,7 +15,6 @@ from pathlib import Path from typing import Any - RISK_ORDER = {"R0": 0, "R1": 1, "R2": 2, "R3": 3} REQUIRED_MANIFESTS = { "dev": { @@ -163,31 +162,21 @@ def validate_branch_contract( if base_branch == "master": if RELEASE_BRANCH.fullmatch(head_branch): if not _has_value(evidence, "release_checklist"): - issues.append( - "release/vX.Y.Z -> master requires a completed release checklist" - ) + issues.append("release/vX.Y.Z -> master requires a completed release checklist") if not _has_value(evidence, "release_validation"): - issues.append( - "release/vX.Y.Z -> master requires release validation evidence" - ) + issues.append("release/vX.Y.Z -> master requires release validation evidence") elif HOTFIX_BRANCH.fullmatch(head_branch): if not _has_value(evidence, "incident"): issues.append( "hotfix/master-* -> master requires an incident or private disclosure record" ) if not _has_value(evidence, "forward_port_plan"): - issues.append( - "hotfix/master-* -> master requires a forward-port plan for dev" - ) + issues.append("hotfix/master-* -> master requires a forward-port plan for dev") else: - issues.append( - "master only accepts release/vX.Y.Z or hotfix/master-* branches" - ) + issues.append("master only accepts release/vX.Y.Z or hotfix/master-* branches") elif base_branch == "dev": if not head_branch.startswith(DEV_BRANCH_PREFIXES): - issues.append( - "dev accepts feature/*, fix/*, docs/*, refactor/*, or test/* branches" - ) + issues.append("dev accepts feature/*, fix/*, docs/*, refactor/*, or test/* branches") else: issues.append(f"unsupported target branch: {base_branch}") return issues @@ -232,9 +221,7 @@ def _is_evidence_reference(value: str) -> bool: ) -def _validate_gate_evidence( - evidence: Any, path: str, required_gates: Sequence[str] -) -> list[str]: +def _validate_gate_evidence(evidence: Any, path: str, required_gates: Sequence[str]) -> list[str]: if not isinstance(evidence, Mapping): return [ _issue( @@ -281,9 +268,7 @@ def _parse_utc_timestamp(value: Any) -> datetime | None: return parsed.astimezone(timezone.utc) -def _validate_bypass_actor( - actor: Any, actor_path: str, *, target_kind: str -) -> list[str]: +def _validate_bypass_actor(actor: Any, actor_path: str, *, target_kind: str) -> list[str]: if not isinstance(actor, Mapping): return [_issue(actor_path, "must be an object from API readback")] @@ -378,9 +363,7 @@ def _validate_bypass( "must cite future GitHub API readback rather than none or empty text", ) ) - elif any( - token.casefold() not in actor_source.casefold() for token in expected_tokens - ): + elif any(token.casefold() not in actor_source.casefold() for token in expected_tokens): token_text = ", ".join(expected_tokens) issues.append( _issue( @@ -537,9 +520,7 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: ) return issues if not isinstance(source, str) or not source.strip(): - issues.append( - _issue(f"{key}.required_checks.source", "must record the evidence source") - ) + issues.append(_issue(f"{key}.required_checks.source", "must record the evidence source")) if required_checks.get("strict") is not True: issues.append(_issue(f"{key}.required_checks.strict", "must be true")) if required_checks.get("do_not_enforce_on_create") is not False: @@ -620,9 +601,7 @@ def _validate_required_checks(key: str, required_checks: Any) -> list[str]: return issues -def _validate_activation( - key: str, activation: Any, expected: Mapping[str, Any] -) -> list[str]: +def _validate_activation(key: str, activation: Any, expected: Mapping[str, Any]) -> list[str]: """Validate current and evidenced-future Ruleset activation states.""" if not isinstance(activation, Mapping): return [_issue(f"{key}.activation", "must be an object")] @@ -679,15 +658,11 @@ def _validate_branch_manifest( ) ) if pull_request.get("conversation_resolution") is not True: - issues.append( - _issue(f"{key}.pull_request.conversation_resolution", "must be true") - ) + issues.append(_issue(f"{key}.pull_request.conversation_resolution", "must be true")) code_owner = pull_request.get("code_owner_review") if not isinstance(code_owner, Mapping): - issues.append( - _issue(f"{key}.pull_request.code_owner_review", "must be an object") - ) + issues.append(_issue(f"{key}.pull_request.code_owner_review", "must be an object")) elif code_owner.get("desired_when_d2_ready") is not True: issues.append( _issue( @@ -696,10 +671,7 @@ def _validate_branch_manifest( ) ) elif code_owner.get("enabled") is False: - if ( - not isinstance(code_owner.get("reason"), str) - or not code_owner["reason"].strip() - ): + if not isinstance(code_owner.get("reason"), str) or not code_owner["reason"].strip(): issues.append( _issue( f"{key}.pull_request.code_owner_review.reason", @@ -729,9 +701,7 @@ def _validate_branch_manifest( protection.get("block_force_push") is not True or protection.get("block_deletion") is not True ): - issues.append( - _issue(f"{key}.protection", "must block force pushes and deletion") - ) + issues.append(_issue(f"{key}.protection", "must block force pushes and deletion")) issues.extend(_validate_required_checks(key, manifest.get("required_checks"))) return issues @@ -751,9 +721,7 @@ def _validate_tag_manifest( issues.append(_issue(f"{key}.tag_protection.{field}", "must be true")) authorized = tag_protection.get("authorized_actors") if not isinstance(authorized, Mapping): - issues.append( - _issue(f"{key}.tag_protection.authorized_actors", "must be an object") - ) + issues.append(_issue(f"{key}.tag_protection.authorized_actors", "must be an object")) else: status = authorized.get("status") actors = authorized.get("actors") @@ -790,9 +758,7 @@ def _validate_tag_manifest( target_kind="tag", ) ) - if normalize_bypass_actors(actors) != normalize_bypass_actors( - bypass_actors - ): + if normalize_bypass_actors(actors) != normalize_bypass_actors(bypass_actors): issues.append( _issue( actor_path, @@ -858,9 +824,7 @@ def validate_manifests( issues.append(_issue(f"{key}.target", "must be an object")) else: if target.get("kind") != expected["target_kind"]: - issues.append( - _issue(f"{key}.target.kind", f"must be {expected['target_kind']!r}") - ) + issues.append(_issue(f"{key}.target.kind", f"must be {expected['target_kind']!r}")) if target.get("include") != expected["target_include"]: issues.append( _issue( @@ -876,9 +840,7 @@ def validate_manifests( ) ) if manifest.get("enforcement") != "active": - issues.append( - _issue(f"{key}.enforcement", "must be 'active' desired state") - ) + issues.append(_issue(f"{key}.enforcement", "must be 'active' desired state")) issues.extend(_validate_activation(key, manifest.get("activation"), expected)) issues.extend( _validate_bypass( @@ -945,9 +907,7 @@ def normalize_bypass_actors(actors: Any) -> list[dict[str, Any]]: canonical.append( { "actor_type": actor_type, - "actor_id": ( - None if actor_type == "OrganizationAdmin" else actor.get("actor_id") - ), + "actor_id": (None if actor_type == "OrganizationAdmin" else actor.get("actor_id")), "bypass_mode": actor.get("bypass_mode"), } ) @@ -993,9 +953,7 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: "required": pr_mapping.get("required"), "required_approvals": pr_mapping.get("required_approvals"), "code_owner_review_enabled": code_owner_mapping.get("enabled"), - "conversation_resolution": pr_mapping.get( - "conversation_resolution" - ), + "conversation_resolution": pr_mapping.get("conversation_resolution"), }, "protection": { "block_force_push": protection_mapping.get("block_force_push"), @@ -1006,9 +964,7 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: checks_mapping.get("contexts", []) ), "strict": checks_mapping.get("strict"), - "do_not_enforce_on_create": checks_mapping.get( - "do_not_enforce_on_create" - ), + "do_not_enforce_on_create": checks_mapping.get("do_not_enforce_on_create"), }, } ) diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index 38aa047b5..c6a27a047 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -15,7 +15,6 @@ from pathlib import Path from types import ModuleType - REPO_ROOT = Path(__file__).resolve().parents[3] GOVERNANCE_CONTRACT = REPO_ROOT / "scripts" / "ci" / "governance_contract.py" RISK_MAP = REPO_ROOT / ".github" / "governance" / "risk-paths.json" @@ -25,9 +24,7 @@ def _load_contract() -> ModuleType: - assert GOVERNANCE_CONTRACT.is_file(), ( - "Task 3 governance contract is not implemented" - ) + assert GOVERNANCE_CONTRACT.is_file(), "Task 3 governance contract is not implemented" spec = importlib.util.spec_from_file_location( "governance_contract_under_test", GOVERNANCE_CONTRACT ) @@ -102,27 +99,21 @@ def test_highest_matching_risk_wins_across_mixed_paths(self) -> None: def test_documentation_only_change_is_r0(self) -> None: contract = _load_contract() - result = contract.classify_paths( - ["docs/architecture/community-pr.md"], _risk_map() - ) + result = contract.classify_paths(["docs/architecture/community-pr.md"], _risk_map()) assert result["risk"] == "R0" def test_unmapped_behavior_change_uses_default_r1(self) -> None: contract = _load_contract() - result = contract.classify_paths( - ["src/backend/app/services/market_data.py"], _risk_map() - ) + result = contract.classify_paths(["src/backend/app/services/market_data.py"], _risk_map()) assert result["risk"] == "R1" def test_router_change_is_r2_without_an_r3_path(self) -> None: contract = _load_contract() - result = contract.classify_paths( - ["src/frontend/src/router/index.ts"], _risk_map() - ) + result = contract.classify_paths(["src/frontend/src/router/index.ts"], _risk_map()) assert result["risk"] == "R2" @@ -149,9 +140,7 @@ def test_auth_and_session_boundaries_are_r2_despite_downgrade_labels(self) -> No assert result["risk"] == "R2" assert result["label_can_lower_risk"] is False assert { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in paths } == {path: {"R2"} for path in paths} entries = { @@ -183,9 +172,7 @@ def test_dependency_and_release_operational_inputs_are_r3(self) -> None: assert result["risk"] == "R3" assert result["label_can_lower_risk"] is False assert { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in paths } == {path: {"R3"} for path in paths} @@ -200,9 +187,7 @@ def test_security_guides_are_r3_and_have_governance_owner(self) -> None: assert result["risk"] == "R3" levels_by_path = { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in paths } assert all("R3" in levels for levels in levels_by_path.values()) @@ -229,9 +214,7 @@ def test_governance_and_security_controls_are_r3_despite_labels(self) -> None: assert result["risk"] == "R3" assert result["label_can_lower_risk"] is False assert { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in paths } == {path: {"R3"} for path in paths} entries = { @@ -257,9 +240,7 @@ def test_backend_secret_and_redaction_controls_are_r3_despite_labels(self) -> No assert result["risk"] == "R3" assert result["label_can_lower_risk"] is False assert { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in paths } == {path: {"R3"} for path in paths} entries = { @@ -296,9 +277,7 @@ def test_sensitive_paths_cannot_be_downgraded_by_pr_labels(self) -> None: assert result["label_can_lower_risk"] is False assert result["ignored_labels"] == ["merge-ready", "risk:R0"] levels_by_path = { - path: { - match["level"] for match in result["matches"] if match["path"] == path - } + path: {match["level"] for match in result["matches"] if match["path"] == path} for path in { ".github/workflows/ci.yml", "src/backend/app/api/auth.py", @@ -322,13 +301,10 @@ class TestBranchRoutingContract: def test_master_rejects_regular_feature_branch(self) -> None: contract = _load_contract() - issues = contract.validate_branch_contract( - "master", "feature/market-screen", {} - ) + issues = contract.validate_branch_contract("master", "feature/market-screen", {}) assert any( - "master only accepts release/vX.Y.Z or hotfix/master-*" in issue - for issue in issues + "master only accepts release/vX.Y.Z or hotfix/master-*" in issue for issue in issues ) def test_release_and_hotfix_require_different_evidence(self) -> None: @@ -443,18 +419,14 @@ def test_repository_manifests_record_current_pending_gate_truth(self) -> None: "strict": True, "do_not_enforce_on_create": False, } - assert ( - manifests[key]["pull_request"]["code_owner_review"]["enabled"] is False - ) + assert manifests[key]["pull_request"]["code_owner_review"]["enabled"] is False assert manifests["release-tags"]["activation"]["remote_state"] == "not_applied" assert manifests["release-tags"]["bypass"]["actors"] == [] assert manifests["release-tags"]["bypass"]["emergency_exceptions"] == [] assert manifests["release-tags"]["tag_protection"]["authorized_actors"] == { "status": "pending_D3_D6", "actors": [], - "source": manifests["release-tags"]["tag_protection"]["authorized_actors"][ - "source" - ], + "source": manifests["release-tags"]["tag_protection"]["authorized_actors"]["source"], } def test_manifest_validation_rejects_future_transitions_without_evidence( @@ -475,8 +447,7 @@ def test_manifest_validation_rejects_future_transitions_without_evidence( assert any("master.pull_request.code_owner_review" in issue for issue in issues) assert any("release-tags.activation.gate" in issue for issue in issues) assert any( - "release-tags.tag_protection.authorized_actors.source" in issue - for issue in issues + "release-tags.tag_protection.authorized_actors.source" in issue for issue in issues ) def test_manifest_validation_allows_evidenced_future_gate_transitions(self) -> None: @@ -535,16 +506,11 @@ def test_manifest_validation_rejects_invalid_verified_tag_actor(self) -> None: def test_pending_tag_actors_require_an_empty_bypass_pool(self) -> None: contract = _load_contract() manifests = copy.deepcopy(_manifests(contract)) - manifests["release-tags"]["bypass"]["actors"] = [ - {"actor_type": "Team", "actor_id": 123} - ] + manifests["release-tags"]["bypass"]["actors"] = [{"actor_type": "Team", "actor_id": 123}] issues = contract.validate_manifests(manifests) - assert any( - "release-tags.bypass.actors" in issue and "pending" in issue - for issue in issues - ) + assert any("release-tags.bypass.actors" in issue and "pending" in issue for issue in issues) def test_manifest_validation_allows_future_user_bypass_actor(self) -> None: contract = _load_contract() @@ -596,10 +562,7 @@ def test_emergency_exception_requires_auditable_fields(self) -> None: issues = contract.validate_manifests(manifests) - assert any( - f"dev.bypass.emergency_exceptions[0].{field}" in issue - for issue in issues - ) + assert any(f"dev.bypass.emergency_exceptions[0].{field}" in issue for issue in issues) def test_emergency_exception_requires_unexpired_bounded_lifetime(self) -> None: contract = _load_contract() @@ -626,8 +589,7 @@ def test_emergency_exception_requires_unexpired_bounded_lifetime(self) -> None: issues = contract.validate_manifests(manifests, now=VALIDATION_NOW) assert any( - f"dev.bypass.emergency_exceptions[0].{expected_field}" in issue - for issue in issues + f"dev.bypass.emergency_exceptions[0].{expected_field}" in issue for issue in issues ) def test_emergency_exception_allows_bounded_unexpired_start_time_alias( @@ -669,8 +631,7 @@ def test_emergency_exception_cannot_be_issued_in_the_future(self) -> None: issues = contract.validate_manifests(manifests, now=VALIDATION_NOW) assert any( - f"dev.bypass.emergency_exceptions[0].{start_field}" in issue - for issue in issues + f"dev.bypass.emergency_exceptions[0].{start_field}" in issue for issue in issues ) def test_manifest_validation_rejects_unstructured_future_gate_evidence( @@ -678,9 +639,7 @@ def test_manifest_validation_rejects_unstructured_future_gate_evidence( ) -> None: contract = _load_contract() manifests = copy.deepcopy(_manifests(contract)) - manifests["dev"]["activation"].update( - {"remote_state": "applied", "readback_evidence": "x"} - ) + manifests["dev"]["activation"].update({"remote_state": "applied", "readback_evidence": "x"}) manifests["master"]["pull_request"]["code_owner_review"].update( {"enabled": True, "evidence": "x"} ) @@ -696,21 +655,15 @@ def test_manifest_validation_rejects_unstructured_future_gate_evidence( manifests, [{"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"}], ) - manifests["release-tags"]["tag_protection"]["authorized_actors"]["evidence"] = ( - "x" - ) + manifests["release-tags"]["tag_protection"]["authorized_actors"]["evidence"] = "x" issues = contract.validate_manifests(manifests) assert any("dev.activation.readback_evidence" in issue for issue in issues) - assert any( - "master.pull_request.code_owner_review.evidence" in issue - for issue in issues - ) + assert any("master.pull_request.code_owner_review.evidence" in issue for issue in issues) assert any("master.required_checks.evidence" in issue for issue in issues) assert any( - "release-tags.tag_protection.authorized_actors.evidence" in issue - for issue in issues + "release-tags.tag_protection.authorized_actors.evidence" in issue for issue in issues ) def test_structured_future_evidence_requires_the_corresponding_gate(self) -> None: @@ -722,9 +675,7 @@ def test_structured_future_evidence_requires_the_corresponding_gate(self) -> Non issues = contract.validate_manifests(manifests) - assert any( - "dev.activation.readback_evidence.gates" in issue for issue in issues - ) + assert any("dev.activation.readback_evidence.gates" in issue for issue in issues) def test_required_checks_disallow_enforcement_on_create(self) -> None: contract = _load_contract() @@ -733,9 +684,7 @@ def test_required_checks_disallow_enforcement_on_create(self) -> None: issues = contract.validate_manifests(manifests) - assert any( - "dev.required_checks.do_not_enforce_on_create" in issue for issue in issues - ) + assert any("dev.required_checks.do_not_enforce_on_create" in issue for issue in issues) def test_bypass_actor_normalization_handles_organization_admin_and_deploy_key( self, @@ -777,9 +726,7 @@ def test_bypass_actor_normalization_handles_organization_admin_and_deploy_key( } assert contract.validate_manifests(manifests) == [] - assert contract.normalized_manifest(manifests["release-tags"])[ - "bypass_actors" - ] == [ + assert contract.normalized_manifest(manifests["release-tags"])["bypass_actors"] == [ { "actor_type": "DeployKey", "actor_id": None, @@ -835,9 +782,7 @@ def test_manifest_validation_rejects_invalid_bypass_actor_schema(self) -> None: issues = contract.validate_manifests(manifests) - assert any( - f"release-tags.bypass.actors[0].{field}" in issue for issue in issues - ) + assert any(f"release-tags.bypass.actors[0].{field}" in issue for issue in issues) def test_branch_deploy_key_rejects_pull_request_mode(self) -> None: contract = _load_contract() @@ -848,15 +793,12 @@ def test_branch_deploy_key_rejects_pull_request_mode(self) -> None: "bypass_mode": "pull_request", } manifests["dev"]["bypass"]["actors"] = [actor] - manifests["dev"]["bypass"]["emergency_exceptions"] = [ - _emergency_exception(actor, "D3") - ] + manifests["dev"]["bypass"]["emergency_exceptions"] = [_emergency_exception(actor, "D3")] issues = contract.validate_manifests(manifests) assert any( - "dev.bypass.actors[0].bypass_mode" in issue and "DeployKey" in issue - for issue in issues + "dev.bypass.actors[0].bypass_mode" in issue and "DeployKey" in issue for issue in issues ) def test_verified_tag_actor_mode_must_match_bypass_actor(self) -> None: diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index 155a43918..a6d8cc5f8 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -11,7 +11,6 @@ from pathlib import Path from types import ModuleType - REPO_ROOT = Path(__file__).resolve().parents[3] VERIFIER = REPO_ROOT / "scripts" / "ci" / "verify_github_governance.py" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" @@ -22,9 +21,7 @@ def _load_verifier() -> ModuleType: assert VERIFIER.is_file(), "Task 3 GitHub governance verifier is not implemented" if str(VERIFIER.parent) not in sys.path: sys.path.insert(0, str(VERIFIER.parent)) - spec = importlib.util.spec_from_file_location( - "verify_github_governance_under_test", VERIFIER - ) + spec = importlib.util.spec_from_file_location("verify_github_governance_under_test", VERIFIER) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -84,9 +81,7 @@ def _future_branch_manifest_dir(tmp_path: Path, *, integration_id: int) -> Path: if name == "dev": manifest["required_checks"] = { "status": "verified", - "contexts": [ - {"context": "Governance Gate", "integration_id": integration_id} - ], + "contexts": [{"context": "Governance Gate", "integration_id": integration_id}], "source": "D4 approved draft-PR Check Run evidence", "strict": True, "do_not_enforce_on_create": False, @@ -121,18 +116,13 @@ def test_real_rule_drift_has_a_human_readable_path(self) -> None: verifier = _load_verifier() actual = copy.deepcopy(_fixture()["rulesets"]) dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") - pull_request = next( - rule for rule in dev_rule["rules"] if rule["type"] == "pull_request" - ) + pull_request = next(rule for rule in dev_rule["rules"] if rule["type"] == "pull_request") pull_request["parameters"]["required_approving_review_count"] = 0 report = verifier.verify_rulesets(MANIFEST_DIR, actual) assert report["ok"] is False - assert any( - "dev.pull_request.required_approvals" in diff - for diff in report["differences"] - ) + assert any("dev.pull_request.required_approvals" in diff for diff in report["differences"]) def test_ref_name_exclude_drift_is_not_ignored(self) -> None: verifier = _load_verifier() @@ -148,9 +138,7 @@ def test_ref_name_exclude_drift_is_not_ignored(self) -> None: def test_duplicate_named_actual_rulesets_fail_regardless_of_order(self) -> None: verifier = _load_verifier() original = _fixture()["rulesets"] - dev_rule = next( - rule for rule in original if rule["name"] == "Iteration 195: dev" - ) + dev_rule = next(rule for rule in original if rule["name"] == "Iteration 195: dev") for duplicate_first in (True, False): actual = copy.deepcopy(original) @@ -176,9 +164,7 @@ def test_verified_tag_actor_matches_api_bypass_actor(self, tmp_path: Path) -> No tmp_path, bypass_actor=actor, authorized_actor=actor ) actual = copy.deepcopy(_fixture()["rulesets"]) - tag_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: release tags" - ) + tag_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: release tags") tag_rule["bypass_actors"] = [actor] report = verifier.verify_rulesets(manifest_dir, actual) @@ -201,9 +187,7 @@ def test_verified_tag_actor_must_match_bypass_actor(self, tmp_path: Path) -> Non }, ) actual = copy.deepcopy(_fixture()["rulesets"]) - tag_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: release tags" - ) + tag_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: release tags") tag_rule["bypass_actors"] = [ {"actor_type": "Team", "actor_id": 123, "bypass_mode": "always"} ] @@ -229,9 +213,7 @@ def test_bypass_mode_drift_is_not_ignored(self, tmp_path: Path) -> None: authorized_actor=expected_actor, ) actual = copy.deepcopy(_fixture()["rulesets"]) - tag_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: release tags" - ) + tag_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: release tags") tag_rule["bypass_actors"] = [ {"actor_type": "Team", "actor_id": 123, "bypass_mode": "exempt"} ] @@ -278,18 +260,13 @@ def test_malformed_api_bypass_actor_readback_fails_closed(self) -> None: verifier = _load_verifier() for malformed_entry in (None, "unexpected-non-object", 7): actual = copy.deepcopy(_fixture()["rulesets"]) - dev_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: dev" - ) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") dev_rule["bypass_actors"] = [malformed_entry] report = verifier.verify_rulesets(MANIFEST_DIR, actual) assert report["ok"] is False - assert any( - "dev.bypass_actors[0]" in difference - for difference in report["differences"] - ) + assert any("dev.bypass_actors[0]" in difference for difference in report["differences"]) def test_malformed_empty_critical_readback_fields_fail_closed(self) -> None: verifier = _load_verifier() @@ -300,35 +277,26 @@ def test_malformed_empty_critical_readback_fields_fail_closed(self) -> None: ("required_checks.contexts", [None]), ): actual = copy.deepcopy(_fixture()["rulesets"]) - dev_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: dev" - ) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") if field_path == "target.exclude": dev_rule["conditions"]["ref_name"]["exclude"] = malformed_value else: status_checks = next( - rule - for rule in dev_rule["rules"] - if rule["type"] == "required_status_checks" + rule for rule in dev_rule["rules"] if rule["type"] == "required_status_checks" ) status_checks["parameters"]["required_status_checks"] = malformed_value report = verifier.verify_rulesets(MANIFEST_DIR, actual) assert report["ok"] is False - assert any( - f"dev.{field_path}" in difference - for difference in report["differences"] - ) + assert any(f"dev.{field_path}" in difference for difference in report["differences"]) def test_duplicate_required_status_check_rules_fail_closed(self) -> None: verifier = _load_verifier() actual = copy.deepcopy(_fixture()["rulesets"]) dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") status_checks = next( - rule - for rule in dev_rule["rules"] - if rule["type"] == "required_status_checks" + rule for rule in dev_rule["rules"] if rule["type"] == "required_status_checks" ) dev_rule["rules"].append(copy.deepcopy(status_checks)) @@ -336,8 +304,7 @@ def test_duplicate_required_status_check_rules_fail_closed(self) -> None: assert report["ok"] is False assert any( - "dev.readback_errors" in difference - and "duplicate required_status_checks" in difference + "dev.readback_errors" in difference and "duplicate required_status_checks" in difference for difference in report["differences"] ) @@ -348,9 +315,7 @@ def test_required_check_integration_id_is_preserved_for_identity_matching( actual = copy.deepcopy(_fixture()["rulesets"]) dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") status_checks = next( - rule - for rule in dev_rule["rules"] - if rule["type"] == "required_status_checks" + rule for rule in dev_rule["rules"] if rule["type"] == "required_status_checks" ) status_checks["parameters"]["required_status_checks"] = [ {"context": "Governance Gate", "integration_id": 195} @@ -378,9 +343,7 @@ def test_do_not_enforce_on_create_drift_is_not_ignored(self) -> None: actual = copy.deepcopy(_fixture()["rulesets"]) dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") status_checks = next( - rule - for rule in dev_rule["rules"] - if rule["type"] == "required_status_checks" + rule for rule in dev_rule["rules"] if rule["type"] == "required_status_checks" ) status_checks["parameters"]["do_not_enforce_on_create"] = True @@ -392,17 +355,13 @@ def test_do_not_enforce_on_create_drift_is_not_ignored(self) -> None: for difference in report["differences"] ) - def test_required_check_integration_id_drift_is_reported( - self, tmp_path: Path - ) -> None: + def test_required_check_integration_id_drift_is_reported(self, tmp_path: Path) -> None: verifier = _load_verifier() manifest_dir = _future_branch_manifest_dir(tmp_path, integration_id=195) actual = copy.deepcopy(_fixture()["rulesets"]) dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") status_checks = next( - rule - for rule in dev_rule["rules"] - if rule["type"] == "required_status_checks" + rule for rule in dev_rule["rules"] if rule["type"] == "required_status_checks" ) status_checks["parameters"]["required_status_checks"] = [ {"context": "Governance Gate", "integration_id": 196} @@ -421,9 +380,7 @@ def test_api_actor_normalization_preserves_mode_and_ignores_org_admin_id( ) -> None: verifier = _load_verifier() actual = copy.deepcopy(_fixture()["rulesets"]) - tag_rule = next( - rule for rule in actual if rule["name"] == "Iteration 195: release tags" - ) + tag_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: release tags") tag_rule["bypass_actors"] = [ { "actor_type": "OrganizationAdmin", @@ -484,15 +441,11 @@ def runner(command: list[str]) -> str: if command[-1].endswith("/rulesets"): return json.dumps([[{"id": item["id"]} for item in raw_rulesets]]) ruleset_id = int(command[-1].rsplit("/", maxsplit=1)[-1]) - return json.dumps( - next(item for item in raw_rulesets if item["id"] == ruleset_id) - ) + return json.dumps(next(item for item in raw_rulesets if item["id"] == ruleset_id)) loaded = verifier.load_live_rulesets("cloudQuant/backtrader_web", runner=runner) - assert [item["name"] for item in loaded] == [ - item["name"] for item in raw_rulesets - ] + assert [item["name"] for item in loaded] == [item["name"] for item in raw_rulesets] assert commands[0] == [ "gh", "api", @@ -502,9 +455,7 @@ def runner(command: list[str]) -> str: "--slurp", "repos/cloudQuant/backtrader_web/rulesets", ] - assert all( - command[:4] == ["gh", "api", "--method", "GET"] for command in commands - ) + assert all(command[:4] == ["gh", "api", "--method", "GET"] for command in commands) payload_flags = { "-d", "-f", @@ -518,6 +469,5 @@ def runner(command: list[str]) -> str: } assert all(not payload_flags.intersection(command) for command in commands) assert all( - not {"POST", "PATCH", "PUT", "DELETE"}.intersection(command) - for command in commands + not {"POST", "PATCH", "PUT", "DELETE"}.intersection(command) for command in commands ) diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 95b077aac..4d42c5108 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -22,7 +22,6 @@ validate_manifests, ) - Runner = Callable[[list[str]], str] @@ -48,11 +47,7 @@ def _has_rule(rules: Any, rule_type: str) -> bool: def _count_rules(rules: Any, rule_type: str) -> int: if not isinstance(rules, list): return 0 - return sum( - 1 - for rule in rules - if isinstance(rule, Mapping) and rule.get("type") == rule_type - ) + return sum(1 for rule in rules if isinstance(rule, Mapping) and rule.get("type") == rule_type) def _normalize_ref_name_patterns(value: Any, *, field: str) -> list[str]: @@ -69,12 +64,8 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: conditions = raw_ruleset.get("conditions") ref_name = conditions.get("ref_name") if isinstance(conditions, Mapping) else None if isinstance(ref_name, Mapping): - include = _normalize_ref_name_patterns( - ref_name.get("include", []), field="target_include" - ) - exclude = _normalize_ref_name_patterns( - ref_name.get("exclude", []), field="target_exclude" - ) + include = _normalize_ref_name_patterns(ref_name.get("include", []), field="target_include") + exclude = _normalize_ref_name_patterns(ref_name.get("exclude", []), field="target_exclude") else: include = ["__malformed_ref_name__"] exclude = ["__malformed_ref_name__"] @@ -112,12 +103,8 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: { "pull_request": { "required": _has_rule(rules, "pull_request"), - "required_approvals": pull_request.get( - "required_approving_review_count" - ), - "code_owner_review_enabled": pull_request.get( - "require_code_owner_review" - ), + "required_approvals": pull_request.get("required_approving_review_count"), + "code_owner_review_enabled": pull_request.get("require_code_owner_review"), "conversation_resolution": pull_request.get( "required_review_thread_resolution" ), @@ -129,9 +116,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "required_checks": { "contexts": contexts, "strict": status_checks.get("strict_required_status_checks_policy"), - "do_not_enforce_on_create": status_checks.get( - "do_not_enforce_on_create" - ), + "do_not_enforce_on_create": status_checks.get("do_not_enforce_on_create"), }, } ) @@ -152,9 +137,7 @@ def _diff(expected: Any, actual: Any, path: str) -> list[str]: for key in sorted(set(expected) | set(actual)): child_path = f"{path}.{key}" if path else str(key) if key not in expected: - differences.append( - f"{child_path}: unexpected actual value {actual[key]!r}" - ) + differences.append(f"{child_path}: unexpected actual value {actual[key]!r}") elif key not in actual: differences.append( f"{child_path}: missing actual value; expected {expected[key]!r}" @@ -167,9 +150,7 @@ def _diff(expected: Any, actual: Any, path: str) -> list[str]: for index in range(max(len(expected), len(actual))): child_path = f"{path}[{index}]" if index >= len(expected): - differences.append( - f"{child_path}: unexpected actual value {actual[index]!r}" - ) + differences.append(f"{child_path}: unexpected actual value {actual[index]!r}") elif index >= len(actual): differences.append( f"{child_path}: missing actual value; expected {expected[index]!r}" @@ -214,14 +195,8 @@ def verify_rulesets( differences.append( f"{key}.bypass_actors: unavailable in API readback; cannot verify an explicit empty bypass pool" ) - expected = { - name: value - for name, value in expected.items() - if name != "bypass_actors" - } - actual = { - name: value for name, value in actual.items() if name != "bypass_actors" - } + expected = {name: value for name, value in expected.items() if name != "bypass_actors"} + actual = {name: value for name, value in actual.items() if name != "bypass_actors"} differences.extend(_diff(expected, actual, key)) managed_names = set(expected_by_name) for name in sorted(set(actual_by_name) - managed_names): @@ -234,9 +209,7 @@ def load_fixture(path: Path | str) -> list[dict[str, Any]]: with Path(path).open(encoding="utf-8") as handle: payload = json.load(handle) rulesets = payload.get("rulesets") if isinstance(payload, Mapping) else payload - if not isinstance(rulesets, list) or not all( - isinstance(item, dict) for item in rulesets - ): + if not isinstance(rulesets, list) or not all(isinstance(item, dict) for item in rulesets): raise ValueError("fixture must contain a rulesets array") return rulesets @@ -256,9 +229,7 @@ def _flatten_pages(payload: Any) -> list[dict[str, Any]]: return [item for item in payload if isinstance(item, dict)] -def load_live_rulesets( - repo: str, *, runner: Runner | None = None -) -> list[dict[str, Any]]: +def load_live_rulesets(repo: str, *, runner: Runner | None = None) -> list[dict[str, Any]]: """Read each Ruleset detail with GitHub CLI GET requests only.""" execute = runner or _run_gh listing = json.loads( @@ -281,14 +252,10 @@ def load_live_rulesets( if not isinstance(ruleset_id, int): raise ValueError("GitHub Rulesets API list response contains an invalid id") detail = json.loads( - execute( - ["gh", "api", "--method", "GET", f"repos/{repo}/rulesets/{ruleset_id}"] - ) + execute(["gh", "api", "--method", "GET", f"repos/{repo}/rulesets/{ruleset_id}"]) ) if not isinstance(detail, dict): - raise ValueError( - f"GitHub Ruleset {ruleset_id} detail response must be an object" - ) + raise ValueError(f"GitHub Ruleset {ruleset_id} detail response must be an object") details.append(detail) return details @@ -296,9 +263,7 @@ def load_live_rulesets( def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group(required=True) - source.add_argument( - "--fixture", type=Path, help="sanitized local Rulesets API fixture" - ) + source.add_argument("--fixture", type=Path, help="sanitized local Rulesets API fixture") source.add_argument( "--live", action="store_true", help="read Rulesets with gh api GET requests" ) @@ -322,11 +287,7 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) try: - rulesets = ( - load_fixture(args.fixture) - if args.fixture - else load_live_rulesets(args.repo) - ) + rulesets = load_fixture(args.fixture) if args.fixture else load_live_rulesets(args.repo) report = verify_rulesets(args.manifest_dir, rulesets) except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: report = {"ok": False, "differences": [f"verifier error: {error}"]} From 6fc5edeef00abe89d9de04bcb49ab93e484d4c92 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:50:29 +0800 Subject: [PATCH 17/32] fix(governance): reject duplicate ruleset controls --- .../ci/tests/test_verify_github_governance.py | 77 +++++++++++++++++++ scripts/ci/verify_github_governance.py | 47 +++++++---- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index a6d8cc5f8..4d15a7705 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -11,6 +11,8 @@ from pathlib import Path from types import ModuleType +import pytest + REPO_ROOT = Path(__file__).resolve().parents[3] VERIFIER = REPO_ROOT / "scripts" / "ci" / "verify_github_governance.py" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" @@ -308,6 +310,63 @@ def test_duplicate_required_status_check_rules_fail_closed(self) -> None: for difference in report["differences"] ) + @pytest.mark.parametrize( + ("ruleset_name", "rule_type"), + ( + ("Iteration 195: dev", "pull_request"), + ("Iteration 195: dev", "required_status_checks"), + ("Iteration 195: dev", "non_fast_forward"), + ("Iteration 195: dev", "deletion"), + ("Iteration 195: release tags", "creation"), + ("Iteration 195: release tags", "update"), + ("Iteration 195: release tags", "deletion"), + ), + ) + @pytest.mark.parametrize("duplicate_first", (True, False)) + @pytest.mark.parametrize("conflicting_parameters", (False, True)) + def test_duplicate_security_critical_rules_fail_closed_regardless_of_order_or_content( + self, + ruleset_name: str, + rule_type: str, + duplicate_first: bool, + conflicting_parameters: bool, + ) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + ruleset = next(rule for rule in actual if rule["name"] == ruleset_name) + source_rule = next(rule for rule in ruleset["rules"] if rule["type"] == rule_type) + duplicate_rule = copy.deepcopy(source_rule) + if conflicting_parameters: + duplicate_rule["parameters"] = {"unexpected_duplicate_parameter": True} + if duplicate_first: + ruleset["rules"].insert(0, duplicate_rule) + else: + ruleset["rules"].append(duplicate_rule) + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + manifest_key = "dev" if ruleset_name.endswith("dev") else "release-tags" + + assert report["ok"] is False + assert any( + f"{manifest_key}.readback_errors" in difference + and f"duplicate {rule_type} rules" in difference + for difference in report["differences"] + ) + + @pytest.mark.parametrize("ruleset_name", ("Iteration 195: dev", "Iteration 195: release tags")) + def test_unique_security_critical_rule_order_remains_non_semantic( + self, + ruleset_name: str, + ) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + ruleset = next(rule for rule in actual if rule["name"] == ruleset_name) + ruleset["rules"].reverse() + + report = verifier.verify_rulesets(MANIFEST_DIR, actual) + + assert report == {"ok": True, "differences": []} + def test_required_check_integration_id_is_preserved_for_identity_matching( self, ) -> None: @@ -471,3 +530,21 @@ def runner(command: list[str]) -> str: assert all( not {"POST", "PATCH", "PUT", "DELETE"}.intersection(command) for command in commands ) + + @pytest.mark.parametrize( + "listing", + ( + [{"id": 1}, "unexpected-non-object"], + [[{"id": 1}, "unexpected-non-object"]], + ), + ) + def test_live_loader_rejects_non_object_pager_entries(self, listing: object) -> None: + verifier = _load_verifier() + + def runner(command: list[str]) -> str: + if command[-1].endswith("/rulesets"): + return json.dumps(listing) + return json.dumps(_fixture()["rulesets"][0]) + + with pytest.raises(ValueError, match="non-object entry"): + verifier.load_live_rulesets("cloudQuant/backtrader_web", runner=runner) diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 4d42c5108..6c45ebf8c 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -25,13 +25,24 @@ Runner = Callable[[list[str]], str] -def _rule_by_type(rules: Any, rule_type: str) -> Mapping[str, Any]: +_BRANCH_SECURITY_CRITICAL_RULE_TYPES = ( + "pull_request", + "required_status_checks", + "non_fast_forward", + "deletion", +) +_TAG_SECURITY_CRITICAL_RULE_TYPES = ("creation", "update", "deletion") + + +def _rules_by_type(rules: Any, rule_type: str) -> list[Mapping[str, Any]]: if not isinstance(rules, list): - return {} - for rule in rules: - if isinstance(rule, Mapping) and rule.get("type") == rule_type: - return rule - return {} + return [] + return [rule for rule in rules if isinstance(rule, Mapping) and rule.get("type") == rule_type] + + +def _rule_by_type(rules: Any, rule_type: str) -> Mapping[str, Any]: + matching_rules = _rules_by_type(rules, rule_type) + return matching_rules[0] if len(matching_rules) == 1 else {} def _rule_parameters(rules: Any, rule_type: str) -> Mapping[str, Any]: @@ -45,9 +56,15 @@ def _has_rule(rules: Any, rule_type: str) -> bool: def _count_rules(rules: Any, rule_type: str) -> int: - if not isinstance(rules, list): - return 0 - return sum(1 for rule in rules if isinstance(rule, Mapping) and rule.get("type") == rule_type) + return len(_rules_by_type(rules, rule_type)) + + +def _duplicate_rule_errors(rules: Any, rule_types: Sequence[str]) -> list[str]: + return [ + f"duplicate {rule_type} rules" + for rule_type in rule_types + if _count_rules(rules, rule_type) > 1 + ] def _normalize_ref_name_patterns(value: Any, *, field: str) -> list[str]: @@ -93,8 +110,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: ), } if target == "branch": - if _count_rules(rules, "required_status_checks") > 1: - readback_errors.append("duplicate required_status_checks rules") + readback_errors.extend(_duplicate_rule_errors(rules, _BRANCH_SECURITY_CRITICAL_RULE_TYPES)) pull_request = _rule_parameters(rules, "pull_request") status_checks = _rule_parameters(rules, "required_status_checks") raw_contexts = status_checks.get("required_status_checks", []) @@ -121,6 +137,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: } ) elif target == "tag": + readback_errors.extend(_duplicate_rule_errors(rules, _TAG_SECURITY_CRITICAL_RULE_TYPES)) canonical["tag_protection"] = { "block_creation": _has_rule(rules, "creation"), "block_update": _has_rule(rules, "update"), @@ -225,8 +242,12 @@ def _flatten_pages(payload: Any) -> list[dict[str, Any]]: if not isinstance(payload, list): raise ValueError("GitHub Rulesets API list response must be an array") if payload and all(isinstance(page, list) for page in payload): - return [item for page in payload for item in page if isinstance(item, dict)] - return [item for item in payload if isinstance(item, dict)] + rulesets = [item for page in payload for item in page] + else: + rulesets = payload + if not all(isinstance(item, dict) for item in rulesets): + raise ValueError("GitHub Rulesets API list response contains a non-object entry") + return rulesets def load_live_rulesets(repo: str, *, runner: Runner | None = None) -> list[dict[str, Any]]: From e8a928ef57f94d7eb299b31306da0627e845cff1 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 03:56:54 +0800 Subject: [PATCH 18/32] docs(governance): harden review event design --- ...\217\350\277\255\344\273\243\350\256\241\345\210\222.md" | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" index e9506b4d3..779f13c1f 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" @@ -469,7 +469,7 @@ D4 解除后的 context 回填必须使用独立 allowlist 提交(仅两个分 4. R3 workflow/lock/release 变更,自动提升风险且不能靠 label 下调; 5. `hotfix/master-*` → `master` 缺 incident/前移字段时失败; 6. `release/vX.Y.Z` → `master` 缺 release 清单时失败; -7. 任意事件类型(PR 打开、同步、编辑、标签变化、review 提交/撤回)都生成同一个 `Governance Gate` check,而不是被条件跳过。 +7. 每个受信任的 `pull_request_target` 事件(PR 打开、同步、编辑、标签变化)都生成同一个 `Governance Gate` check,而不是被条件跳过。`pull_request_review` 的 submitted/edited/dismissed 不能直接加入该特权 workflow:GitHub 会从 PR merge ref 读取该类 event 的 workflow,fork 可篡改 Gate 实现。review 变化先通过维护者重跑已有受信任 Gate,或后续经独立、受信任的 GitHub App/webhook relay 接入;在真实 bridge 证据出现前,D4 保持阻塞。 **Step 2: 运行失败测试** @@ -483,7 +483,7 @@ D4 解除后的 context 回填必须使用独立 allowlist 提交(仅两个分 1. `classify_pr_risk.py` 只接收 changed-files 清单和 `.github/governance/risk-paths.json`;输出建议风险、命中规则和不可下调说明。它不从 PR label 读取风险结论。 2. `check_pr_governance.py` 校验 base/head、PR 正文治理区块、风险证据、owner/review 状态和 hotfix/release 附加契约。失败信息必须可以让贡献者修正,不能只输出“blocked”。 -3. `pr-governance.yml` 监听 `pull_request_target` 的 opened/synchronize/reopened/edited/ready_for_review/labeled/unlabeled 事件和 `pull_request_review` 的 submitted/edited/dismissed 事件。它只读取 GitHub API metadata 与以 base SHA checkout 的受信任基线配置,始终生成稳定命名的 `Governance Gate` 结果;不得触碰 PR head。若默认分支仍是 `master`,必须先完成 D0 的 `release/governance-bootstrap` PR,否则 `pull_request_target` 不会使用 `dev` 中刚合入的 workflow、脚本和 manifest。 +3. `pr-governance.yml` 只监听 `pull_request_target` 的 opened/synchronize/reopened/edited/ready_for_review/labeled/unlabeled 事件。它只读取 GitHub API metadata 与以 base SHA checkout 的受信任基线配置,始终生成稳定命名的 `Governance Gate` 结果;不得触碰 PR head。**不得**在同一 Gate workflow 加入 `pull_request_review`:该 event 使用 PR merge ref 的 workflow 文件,不能证明 Gate 实现来自受信任基线。review submitted/edited/dismissed 后由维护者重跑同一受信任 Gate;若需要自动重跑,另行引入经过安全评审的 GitHub App/webhook relay,并将其 actor、权限和实际 Check Run 证据记录为新的 D4 子门。若默认分支仍是 `master`,必须先完成 D0 的 `release/governance-bootstrap` PR,否则 `pull_request_target` 不会使用 `dev` 中刚合入的 workflow、脚本和 manifest。 4. 阴影期只发布结果/摘要,不把它设为 Ruleset required check。启用后,只有这个始终运行的聚合结果可被设为 required;不可将可能被 paths/if 跳过的 job 直接设为 required。 5. 现有 `e2e.yml` 在本阶段保持当前覆盖范围。若未来要把 R0/R1 的 e2e 改成条件执行,必须先设计一个始终报告的 `E2E Gate` 聚合 job,并用新的独立变更证明 required check 不会缺失;本迭代不得在未证明前靠 paths filter 缩减门禁。 @@ -507,7 +507,7 @@ git diff --check -- .github/workflows/pr-governance.yml \ docs/iterations/迭代195-社区PR治理与双分支协作/evidence/dry-run-matrix.md ``` -在内部草稿 PR 中演练 3 个情形:普通 R1 → `dev`、R2 → `dev`、`hotfix/master-*` → `master`。把 PR URL、commit SHA、实际 check display name、事件重跑行为、所请求的 review 与可读阻断消息写入 `evidence/dry-run-matrix.md`。D4 决策人据此解除或继续阻塞 D4;只有解除后才能返回 Task 3 Step 5 回填并应用 required checks。 +在内部草稿 PR 中演练 3 个情形:普通 R1 → `dev`、R2 → `dev`、`hotfix/master-*` → `master`。把 PR URL、commit SHA、实际 check display name、受信任 `pull_request_target` 事件重跑行为、review 后维护者重跑(或已批准 GitHub App/webhook relay)的证据、所请求的 review 与可读阻断消息写入 `evidence/dry-run-matrix.md`。D4 决策人据此解除或继续阻塞 D4;只有解除后才能返回 Task 3 Step 5 回填并应用 required checks。 **退出条件:** 没有 PR 因 skipped check 永久等待;目标分支和风险分级不能靠作者字段绕过;fork PR 不需写 token 或 secrets 即可获得治理反馈。 From cfe4d0491fdf7162e47c504c627f466350ad9c91 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 04:09:37 +0800 Subject: [PATCH 19/32] feat(ci): add stable pull request governance gate --- .github/workflows/pr-governance.yml | 48 ++- .../evidence/dry-run-matrix.md" | 23 ++ scripts/ci/check_pr_governance.py | 363 +++++++++++++++++ scripts/ci/classify_pr_risk.py | 98 +++++ scripts/ci/tests/test_check_pr_governance.py | 366 ++++++++++++++++++ scripts/ci/tests/test_classify_pr_risk.py | 106 +++++ .../test_workflow_governance_contract.py | 95 ++++- 7 files changed, 1070 insertions(+), 29 deletions(-) create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" create mode 100644 scripts/ci/check_pr_governance.py create mode 100644 scripts/ci/classify_pr_risk.py create mode 100644 scripts/ci/tests/test_check_pr_governance.py create mode 100644 scripts/ci/tests/test_classify_pr_risk.py diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 89c4da228..49a8b02c1 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -1,22 +1,48 @@ -name: PR Governance (Bootstrap) +name: PR Governance -# This read-only bootstrap remains inactive until the equivalent workflow and -# its trusted governance implementation are promoted to the default branch. +# The trusted workflow definition comes from the base/default branch for +# pull_request_target. It deliberately does not subscribe to review events: +# their workflow file can come from an untrusted PR merge ref. Maintainers +# re-run this safe gate after a review state changes. on: pull_request_target: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] permissions: contents: read pull-requests: read -# This skeleton deliberately has no actions. Task 4 will add the trusted-base -# governance check after bootstrap. jobs: - governance-bootstrap: - name: Governance bootstrap pending default-branch promotion + governance-gate: + name: Governance Gate runs-on: ubuntu-latest - if: ${{ github.event.pull_request.number > 0 }} steps: - - name: Report bootstrap status - run: echo "Read-only governance bootstrap; awaiting default-branch promotion." + - name: Checkout trusted base policy + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Read pull request metadata + env: + GH_TOKEN: ${{ github.token }} + PR_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GOVERNANCE_TMP: ${{ runner.temp }}/pr-governance + run: | + mkdir -p "$GOVERNANCE_TMP" + gh api --method GET "repos/$PR_REPOSITORY/pulls/$PR_NUMBER" > "$GOVERNANCE_TMP/pr.json" + gh api --method GET --paginate --slurp "repos/$PR_REPOSITORY/pulls/$PR_NUMBER/reviews" > "$GOVERNANCE_TMP/reviews.json" + gh api --method GET --paginate --slurp "repos/$PR_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" > "$GOVERNANCE_TMP/files.json" + + - name: Evaluate trusted governance policy + env: + GOVERNANCE_TMP: ${{ runner.temp }}/pr-governance + run: | + python scripts/ci/check_pr_governance.py \ + --pr "$GOVERNANCE_TMP/pr.json" \ + --reviews "$GOVERNANCE_TMP/reviews.json" \ + --changed-files "$GOVERNANCE_TMP/files.json" \ + --risk-map .github/governance/risk-paths.json \ + --manifest-dir .github/governance/rulesets diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" new file mode 100644 index 000000000..178ea06f0 --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" @@ -0,0 +1,23 @@ +# D4 PR Governance 演练矩阵(模板) + +状态:**尚未执行;D4 仍为 blocked。** 本文件只定义真实草稿 PR 演练所需的记录字段,不能 +作为 PR URL、commit SHA、Check Run context 或远端 Ruleset 已启用的证据。 + +`PR Governance` 只运行受信任的 `pull_request_target` 事件:`opened`、`synchronize`、 +`reopened`、`edited`、`ready_for_review`、`labeled`、`unlabeled`。它不会订阅 +`pull_request_review`;该事件的 workflow 文件可来自 PR merge ref,不能作为受信任 Gate。 +review submitted、edited 或 dismissed 后,由维护者重新运行已有的受信任 `Governance Gate`。 +若将来需要自动重跑,必须先单独评审 GitHub App/webhook relay 的 actor、权限与实际 Check +Run 证据;该 relay 未获批前不得作为 D4 通过依据。 + +| 场景 | 来源 → 目标 | 预期路径风险/结果 | 草稿 PR URL | base/head SHA | 实际 display name / context | 事件与重跑观察 | review 与可读诊断 | 记录人/时间 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 常规 R1 | `feature/*` → `dev` | R1;完整声明、1 个非作者批准后通过 | 待真实演练 | 待真实演练 | 待实际 Check Run 读取 | 逐项记录受信任事件;review 后由维护者重跑 | 待真实演练 | 待填写 | +| R2 | `feature/*` → `dev`(auth/router/store) | R2;不可被 label 下调,需测试证据及可读保护性 review/request | 待真实演练 | 待真实演练 | 待实际 Check Run 读取 | 逐项记录受信任事件;review 后由维护者重跑 | 待真实演练 | 待填写 | +| master hotfix | `hotfix/master-*` → `master` | 缺 incident/前移计划时给出修复诊断;完整资料、2 个非作者批准后才通过 | 待真实演练 | 待真实演练 | 待实际 Check Run 读取 | 逐项记录受信任事件;review 后由维护者重跑 | 待真实演练 | 待填写 | + +完成三项真实演练前: + +- 不得把 `Governance Gate` 填入 `dev.json` 或 `master.json` 的 required-check context; +- 不得将本地 workflow job id 视为 GitHub 实际 Check Run context; +- 不得把 shadow 观察期、D3 Ruleset 应用或 D6 tag/release-environment 状态标为完成。 diff --git a/scripts/ci/check_pr_governance.py b/scripts/ci/check_pr_governance.py new file mode 100644 index 000000000..ef7d3e165 --- /dev/null +++ b/scripts/ci/check_pr_governance.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Evaluate the trusted-base Iteration 195 PR governance contract locally. + +This checker is intentionally standard-library-only. It consumes JSON that +the read-only workflow saved from GitHub's metadata APIs and never executes, +checks out, or imports pull-request head content. +""" + +from __future__ import annotations + +import argparse +import json +import re +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from check_pr_template import governance_declaration_issues +from classify_pr_risk import changed_file_paths, classify_changed_files +from governance_contract import ( + HOTFIX_BRANCH, + RELEASE_BRANCH, + RISK_ORDER, + load_manifests, + load_risk_map, + validate_branch_contract, +) + +_FIELD_LINE = r"^\s*[-*]\s*(?:\*\*)?{label}(?:\*\*)?\s*[::]\s*(?P[^\n]*)$" +_INCIDENT_RE = re.compile(r"\binc[- #]?\d+\b|\bincident\b|事故|披露", re.IGNORECASE) +_FORWARD_PORT_RE = re.compile(r"\bdev\b|forward[- ]?port|backport|前移", re.IGNORECASE) +_TARGET_RE = re.compile(r"\b(dev|master)\b", re.IGNORECASE) +_RISK_RE = re.compile(r"\b(R[0-3])\b", re.IGNORECASE) + + +def _mapping(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be a JSON object") + return value + + +def _login(value: Any) -> str | None: + if isinstance(value, Mapping): + login = value.get("login") + if isinstance(login, str) and login.strip(): + return login.strip() + return None + + +def _field_value(body: str, label: str) -> str | None: + pattern = re.compile(_FIELD_LINE.format(label=re.escape(label)), re.IGNORECASE | re.MULTILINE) + match = pattern.search(body) + if not match: + return None + value = match.group("value").strip().strip("`").rstrip("。.") + return value or None + + +def _declared_target(body: str) -> str | None: + value = _field_value(body, "目标分支") + match = _TARGET_RE.search(value or "") + return match.group(1).casefold() if match else None + + +def _declared_risk(body: str) -> str | None: + value = _field_value(body, "风险等级") + match = _RISK_RE.search(value or "") + return match.group(1).upper() if match else None + + +def _pr_kind(base_branch: str, head_branch: str) -> str: + if base_branch == "master" and HOTFIX_BRANCH.fullmatch(head_branch): + return "hotfix" + if base_branch == "master" and RELEASE_BRANCH.fullmatch(head_branch): + return "release" + return "normal" + + +def _branch_evidence(body: str, kind: str) -> dict[str, str]: + """Derive the branch-contract evidence that the current template exposes.""" + test_evidence = _field_value(body, "测试证据") or "" + hotfix_plan = _field_value(body, "前移计划") or "" + release_checklist = _field_value(body, "Release 清单") or "" + evidence: dict[str, str] = {} + if kind == "hotfix": + if _INCIDENT_RE.search(hotfix_plan): + evidence["incident"] = hotfix_plan + if _FORWARD_PORT_RE.search(hotfix_plan): + evidence["forward_port_plan"] = hotfix_plan + if kind == "release": + if release_checklist: + evidence["release_checklist"] = release_checklist + if test_evidence: + evidence["release_validation"] = test_evidence + return evidence + + +def _labels(pr: Mapping[str, Any]) -> list[str]: + raw_labels = pr.get("labels", []) + if not isinstance(raw_labels, list): + return [] + labels: list[str] = [] + for entry in raw_labels: + if isinstance(entry, str) and entry.strip(): + labels.append(entry.strip()) + elif isinstance(entry, Mapping): + name = entry.get("name") + if isinstance(name, str) and name.strip(): + labels.append(name.strip()) + return sorted(set(labels)) + + +def _requested_reviewer_logins(pr: Mapping[str, Any], author: str) -> list[str]: + requested = pr.get("requested_reviewers", []) + if not isinstance(requested, list): + return [] + return sorted( + { + login + for item in requested + if (login := _login(item)) is not None and login.casefold() != author.casefold() + } + ) + + +def _review_timestamp(value: Any, position: int) -> tuple[datetime, int]: + if isinstance(value, str) and value.strip(): + timestamp = value.strip() + if timestamp.endswith("Z"): + timestamp = f"{timestamp[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(timestamp) + if parsed.tzinfo is not None: + return parsed.astimezone(timezone.utc), position + except ValueError: + pass + return datetime.min.replace(tzinfo=timezone.utc), position + + +def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, str]: + """Return each non-author reviewer's latest normalized review state.""" + latest: dict[str, tuple[tuple[datetime, int], str]] = {} + for position, review in enumerate(reviews): + if not isinstance(review, Mapping): + continue + login = _login(review.get("user")) + if login is None or login.casefold() == author.casefold(): + continue + state = review.get("state") + if not isinstance(state, str) or not state.strip(): + continue + key = login.casefold() + candidate = (_review_timestamp(review.get("submitted_at"), position), state.strip().upper()) + if key not in latest or candidate[0] >= latest[key][0]: + latest[key] = candidate + return {login: state for login, (_, state) in sorted(latest.items())} + + +def _review_summary(reviews: Sequence[Any], author: str) -> dict[str, list[str]]: + latest = _latest_reviews(reviews, author) + approvals = sorted(login for login, state in latest.items() if state == "APPROVED") + changes_requested = sorted( + login for login, state in latest.items() if state == "CHANGES_REQUESTED" + ) + return { + "effective_approvals": approvals, + "changes_requested_by": changes_requested, + "reviewers": sorted(latest), + } + + +def _code_owner_status(manifest: Mapping[str, Any]) -> dict[str, Any]: + pull_request = manifest.get("pull_request") + if not isinstance(pull_request, Mapping): + return {"enabled": False, "status": "manifest_invalid", "verified": False} + code_owner = pull_request.get("code_owner_review") + if not isinstance(code_owner, Mapping): + return {"enabled": False, "status": "manifest_invalid", "verified": False} + enabled = code_owner.get("enabled") is True + if not enabled: + return {"enabled": False, "status": "disabled_pending_D2", "verified": False} + return { + "enabled": True, + "status": "ruleset_required_not_verified_by_gate", + "verified": False, + } + + +def _branch_manifest( + manifests: Mapping[str, Mapping[str, Any]], base_branch: str +) -> Mapping[str, Any] | None: + manifest = manifests.get(base_branch) + return manifest if isinstance(manifest, Mapping) else None + + +def evaluate_pr_governance( + pr: Mapping[str, Any], + reviews: Sequence[Any], + changed_files: Sequence[str], + *, + risk_map: Mapping[str, Any], + manifests: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + """Return an actionable, deterministic Governance Gate result. + + This function operates entirely on supplied data. The caller owns safe + collection of trusted-base configuration and read-only PR metadata. + """ + pr_data = _mapping(pr, "PR metadata") + base = _mapping(pr_data.get("base"), "PR base") + head = _mapping(pr_data.get("head"), "PR head") + author_data = _mapping(pr_data.get("user"), "PR author") + base_branch = base.get("ref") + head_branch = head.get("ref") + author = _login(author_data) + body = pr_data.get("body") + if not isinstance(base_branch, str) or not base_branch.strip(): + raise ValueError("PR base.ref must be a non-empty string") + if not isinstance(head_branch, str) or not head_branch.strip(): + raise ValueError("PR head.ref must be a non-empty string") + if author is None: + raise ValueError("PR user.login must be a non-empty string") + if not isinstance(body, str): + raise ValueError("PR body must be a string") + if not isinstance(reviews, Sequence) or isinstance(reviews, (str, bytes)): + raise ValueError("reviews must be a JSON list") + + base_branch = base_branch.strip() + head_branch = head_branch.strip() + kind = _pr_kind(base_branch, head_branch) + classification = classify_changed_files(changed_files, risk_map) + labels = _labels(pr_data) + manifest = _branch_manifest(manifests, base_branch) + approval_floor: int | None = None + if manifest is not None: + pull_request = _mapping( + manifest.get("pull_request"), f"{base_branch} manifest pull_request" + ) + approval_floor = pull_request.get("required_approvals") + if not isinstance(approval_floor, int) or isinstance(approval_floor, bool): + raise ValueError(f"{base_branch} manifest required_approvals must be an integer") + + issues = list(governance_declaration_issues(body, kind)) + declared_target = _declared_target(body) + if declared_target != base_branch: + issues.append( + f"governance declaration target branch {declared_target!r} does not match actual " + f"PR base {base_branch!r}; update the declaration or retarget the PR" + ) + + declared_risk = _declared_risk(body) + computed_risk = str(classification["risk"]) + if declared_risk not in RISK_ORDER: + issues.append( + f"governance declaration must name R0-R3; changed paths require {computed_risk}" + ) + elif RISK_ORDER[declared_risk] < RISK_ORDER[computed_risk]: + issues.append( + f"governance declaration declares {declared_risk} but changed paths require " + f"{computed_risk}; labels cannot lower path-derived risk" + ) + + issues.extend(validate_branch_contract(base_branch, head_branch, _branch_evidence(body, kind))) + if base_branch == "master" and kind == "normal": + issues.append("regular contribution branches must target dev; retarget this PR to dev") + + review_summary = _review_summary(reviews, author) + effective_approvals = review_summary["effective_approvals"] + changes_requested_by = review_summary["changes_requested_by"] + if approval_floor is not None and len(effective_approvals) < approval_floor: + issues.append( + f"{base_branch} requires {approval_floor} non-author approvals but has " + f"{len(effective_approvals)}; request an additional maintainer review" + ) + for reviewer in changes_requested_by: + issues.append( + f"latest review from {reviewer} is CHANGES_REQUESTED; resolve it before merge" + ) + + requested_reviewers = _requested_reviewer_logins(pr_data, author) + protective_reviewers = sorted(set(review_summary["reviewers"]) | set(requested_reviewers)) + if computed_risk in {"R2", "R3"}: + if not _field_value(body, "测试证据"): + issues.append( + f"{computed_risk} change requires concrete test evidence in the governance declaration" + ) + if not protective_reviewers: + issues.append( + f"{computed_risk} change requires a readable protective reviewer or " + "requested-reviewer record; request a maintainer review" + ) + + code_owner_review = ( + _code_owner_status(manifest) + if manifest is not None + else {"enabled": False, "status": "unsupported_target", "verified": False} + ) + return { + "ok": not issues, + "pr_kind": kind, + "base_branch": base_branch, + "head_branch": head_branch, + "risk": computed_risk, + "matched_rules": classification["matches"], + "labels_can_lower_risk": False, + "ignored_labels": labels, + "approval_floor": approval_floor, + "effective_approvals": effective_approvals, + "changes_requested_by": changes_requested_by, + "protective_reviewers": protective_reviewers, + "code_owner_review": code_owner_review, + "issues": issues, + } + + +def _load_json(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def _flatten_pages(value: Any) -> list[Any]: + if not isinstance(value, list): + raise ValueError("reviews payload must be a JSON list") + entries: list[Any] = [] + for item in value: + if isinstance(item, list): + entries.extend(_flatten_pages(item)) + else: + entries.append(item) + return entries + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pr", required=True, type=Path) + parser.add_argument("--reviews", required=True, type=Path) + parser.add_argument("--changed-files", required=True, type=Path) + parser.add_argument("--risk-map", required=True, type=Path) + parser.add_argument("--manifest-dir", required=True, type=Path) + return parser.parse_args() + + +def main() -> int: + """Evaluate local inputs and use the exit status only as a gate result.""" + args = _parse_args() + try: + result = evaluate_pr_governance( + _load_json(args.pr), + _flatten_pages(_load_json(args.reviews)), + changed_file_paths(_load_json(args.changed_files)), + risk_map=load_risk_map(args.risk_map), + manifests=load_manifests(args.manifest_dir), + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(json.dumps({"ok": False, "issues": [str(error)]}, ensure_ascii=False)) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/classify_pr_risk.py b/scripts/ci/classify_pr_risk.py new file mode 100644 index 000000000..7a5897b36 --- /dev/null +++ b/scripts/ci/classify_pr_risk.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Classify local changed-file JSON with the Iteration 195 risk map. + +The module deliberately has no GitHub client, environment-token handling, or +PR-label input. The trusted workflow obtains metadata separately, then passes +only a local changed-files payload to this deterministic classifier. +""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from governance_contract import classify_paths, load_risk_map + + +def _flatten_payload(value: Any) -> list[Any]: + """Flatten GitHub API ``--slurp`` pages into individual file entries.""" + if not isinstance(value, list): + raise ValueError("changed-files payload must be a JSON list") + entries: list[Any] = [] + for item in value: + if isinstance(item, list): + entries.extend(_flatten_payload(item)) + else: + entries.append(item) + return entries + + +def changed_file_paths(payload: Any) -> list[str]: + """Return repository-relative paths from GitHub files API JSON. + + A local fixture may use a plain list of paths. The live workflow uses the + GitHub REST representation with a ``filename`` field. No arbitrary PR + fields are accepted here because path classification must remain isolated. + """ + paths: list[str] = [] + for entry in _flatten_payload(payload): + if isinstance(entry, str) and entry.strip(): + paths.append(entry.strip()) + continue + if isinstance(entry, Mapping): + filename = entry.get("filename") + if isinstance(filename, str) and filename.strip(): + paths.append(filename.strip()) + continue + raise ValueError("each changed-files entry must be a path string or filename object") + return paths + + +def load_changed_file_paths(path: Path | str) -> list[str]: + """Load a local JSON changed-files payload.""" + with Path(path).open(encoding="utf-8") as handle: + return changed_file_paths(json.load(handle)) + + +def classify_changed_files( + changed_files: Sequence[str], risk_map: Mapping[str, Any] +) -> dict[str, Any]: + """Return highest path-derived risk without any label downgrade channel.""" + classified = classify_paths(changed_files, risk_map) + return { + "risk": classified["risk"], + "matches": classified["matches"], + "labels_can_lower_risk": False, + "downgrade_policy": ( + "PR labels and contributor declarations may request stricter review but cannot " + "lower path-derived risk." + ), + } + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--changed-files", required=True, type=Path) + parser.add_argument("--risk-map", required=True, type=Path) + return parser.parse_args() + + +def main() -> int: + """Print a stable JSON classification for a local payload.""" + args = _parse_args() + try: + result = classify_changed_files( + load_changed_file_paths(args.changed_files), load_risk_map(args.risk_map) + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(json.dumps({"ok": False, "issues": [str(error)]}, ensure_ascii=False)) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/tests/test_check_pr_governance.py b/scripts/ci/tests/test_check_pr_governance.py new file mode 100644 index 000000000..d034392b6 --- /dev/null +++ b/scripts/ci/tests/test_check_pr_governance.py @@ -0,0 +1,366 @@ +"""Local fixtures for the trusted-base Iteration 195 Governance Gate.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPTS_CI = REPO_ROOT / "scripts" / "ci" +CHECKER_PATH = SCRIPTS_CI / "check_pr_governance.py" +RISK_MAP_PATH = REPO_ROOT / ".github" / "governance" / "risk-paths.json" +MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" + + +def _load_checker() -> ModuleType: + assert CHECKER_PATH.is_file(), "Task 4 Governance Gate checker is not implemented" + sys.path.insert(0, str(SCRIPTS_CI)) + try: + spec = importlib.util.spec_from_file_location( + "check_pr_governance_under_test", CHECKER_PATH + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def _risk_map() -> dict[str, object]: + return json.loads(RISK_MAP_PATH.read_text(encoding="utf-8")) + + +def _manifests(checker: ModuleType) -> dict[str, dict[str, object]]: + return checker.load_manifests(MANIFEST_DIR) + + +def _body( + *, + target: str, + risk: str, + test_evidence: str = "pytest scripts/ci/tests/test_check_pr_governance.py -q passed", + hotfix_plan: str | None = None, + release_checklist: str | None = None, +) -> str: + body = ( + "## Governance declaration\n\n" + f"- **目标分支**: {target}\n" + f"- **风险等级**: {risk}\n" + f"- **测试证据**: {test_evidence}\n" + ) + if hotfix_plan is not None: + body += f"\n## Hotfix 前移计划\n\n- **前移计划**: {hotfix_plan}\n" + if release_checklist is not None: + body += f"\n## Release 清单\n\n- **Release 清单**: {release_checklist}\n" + return body + + +def _pr( + *, + base: str, + head: str, + body: str, + author: str = "contributor", + labels: tuple[str, ...] = (), + requested_reviewers: tuple[str, ...] = (), +) -> dict[str, object]: + return { + "number": 195, + "base": {"ref": base}, + "head": {"ref": head}, + "user": {"login": author}, + "body": body, + "labels": [{"name": label} for label in labels], + "requested_reviewers": [{"login": login} for login in requested_reviewers], + } + + +def _review(login: str, state: str, submitted_at: str) -> dict[str, object]: + return { + "user": {"login": login}, + "state": state, + "submitted_at": submitted_at, + } + + +def _evaluate( + checker: ModuleType, + pr: dict[str, object], + reviews: list[dict[str, object]], + changed_files: list[str], +) -> dict[str, object]: + return checker.evaluate_pr_governance( + pr, + reviews, + changed_files, + risk_map=_risk_map(), + manifests=_manifests(checker), + ) + + +def test_feature_to_dev_r1_with_complete_evidence_and_approval_passes() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is True + assert result["pr_kind"] == "normal" + assert result["risk"] == "R1" + assert result["approval_floor"] == 1 + assert result["effective_approvals"] == ["maintainer"] + assert result["code_owner_review"]["status"] == "disabled_pending_D2" + assert result["issues"] == [] + + +def test_declared_target_must_match_actual_pr_base() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="master", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any("does not match actual PR base 'dev'" in issue for issue in result["issues"]) + + +def test_feature_to_master_is_blocked_with_the_dev_routing_remedy() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="feature/market-screen", + body=_body(target="master", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "master only accepts release/vX.Y.Z or hotfix/master-*" in issue + for issue in result["issues"] + ) + assert any("target dev" in issue for issue in result["issues"]) + + +def test_unsupported_target_branch_returns_a_retargeting_diagnostic() -> None: + checker = _load_checker() + pr = _pr( + base="staging", + head="feature/market-screen", + body=_body(target="staging", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any("unsupported target branch: staging" in issue for issue in result["issues"]) + + +def test_r2_auth_change_requires_matching_declaration_and_protective_record() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/auth-refresh", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [], + ["src/backend/app/api/auth.py"], + ) + + assert result["risk"] == "R2" + assert result["labels_can_lower_risk"] is False + assert any("declares R1 but changed paths require R2" in issue for issue in result["issues"]) + assert any( + "protective reviewer or requested-reviewer record" in issue for issue in result["issues"] + ) + assert result["code_owner_review"]["verified"] is False + + +def test_r3_changes_cannot_be_downgraded_by_a_label() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/ci-cleanup", + body=_body(target="dev", risk="R1"), + labels=("risk:R0",), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + [".github/workflows/ci.yml", "config/requirements-dev.lock"], + ) + + assert result["risk"] == "R3" + assert result["labels_can_lower_risk"] is False + assert result["ignored_labels"] == ["risk:R0"] + assert any("declares R1 but changed paths require R3" in issue for issue in result["issues"]) + + +def test_hotfix_missing_incident_and_forward_port_fields_is_actionably_rejected() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="hotfix/master-session-expiry", + body=_body(target="master", risk="R3"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/api/auth.py"], + ) + + assert result["pr_kind"] == "hotfix" + assert result["ok"] is False + assert any("incident or private disclosure record" in issue for issue in result["issues"]) + assert any("forward-port plan for dev" in issue for issue in result["issues"]) + + +def test_release_missing_checklist_is_rejected() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body(target="master", risk="R3"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["pr_kind"] == "release" + assert result["ok"] is False + assert any("Release 清单" in issue for issue in result["issues"]) + + +def test_latest_non_author_review_state_controls_approval_floor_and_blocks_changes_requested() -> ( + None +): + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("contributor", "APPROVED", "2026-08-24T00:00:00Z"), + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-a", "CHANGES_REQUESTED", "2026-08-24T00:03:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["effective_approvals"] == ["maintainer-b"] + assert result["changes_requested_by"] == ["maintainer-a"] + assert any( + "latest review from maintainer-a is CHANGES_REQUESTED" in issue + for issue in result["issues"] + ) + assert any("requires 2 non-author approvals but has 1" in issue for issue in result["issues"]) + + +def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result(tmp_path: Path) -> None: + pr_path = tmp_path / "pr.json" + reviews_path = tmp_path / "reviews.json" + files_path = tmp_path / "files.json" + pr_path.write_text( + json.dumps( + _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + ), + encoding="utf-8", + ) + reviews_path.write_text( + json.dumps([_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")]), + encoding="utf-8", + ) + files_path.write_text( + json.dumps([{"filename": "src/backend/app/services/market_data.py"}]), + encoding="utf-8", + ) + + completed = subprocess.run( + [ + sys.executable, + str(CHECKER_PATH), + "--pr", + str(pr_path), + "--reviews", + str(reviews_path), + "--changed-files", + str(files_path), + "--risk-map", + str(RISK_MAP_PATH), + "--manifest-dir", + str(MANIFEST_DIR), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + result = json.loads(completed.stdout) + assert result["ok"] is True + assert result["risk"] == "R1" diff --git a/scripts/ci/tests/test_classify_pr_risk.py b/scripts/ci/tests/test_classify_pr_risk.py new file mode 100644 index 000000000..b91ed3846 --- /dev/null +++ b/scripts/ci/tests/test_classify_pr_risk.py @@ -0,0 +1,106 @@ +"""Tests for the local-only Iteration 195 PR path-risk classifier.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPTS_CI = REPO_ROOT / "scripts" / "ci" +CLASSIFIER_PATH = SCRIPTS_CI / "classify_pr_risk.py" +RISK_MAP_PATH = REPO_ROOT / ".github" / "governance" / "risk-paths.json" + + +def _load_classifier() -> ModuleType: + assert CLASSIFIER_PATH.is_file(), "Task 4 risk classifier is not implemented" + sys.path.insert(0, str(SCRIPTS_CI)) + try: + spec = importlib.util.spec_from_file_location( + "classify_pr_risk_under_test", CLASSIFIER_PATH + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def _risk_map() -> dict[str, object]: + return json.loads(RISK_MAP_PATH.read_text(encoding="utf-8")) + + +def test_unmapped_behavior_change_is_r1_and_has_no_label_downgrade_path() -> None: + classifier = _load_classifier() + + result = classifier.classify_changed_files( + ["src/backend/app/services/market_data.py"], _risk_map() + ) + + assert result["risk"] == "R1" + assert result["matches"] == [] + assert result["labels_can_lower_risk"] is False + assert "label" in result["downgrade_policy"].casefold() + + +def test_auth_router_and_store_changes_raise_risk_to_r2() -> None: + classifier = _load_classifier() + + result = classifier.classify_changed_files( + [ + "src/backend/app/api/auth.py", + "src/frontend/src/router/index.ts", + "src/frontend/src/stores/auth.ts", + ], + _risk_map(), + ) + + assert result["risk"] == "R2" + assert {match["level"] for match in result["matches"]} == {"R2"} + assert result["labels_can_lower_risk"] is False + + +def test_workflow_lock_and_release_changes_raise_risk_to_r3() -> None: + classifier = _load_classifier() + + result = classifier.classify_changed_files( + [ + ".github/workflows/ci.yml", + "config/requirements-dev.lock", + "scripts/ops/release.sh", + ], + _risk_map(), + ) + + assert result["risk"] == "R3" + assert {match["level"] for match in result["matches"]} == {"R3"} + assert result["labels_can_lower_risk"] is False + + +def test_cli_reads_only_a_local_changed_files_payload_and_risk_map(tmp_path: Path) -> None: + payload = tmp_path / "changed-files.json" + payload.write_text(json.dumps([{"filename": "src/backend/app/api/auth.py"}]), encoding="utf-8") + + completed = subprocess.run( + [ + sys.executable, + str(CLASSIFIER_PATH), + "--changed-files", + str(payload), + "--risk-map", + str(RISK_MAP_PATH), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + result = json.loads(completed.stdout) + assert result["risk"] == "R2" + assert result["labels_can_lower_risk"] is False diff --git a/scripts/ci/tests/test_workflow_governance_contract.py b/scripts/ci/tests/test_workflow_governance_contract.py index b101f35e5..9b655b983 100644 --- a/scripts/ci/tests/test_workflow_governance_contract.py +++ b/scripts/ci/tests/test_workflow_governance_contract.py @@ -138,42 +138,100 @@ def test_pr_check_actions_are_sha_pinned_with_version_comments(self) -> None: class TestPrGovernanceWorkflowContract: - def test_governance_uses_only_pull_request_target(self) -> None: - assert _event_issues(_read(PR_GOVERNANCE), {"pull_request_target"}) == [] + EXPECTED_EVENTS = { + "opened", + "synchronize", + "reopened", + "edited", + "ready_for_review", + "labeled", + "unlabeled", + } + + def _governance_event(self) -> dict[str, object]: + workflow = _workflow_mapping(_read(PR_GOVERNANCE)) + events = workflow.get("on") + assert isinstance(events, dict) + event = events.get("pull_request_target") + assert isinstance(event, dict) + return event + + def test_governance_uses_only_trusted_pull_request_target_events(self) -> None: + workflow = _read(PR_GOVERNANCE) + + assert _event_issues(workflow, {"pull_request_target"}) == [] + assert set(self._governance_event().get("types", [])) == self.EXPECTED_EVENTS + assert "pull_request_review:" not in workflow - def test_governance_rejects_additional_trigger(self) -> None: + def test_governance_rejects_a_pull_request_review_trigger(self) -> None: workflow = _read(PR_GOVERNANCE).replace( - " types: [opened, synchronize, reopened]\n", - " types: [opened, synchronize, reopened]\n issue_comment:\n", - 1, + " pull_request_target:\n", " pull_request_review:\n", 1 ) assert _event_issues(workflow, {"pull_request_target"}) - def test_governance_rejects_job_level_flow_mapping_write_permission(self) -> None: + def test_governance_rejects_job_level_write_permissions(self) -> None: workflow = _read(PR_GOVERNANCE).replace( - " governance-bootstrap:\n", - " governance-bootstrap:\n permissions: {contents: write}\n", + " governance-gate:\n", + " governance-gate:\n permissions: {contents: write}\n", 1, ) assert _permission_issues(workflow) - def test_governance_workflow_is_a_read_only_metadata_bootstrap(self) -> None: + def test_governance_workflow_is_read_only_and_has_an_always_running_gate(self) -> None: workflow = _read(PR_GOVERNANCE) + parsed = _workflow_mapping(workflow) + jobs = parsed.get("jobs") - assert re.search(r"^\s{2}pull_request_target:\s*$", workflow, re.MULTILINE) + assert parsed.get("name") == "PR Governance" assert _top_level_permissions(workflow) == { "contents": "read", "pull-requests": "read", } assert _permission_issues(workflow) == [] + assert isinstance(jobs, dict) + assert set(jobs) == {"governance-gate"} + gate = jobs["governance-gate"] + assert isinstance(gate, dict) + assert gate.get("name") == "Governance Gate" + assert "if" not in gate + assert "paths" not in self._governance_event() + assert "paths-ignore" not in self._governance_event() + + def test_governance_checkout_is_pinned_to_the_trusted_base_sha_without_credentials( + self, + ) -> None: + workflow = _read(PR_GOVERNANCE) + parsed = _workflow_mapping(workflow) + jobs = parsed["jobs"] + assert isinstance(jobs, dict) + gate = jobs["governance-gate"] + assert isinstance(gate, dict) + steps = gate.get("steps") + assert isinstance(steps, list) + checkout_steps = [ + step + for step in steps + if isinstance(step, dict) and str(step.get("uses", "")).startswith("actions/checkout@") + ] - def test_governance_workflow_does_not_touch_untrusted_pr_code_or_write_state(self) -> None: + assert len(checkout_steps) == 1 + checkout = checkout_steps[0] + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", str(checkout["uses"]).split()[0]) + checkout_with = checkout.get("with") + assert isinstance(checkout_with, dict) + assert checkout_with.get("ref") == "${{ github.event.pull_request.base.sha }}" + assert checkout_with.get("persist-credentials") == "false" + assert "github.event.pull_request.head" not in workflow + + def test_governance_uses_only_read_only_metadata_requests_and_standard_library_checker( + self, + ) -> None: workflow = _read(PR_GOVERNANCE).lower() for forbidden in ( - "checkout", + "pull_request_review:", "github.event.pull_request.head", "secrets.", "pip install", @@ -183,11 +241,12 @@ def test_governance_workflow_does_not_touch_untrusted_pr_code_or_write_state(sel "addlabels", "removelabel", "github-script", - "check_pr_governance.py", + "gh api --method post", + "gh api --method patch", + "gh api --method put", + "gh api --method delete", ): assert forbidden not in workflow - assert ( - 'run: echo "read-only governance bootstrap; awaiting default-branch promotion."' - in workflow - ) + assert workflow.count("gh api --method get") == 3 + assert "check_pr_governance.py" in workflow From dfd86942d9f416c387fb7f3dbf818302fe4c30f7 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 04:45:46 +0800 Subject: [PATCH 20/32] fix(ci): harden pull request governance gate --- scripts/ci/check_pr_governance.py | 341 ++++++- scripts/ci/classify_pr_risk.py | 19 +- scripts/ci/tests/test_check_pr_governance.py | 892 +++++++++++++++++- scripts/ci/tests/test_classify_pr_risk.py | 60 ++ .../test_workflow_governance_contract.py | 215 +++++ 5 files changed, 1450 insertions(+), 77 deletions(-) diff --git a/scripts/ci/check_pr_governance.py b/scripts/ci/check_pr_governance.py index ef7d3e165..fdf9a5fc0 100644 --- a/scripts/ci/check_pr_governance.py +++ b/scripts/ci/check_pr_governance.py @@ -25,13 +25,43 @@ load_manifests, load_risk_map, validate_branch_contract, + validate_manifests, ) _FIELD_LINE = r"^\s*[-*]\s*(?:\*\*)?{label}(?:\*\*)?\s*[::]\s*(?P[^\n]*)$" -_INCIDENT_RE = re.compile(r"\binc[- #]?\d+\b|\bincident\b|事故|披露", re.IGNORECASE) -_FORWARD_PORT_RE = re.compile(r"\bdev\b|forward[- ]?port|backport|前移", re.IGNORECASE) +_GOVERNANCE_HEADING_RE = re.compile( + r"^##[ \t]+Governance declaration[ \t]*$", re.IGNORECASE | re.MULTILINE +) +_HOTFIX_HEADING_RE = re.compile(r"^##[ \t]+Hotfix[ \t]+前移计划(?:[ \t]+.*)?$", re.MULTILINE) +_RELEASE_HEADING_RE = re.compile(r"^##[ \t]+Release[ \t]+清单(?:[ \t]+.*)?$", re.MULTILINE) +_HTML_COMMENT_RE = re.compile(r"|$)", re.DOTALL) +_FENCE_RE = re.compile(r"^[ \t]{0,3}(?P`{3,}|~{3,})[^\n]*$") +_INCIDENT_IDENTIFIER_RE = re.compile( + r"\b(?:INC|SEC)-[A-Z0-9][A-Z0-9._-]*\b|\bGHSA-[A-Z0-9-]+\b", re.IGNORECASE +) +_PRIVATE_DISCLOSURE_RE = re.compile( + r"https://[^\s]*(?:security|disclosure|advisory)[^\s]*", re.IGNORECASE +) +_NEGATIVE_INCIDENT_RE = re.compile( + r"\b(?:no|none|without|not)\s+(?:incident|inc(?:ident)?|security)\b|无(?:事故|事件)|未(?:发生|提供).{0,8}(?:事故|事件)", + re.IGNORECASE, +) +_FORWARD_PORT_REFERENCE_RE = re.compile( + r"(?:\bdev\b.*?(?:\bpr\b|\bissue\b|https://|#\d+)|(?:\bpr\b|\bissue\b|https://|#\d+).*?\bdev\b)", + re.IGNORECASE | re.DOTALL, +) +_NO_IMPACT_RE = re.compile(r"\bnot\s+affected\b|不受影响", re.IGNORECASE) +_NEGATIVE_FORWARD_PORT_RE = re.compile( + r"\b(?:no|none|not)\s+(?:forward[- ]?port|backport|dev\s+(?:pr|issue))\b|无(?:前移|回迁)|未(?:前移|回迁)", + re.IGNORECASE, +) +_PLACEHOLDER_EVIDENCE_RE = re.compile( + r"^(?:none|n/?a|tbd|todo|nil|null|unknown|no\s+evidence|无|未填|待补充|待定|暂无)\s*[。.!!]*$", + re.IGNORECASE, +) _TARGET_RE = re.compile(r"\b(dev|master)\b", re.IGNORECASE) _RISK_RE = re.compile(r"\b(R[0-3])\b", re.IGNORECASE) +_TERMINAL_REVIEW_STATES = {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"} def _mapping(value: Any, name: str) -> Mapping[str, Any]: @@ -48,24 +78,96 @@ def _login(value: Any) -> str | None: return None -def _field_value(body: str, label: str) -> str | None: +def _visible_body(body: str) -> str: + """Discard hidden comments and fenced code before policy parsing.""" + without_comments = _HTML_COMMENT_RE.sub("", body) + visible_lines: list[str] = [] + active_fence: str | None = None + for line in without_comments.splitlines(): + if active_fence is not None: + closing = re.compile( + rf"^[ \t]*{re.escape(active_fence[0])}{{{len(active_fence)},}}[ \t]*$" + ) + if closing.fullmatch(line): + active_fence = None + continue + opening = _FENCE_RE.match(line) + if opening: + active_fence = opening.group("marker") + continue + visible_lines.append(line) + return "\n".join(visible_lines) + + +def _section_content(body: str, heading: re.Pattern[str]) -> str | None: + """Return a unique visible H2 section, excluding every other H2 section.""" + matches = list(heading.finditer(body)) + if len(matches) != 1: + return None + rest = body[matches[0].end() :] + next_heading = re.search(r"^##[ \t]+", rest, re.MULTILINE) + return rest[: next_heading.start()] if next_heading else rest + + +def _section_count(body: str, heading: re.Pattern[str]) -> int: + return len(list(heading.finditer(body))) + + +def _field_values(section: str | None, label: str) -> list[str | None]: + if section is None: + return [] pattern = re.compile(_FIELD_LINE.format(label=re.escape(label)), re.IGNORECASE | re.MULTILINE) - match = pattern.search(body) - if not match: + values: list[str | None] = [] + for match in pattern.finditer(section): + value = match.group("value").strip().strip("`").rstrip("。.") + values.append(value or None) + return values + + +def _field_value(section: str | None, label: str) -> str | None: + """Return the sole non-empty field value, never selecting among duplicates.""" + values = _field_values(section, label) + if len(values) != 1: return None - value = match.group("value").strip().strip("`").rstrip("。.") - return value or None + return values[0] + + +def _governance_field(visible_body: str, label: str) -> str | None: + return _field_value(_section_content(visible_body, _GOVERNANCE_HEADING_RE), label) + + +def _section_field_issues(visible_body: str, kind: str) -> list[str]: + """Reject ambiguous or duplicated policy fields in their designated sections.""" + requirements: list[tuple[str, re.Pattern[str], tuple[str, ...]]] = [ + ("Governance declaration", _GOVERNANCE_HEADING_RE, ("目标分支", "风险等级", "测试证据")) + ] + if kind == "hotfix": + requirements.append(("Hotfix 前移计划", _HOTFIX_HEADING_RE, ("前移计划",))) + elif kind == "release": + requirements.append(("Release 清单", _RELEASE_HEADING_RE, ("Release 清单",))) + + issues: list[str] = [] + for section_name, heading, labels in requirements: + section_count = _section_count(visible_body, heading) + if section_count != 1: + issues.append(f"{section_name} section must appear exactly once in the visible PR body") + continue + section = _section_content(visible_body, heading) + for label in labels: + if len(_field_values(section, label)) != 1: + issues.append(f"{section_name} field {label!r} must appear exactly once") + return issues -def _declared_target(body: str) -> str | None: - value = _field_value(body, "目标分支") - match = _TARGET_RE.search(value or "") +def _declared_target(visible_body: str) -> str | None: + value = _governance_field(visible_body, "目标分支") + match = _TARGET_RE.fullmatch(value or "") return match.group(1).casefold() if match else None -def _declared_risk(body: str) -> str | None: - value = _field_value(body, "风险等级") - match = _RISK_RE.search(value or "") +def _declared_risk(visible_body: str) -> str | None: + value = _governance_field(visible_body, "风险等级") + match = _RISK_RE.fullmatch(value or "") return match.group(1).upper() if match else None @@ -77,21 +179,49 @@ def _pr_kind(base_branch: str, head_branch: str) -> str: return "normal" -def _branch_evidence(body: str, kind: str) -> dict[str, str]: - """Derive the branch-contract evidence that the current template exposes.""" - test_evidence = _field_value(body, "测试证据") or "" - hotfix_plan = _field_value(body, "前移计划") or "" - release_checklist = _field_value(body, "Release 清单") or "" +def _meaningful_evidence(value: str | None) -> bool: + """Reject empty and common placeholder evidence values.""" + if value is None: + return False + normalized = value.strip() + return bool(normalized) and _PLACEHOLDER_EVIDENCE_RE.fullmatch(normalized) is None + + +def _has_structured_incident(value: str) -> bool: + return not _NEGATIVE_INCIDENT_RE.search(value) and bool( + _INCIDENT_IDENTIFIER_RE.search(value) or _PRIVATE_DISCLOSURE_RE.search(value) + ) + + +def _has_forward_port_reference(value: str) -> bool: + if _NEGATIVE_FORWARD_PORT_RE.search(value) or not _meaningful_evidence(value): + return False + if _FORWARD_PORT_REFERENCE_RE.search(value): + return True + no_impact = _NO_IMPACT_RE.search(value) + if not no_impact: + return False + reason = value[no_impact.end() :].strip(" ::-—。.") + return len(reason) >= 12 and _meaningful_evidence(reason) + + +def _branch_evidence(visible_body: str, kind: str) -> dict[str, str]: + """Derive strict branch-contract evidence from its designated H2 sections.""" + test_evidence = _governance_field(visible_body, "测试证据") + hotfix_plan = _field_value(_section_content(visible_body, _HOTFIX_HEADING_RE), "前移计划") + release_checklist = _field_value( + _section_content(visible_body, _RELEASE_HEADING_RE), "Release 清单" + ) evidence: dict[str, str] = {} if kind == "hotfix": - if _INCIDENT_RE.search(hotfix_plan): + if hotfix_plan and _has_structured_incident(hotfix_plan): evidence["incident"] = hotfix_plan - if _FORWARD_PORT_RE.search(hotfix_plan): + if hotfix_plan and _has_forward_port_reference(hotfix_plan): evidence["forward_port_plan"] = hotfix_plan if kind == "release": - if release_checklist: + if _meaningful_evidence(release_checklist): evidence["release_checklist"] = release_checklist - if test_evidence: + if _meaningful_evidence(test_evidence): evidence["release_validation"] = test_evidence return evidence @@ -138,9 +268,21 @@ def _review_timestamp(value: Any, position: int) -> tuple[datetime, int]: return datetime.min.replace(tzinfo=timezone.utc), position -def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, str]: - """Return each non-author reviewer's latest normalized review state.""" - latest: dict[str, tuple[tuple[datetime, int], str]] = {} +def _review_authorization(value: Any) -> str | None: + if isinstance(value, str) and value.strip(): + return value.strip().upper() + return None + + +def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, tuple[str, str | None]]: + """Return each reviewer's effective terminal state and authorization. + + Comments and pending reviews are non-terminal. They cannot erase an + earlier approval or change request. An OWNER terminal review has + precedence over subsequent unverified terminal events so an unverified + actor cannot clear an OWNER change request or approval. + """ + histories: dict[str, list[tuple[tuple[datetime, int], str, str | None]]] = {} for position, review in enumerate(reviews): if not isinstance(review, Mapping): continue @@ -151,22 +293,54 @@ def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, str]: if not isinstance(state, str) or not state.strip(): continue key = login.casefold() - candidate = (_review_timestamp(review.get("submitted_at"), position), state.strip().upper()) - if key not in latest or candidate[0] >= latest[key][0]: - latest[key] = candidate - return {login: state for login, (_, state) in sorted(latest.items())} + histories.setdefault(key, []).append( + ( + _review_timestamp(review.get("submitted_at"), position), + state.strip().upper(), + _review_authorization(review.get("author_association")), + ) + ) + + effective: dict[str, tuple[str, str | None]] = {} + for login, history in sorted(histories.items()): + latest_terminal: tuple[str, str | None] | None = None + latest_nonterminal: tuple[str, str | None] | None = None + owner_terminal: tuple[str, str | None] | None = None + for _, state, authorization in sorted(history, key=lambda entry: entry[0]): + if state in _TERMINAL_REVIEW_STATES: + latest_terminal = (state, authorization) + if authorization == "OWNER": + owner_terminal = (state, authorization) + else: + latest_nonterminal = (state, authorization) + state = owner_terminal or latest_terminal or latest_nonterminal + if state is not None: + effective[login] = state + return effective def _review_summary(reviews: Sequence[Any], author: str) -> dict[str, list[str]]: latest = _latest_reviews(reviews, author) - approvals = sorted(login for login, state in latest.items() if state == "APPROVED") + approvals = sorted( + login + for login, (state, authorization) in latest.items() + if state == "APPROVED" and authorization == "OWNER" + ) changes_requested = sorted( - login for login, state in latest.items() if state == "CHANGES_REQUESTED" + login + for login, (state, authorization) in latest.items() + if state == "CHANGES_REQUESTED" and authorization == "OWNER" + ) + unverified = sorted( + login + for login, (state, authorization) in latest.items() + if state == "APPROVED" and authorization != "OWNER" ) return { "effective_approvals": approvals, "changes_requested_by": changes_requested, "reviewers": sorted(latest), + "unverified_reviews": unverified, } @@ -194,6 +368,48 @@ def _branch_manifest( return manifest if isinstance(manifest, Mapping) else None +def _is_nonnegative_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _inventory_issues(pr: Mapping[str, Any], entry_count: Any) -> list[str]: + """Fail closed when the GitHub files listing is incomplete or malformed.""" + reported_count = pr.get("changed_files") + if not _is_nonnegative_int(reported_count): + return [ + "complete changed-file inventory unavailable: PR metadata changed_files must be " + "a non-negative integer" + ] + if not _is_nonnegative_int(entry_count): + return [ + "complete changed-file inventory unavailable: files API entry count must be " + "a non-negative integer" + ] + if reported_count != entry_count: + return [ + "complete changed-file inventory unavailable: PR metadata reports " + f"{reported_count} files but the files API returned {entry_count}; do not trust " + "path-risk classification until the complete inventory is available" + ] + return [] + + +def _manifest_policy_issues(manifests: Any) -> list[str]: + """Validate the full Ruleset desired state before using any review floor.""" + if not isinstance(manifests, Mapping): + return ["Ruleset manifests must be a mapping"] + try: + issues = list(validate_manifests(manifests)) + except (AttributeError, TypeError) as error: + issues = [f"Ruleset manifests are invalid: {error}"] + for branch in ("dev", "master"): + if not isinstance(manifests.get(branch), Mapping): + message = f"missing Ruleset manifest: {branch} (missing or invalid)" + if message not in issues: + issues.append(message) + return issues + + def evaluate_pr_governance( pr: Mapping[str, Any], reviews: Sequence[Any], @@ -201,6 +417,7 @@ def evaluate_pr_governance( *, risk_map: Mapping[str, Any], manifests: Mapping[str, Mapping[str, Any]], + changed_file_entry_count: Any, ) -> dict[str, Any]: """Return an actionable, deterministic Governance Gate result. @@ -229,27 +446,42 @@ def evaluate_pr_governance( base_branch = base_branch.strip() head_branch = head_branch.strip() kind = _pr_kind(base_branch, head_branch) + visible_body = _visible_body(body) classification = classify_changed_files(changed_files, risk_map) labels = _labels(pr_data) - manifest = _branch_manifest(manifests, base_branch) + manifest = _branch_manifest(manifests, base_branch) if isinstance(manifests, Mapping) else None + issues = _manifest_policy_issues(manifests) + issues.extend(_inventory_issues(pr_data, changed_file_entry_count)) approval_floor: int | None = None if manifest is not None: - pull_request = _mapping( - manifest.get("pull_request"), f"{base_branch} manifest pull_request" - ) - approval_floor = pull_request.get("required_approvals") - if not isinstance(approval_floor, int) or isinstance(approval_floor, bool): - raise ValueError(f"{base_branch} manifest required_approvals must be an integer") - - issues = list(governance_declaration_issues(body, kind)) - declared_target = _declared_target(body) + pull_request = manifest.get("pull_request") + if not isinstance(pull_request, Mapping): + issues.append(f"{base_branch} Ruleset manifest pull_request must be an object") + else: + candidate_floor = pull_request.get("required_approvals") + if _is_nonnegative_int(candidate_floor): + approval_floor = candidate_floor + else: + issues.append( + f"{base_branch} Ruleset manifest required_approvals must be a " + "non-negative integer" + ) + + # The inherited static checker remains the compatibility authority for the + # common declaration. Its kind-specific H2 parser currently treats the + # newline after a Chinese heading as whitespace inside the heading, so the + # trusted gate intentionally calls its stable normal contract here and + # enforces hotfix/release fields below with this module's strict parser. + issues.extend(governance_declaration_issues(visible_body, "normal")) + issues.extend(_section_field_issues(visible_body, kind)) + declared_target = _declared_target(visible_body) if declared_target != base_branch: issues.append( f"governance declaration target branch {declared_target!r} does not match actual " f"PR base {base_branch!r}; update the declaration or retarget the PR" ) - declared_risk = _declared_risk(body) + declared_risk = _declared_risk(visible_body) computed_risk = str(classification["risk"]) if declared_risk not in RISK_ORDER: issues.append( @@ -261,7 +493,9 @@ def evaluate_pr_governance( f"{computed_risk}; labels cannot lower path-derived risk" ) - issues.extend(validate_branch_contract(base_branch, head_branch, _branch_evidence(body, kind))) + issues.extend( + validate_branch_contract(base_branch, head_branch, _branch_evidence(visible_body, kind)) + ) if base_branch == "master" and kind == "normal": issues.append("regular contribution branches must target dev; retarget this PR to dev") @@ -269,6 +503,13 @@ def evaluate_pr_governance( effective_approvals = review_summary["effective_approvals"] changes_requested_by = review_summary["changes_requested_by"] if approval_floor is not None and len(effective_approvals) < approval_floor: + unverified_reviews = review_summary["unverified_reviews"] + if unverified_reviews: + reviewers = ", ".join(unverified_reviews) + issues.append( + f"review approval authorization not verified for {reviewers}; request verified " + "maintainer review" + ) issues.append( f"{base_branch} requires {approval_floor} non-author approvals but has " f"{len(effective_approvals)}; request an additional maintainer review" @@ -279,16 +520,16 @@ def evaluate_pr_governance( ) requested_reviewers = _requested_reviewer_logins(pr_data, author) - protective_reviewers = sorted(set(review_summary["reviewers"]) | set(requested_reviewers)) + protective_reviewers = effective_approvals if computed_risk in {"R2", "R3"}: - if not _field_value(body, "测试证据"): + if not _meaningful_evidence(_governance_field(visible_body, "测试证据")): issues.append( f"{computed_risk} change requires concrete test evidence in the governance declaration" ) if not protective_reviewers: issues.append( - f"{computed_risk} change requires a readable protective reviewer or " - "requested-reviewer record; request a maintainer review" + f"{computed_risk} change requires a verified OWNER protective approval; " + "requested reviewers alone do not satisfy it; request verified maintainer review" ) code_owner_review = ( @@ -308,7 +549,9 @@ def evaluate_pr_governance( "approval_floor": approval_floor, "effective_approvals": effective_approvals, "changes_requested_by": changes_requested_by, + "unverified_reviews": review_summary["unverified_reviews"], "protective_reviewers": protective_reviewers, + "requested_reviewers": requested_reviewers, "code_owner_review": code_owner_review, "issues": issues, } @@ -345,12 +588,14 @@ def main() -> int: """Evaluate local inputs and use the exit status only as a gate result.""" args = _parse_args() try: + file_entries = _flatten_pages(_load_json(args.changed_files)) result = evaluate_pr_governance( _load_json(args.pr), _flatten_pages(_load_json(args.reviews)), - changed_file_paths(_load_json(args.changed_files)), + changed_file_paths(file_entries), risk_map=load_risk_map(args.risk_map), manifests=load_manifests(args.manifest_dir), + changed_file_entry_count=len(file_entries), ) except (OSError, ValueError, json.JSONDecodeError) as error: print(json.dumps({"ok": False, "issues": [str(error)]}, ensure_ascii=False)) diff --git a/scripts/ci/classify_pr_risk.py b/scripts/ci/classify_pr_risk.py index 7a5897b36..ba64c28d0 100644 --- a/scripts/ci/classify_pr_risk.py +++ b/scripts/ci/classify_pr_risk.py @@ -38,14 +38,29 @@ def changed_file_paths(payload: Any) -> list[str]: fields are accepted here because path classification must remain isolated. """ paths: list[str] = [] + seen: set[str] = set() + + def add_path(path: str) -> None: + if path not in seen: + seen.add(path) + paths.append(path) + for entry in _flatten_payload(payload): if isinstance(entry, str) and entry.strip(): - paths.append(entry.strip()) + add_path(entry.strip()) continue if isinstance(entry, Mapping): filename = entry.get("filename") if isinstance(filename, str) and filename.strip(): - paths.append(filename.strip()) + add_path(filename.strip()) + status = entry.get("status") + if isinstance(status, str) and status.casefold() == "renamed": + previous_filename = entry.get("previous_filename") + if not isinstance(previous_filename, str) or not previous_filename.strip(): + raise ValueError( + "renamed changed-files entries must include a non-empty previous_filename" + ) + add_path(previous_filename.strip()) continue raise ValueError("each changed-files entry must be a path string or filename object") return paths diff --git a/scripts/ci/tests/test_check_pr_governance.py b/scripts/ci/tests/test_check_pr_governance.py index d034392b6..dcad4ab34 100644 --- a/scripts/ci/tests/test_check_pr_governance.py +++ b/scripts/ci/tests/test_check_pr_governance.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import importlib.util import json import subprocess @@ -14,6 +15,7 @@ CHECKER_PATH = SCRIPTS_CI / "check_pr_governance.py" RISK_MAP_PATH = REPO_ROOT / ".github" / "governance" / "risk-paths.json" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" +_DEFAULT_CHANGED_FILE_COUNT = object() def _load_checker() -> ModuleType: @@ -68,6 +70,7 @@ def _pr( author: str = "contributor", labels: tuple[str, ...] = (), requested_reviewers: tuple[str, ...] = (), + changed_file_count: object = 1, ) -> dict[str, object]: return { "number": 195, @@ -77,15 +80,24 @@ def _pr( "body": body, "labels": [{"name": label} for label in labels], "requested_reviewers": [{"login": login} for login in requested_reviewers], + "changed_files": changed_file_count, } -def _review(login: str, state: str, submitted_at: str) -> dict[str, object]: - return { +def _review( + login: str, + state: str, + submitted_at: str, + author_association: str | None = "OWNER", +) -> dict[str, object]: + review: dict[str, object] = { "user": {"login": login}, "state": state, "submitted_at": submitted_at, } + if author_association is not None: + review["author_association"] = author_association + return review def _evaluate( @@ -93,13 +105,26 @@ def _evaluate( pr: dict[str, object], reviews: list[dict[str, object]], changed_files: list[str], + *, + metadata_changed_file_count: object = _DEFAULT_CHANGED_FILE_COUNT, + raw_file_entry_count: int | None = None, + manifests: dict[str, dict[str, object]] | None = None, ) -> dict[str, object]: + pr_with_count = dict(pr) + pr_with_count["changed_files"] = ( + len(changed_files) + if metadata_changed_file_count is _DEFAULT_CHANGED_FILE_COUNT + else metadata_changed_file_count + ) return checker.evaluate_pr_governance( - pr, + pr_with_count, reviews, changed_files, risk_map=_risk_map(), - manifests=_manifests(checker), + manifests=_manifests(checker) if manifests is None else manifests, + changed_file_entry_count=( + len(changed_files) if raw_file_entry_count is None else raw_file_entry_count + ), ) @@ -127,6 +152,167 @@ def test_feature_to_dev_r1_with_complete_evidence_and_approval_passes() -> None: assert result["issues"] == [] +def test_matching_changed_file_metadata_and_files_inventory_is_accepted() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + metadata_changed_file_count=1, + raw_file_entry_count=1, + ) + + assert result["ok"] is True + assert not any( + "complete changed-file inventory unavailable" in issue for issue in result["issues"] + ) + + +def test_truncated_files_api_inventory_is_rejected_before_path_risk_can_be_trusted() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/ci-cleanup", + body=_body(target="dev", risk="R3"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + [".github/workflows/ci.yml"], + metadata_changed_file_count=3001, + raw_file_entry_count=3000, + ) + + assert result["risk"] == "R3" + assert result["ok"] is False + assert any("complete changed-file inventory unavailable" in issue for issue in result["issues"]) + + +def test_malformed_changed_file_metadata_is_rejected_before_path_risk_can_be_trusted() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/ci-cleanup", + body=_body(target="dev", risk="R3"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + [".github/workflows/ci.yml"], + metadata_changed_file_count=True, + raw_file_entry_count=1, + ) + + assert result["ok"] is False + assert any("complete changed-file inventory unavailable" in issue for issue in result["issues"]) + + +def test_empty_ruleset_manifest_set_blocks_supported_branch_governance() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + manifests={}, + ) + + assert result["ok"] is False + assert any("missing Ruleset manifest: dev" in issue for issue in result["issues"]) + + +def test_missing_dev_or_master_ruleset_manifest_blocks_the_gate() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + missing_dev = _manifests(checker) + missing_dev.pop("dev") + missing_master = _manifests(checker) + missing_master.pop("master") + + dev_result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + manifests=missing_dev, + ) + master_result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + manifests=missing_master, + ) + + assert any("missing Ruleset manifest: dev" in issue for issue in dev_result["issues"]) + assert any("missing Ruleset manifest: master" in issue for issue in master_result["issues"]) + + +def test_non_mapping_ruleset_manifest_blocks_the_gate_without_an_exception() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + manifests = _manifests(checker) + manifests["dev"] = [] # type: ignore[assignment] + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + manifests=manifests, + ) + + assert result["ok"] is False + assert any("missing Ruleset manifest: dev" in issue for issue in result["issues"]) + + +def test_malformed_required_approvals_blocks_the_gate() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + manifests = copy.deepcopy(_manifests(checker)) + manifests["dev"]["pull_request"]["required_approvals"] = False + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + manifests=manifests, + ) + + assert result["ok"] is False + assert any("required_approvals" in issue for issue in result["issues"]) + + def test_declared_target_must_match_actual_pr_base() -> None: checker = _load_checker() pr = _pr( @@ -146,6 +332,192 @@ def test_declared_target_must_match_actual_pr_base() -> None: assert any("does not match actual PR base 'dev'" in issue for issue in result["issues"]) +def test_governance_target_and_risk_values_must_be_unambiguous() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev or master", risk="R1 or R3"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "target branch None does not match actual PR base 'dev'" in issue + for issue in result["issues"] + ) + assert any("must name R0-R3" in issue for issue in result["issues"]) + + +def test_common_fields_are_bound_to_governance_section_not_preamble_decoys() -> None: + checker = _load_checker() + body = """\ +## What & Why + +- **目标分支**: dev +- **风险等级**: R1 +- **测试证据**: pytest preamble-decoy + +## Governance declaration + +- **目标分支**: master +- **风险等级**: R1 +- **测试证据**: pytest scripts/ci/tests/test_check_pr_governance.py -q passed +""" + pr = _pr(base="dev", head="feature/market-screen", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "target branch 'master' does not match actual PR base 'dev'" in issue + for issue in result["issues"] + ) + + +def test_html_comment_decoys_cannot_supply_risk_or_test_evidence() -> None: + checker = _load_checker() + body = """\ + + +## Governance declaration + +- **目标分支**: dev +- **风险等级**: R1 +- **测试证据**: +""" + pr = _pr(base="dev", head="feature/ci-cleanup", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + [".github/workflows/ci.yml"], + ) + + assert result["risk"] == "R3" + assert any("declares R1 but changed paths require R3" in issue for issue in result["issues"]) + assert any("R3 change requires concrete test evidence" in issue for issue in result["issues"]) + + +def test_hidden_html_comment_declaration_does_not_satisfy_the_gate() -> None: + checker = _load_checker() + body = """\ + +""" + pr = _pr(base="dev", head="feature/market-screen", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "missing required section: ## Governance declaration" in issue for issue in result["issues"] + ) + + +def test_fenced_code_declaration_does_not_satisfy_the_gate() -> None: + checker = _load_checker() + body = """\ +```markdown +## Governance declaration + +- **目标分支**: dev +- **风险等级**: R1 +- **测试证据**: pytest fenced-code-decoy +``` +""" + pr = _pr(base="dev", head="feature/market-screen", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "missing required section: ## Governance declaration" in issue for issue in result["issues"] + ) + + +def test_mixed_length_fences_do_not_expose_hidden_governance_declarations() -> None: + checker = _load_checker() + hidden_declaration = """\ +## Governance declaration + +- **目标分支**: dev +- **风险等级**: R1 +- **测试证据**: pytest hidden-fence-decoy +""" + + for opening_fence, inner_fence in (("````", "```"), ("`````", "````"), ("~~~~", "~~~")): + body = f"{opening_fence}markdown\n{inner_fence}\n{hidden_declaration}{opening_fence}\n" + pr = _pr(base="dev", head="feature/market-screen", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + "missing required section: ## Governance declaration" in issue + for issue in result["issues"] + ) + + +def test_visible_governance_fields_must_appear_exactly_once() -> None: + checker = _load_checker() + for label, value in ( + ("目标分支", "dev"), + ("风险等级", "R1"), + ("测试证据", "pytest scripts/ci/tests/test_check_pr_governance.py -q passed"), + ): + body = _body(target="dev", risk="R1") + f"- **{label}**: {value}\n" + pr = _pr(base="dev", head="feature/market-screen", body=body) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert any( + f"Governance declaration field {label!r} must appear exactly once" in issue + for issue in result["issues"] + ) + + def test_feature_to_master_is_blocked_with_the_dev_routing_remedy() -> None: checker = _load_checker() pr = _pr( @@ -206,9 +578,7 @@ def test_r2_auth_change_requires_matching_declaration_and_protective_record() -> assert result["risk"] == "R2" assert result["labels_can_lower_risk"] is False assert any("declares R1 but changed paths require R2" in issue for issue in result["issues"]) - assert any( - "protective reviewer or requested-reviewer record" in issue for issue in result["issues"] - ) + assert any("verified OWNER protective approval" in issue for issue in result["issues"]) assert result["code_owner_review"]["verified"] is False @@ -258,13 +628,20 @@ def test_hotfix_missing_incident_and_forward_port_fields_is_actionably_rejected( assert any("forward-port plan for dev" in issue for issue in result["issues"]) -def test_release_missing_checklist_is_rejected() -> None: +def test_hotfix_fields_outside_hotfix_section_cannot_satisfy_branch_contract() -> None: checker = _load_checker() - pr = _pr( - base="master", - head="release/v1.2.3", - body=_body(target="master", risk="R3"), - ) + body = """\ +- **前移计划**: INC-195; forward-port to dev in PR #196 + +## Governance declaration + +- **目标分支**: master +- **风险等级**: R3 +- **测试证据**: pytest scripts/ci/tests/test_check_pr_governance.py -q passed + +## Hotfix 前移计划 +""" + pr = _pr(base="master", head="hotfix/master-session-expiry", body=body) result = _evaluate( checker, @@ -273,31 +650,223 @@ def test_release_missing_checklist_is_rejected() -> None: _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), ], - ["scripts/ops/release.sh"], + ["src/backend/app/api/auth.py"], ) - assert result["pr_kind"] == "release" - assert result["ok"] is False - assert any("Release 清单" in issue for issue in result["issues"]) + assert any("incident or private disclosure record" in issue for issue in result["issues"]) + assert any("forward-port plan for dev" in issue for issue in result["issues"]) -def test_latest_non_author_review_state_controls_approval_floor_and_blocks_changes_requested() -> ( - None -): +def test_visible_hotfix_and_release_fields_must_appear_exactly_once() -> None: checker = _load_checker() - pr = _pr( - base="master", - head="release/v1.2.3", - body=_body( + hotfix_body = ( + _body( + target="master", + risk="R3", + hotfix_plan="INC-195; dev PR #196 carries the forward-port", + ) + + "- **前移计划**: INC-195; dev PR #196 carries the forward-port\n" + ) + release_body = ( + _body( target="master", risk="R3", release_checklist="v1.2.3 changelog, regression results, and rollback point", - ), + ) + + "- **Release 清单**: v1.2.3 changelog, regression results, and rollback point\n" ) - result = _evaluate( + hotfix_result = _evaluate( checker, - pr, + _pr(base="master", head="hotfix/master-session-expiry", body=hotfix_body), + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/api/auth.py"], + ) + release_result = _evaluate( + checker, + _pr(base="master", head="release/v1.2.3", body=release_body), + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert any( + "Hotfix 前移计划 field '前移计划' must appear exactly once" in issue + for issue in hotfix_result["issues"] + ) + assert any( + "Release 清单 field 'Release 清单' must appear exactly once" in issue + for issue in release_result["issues"] + ) + + +def test_structured_hotfix_incident_and_dev_forward_port_evidence_passes() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="hotfix/master-session-expiry", + body=_body( + target="master", + risk="R3", + hotfix_plan="INC-195: session expiry; dev PR #196 carries the forward-port fix", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/api/auth.py"], + ) + + assert result["ok"] is True + + +def test_hotfix_negative_or_placeholder_plan_cannot_satisfy_incident_or_forward_port() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="hotfix/master-session-expiry", + body=_body( + target="master", + risk="R3", + hotfix_plan="no incident; no forward-port; TBD", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/api/auth.py"], + ) + + assert result["ok"] is False + assert any("incident or private disclosure record" in issue for issue in result["issues"]) + assert any("forward-port plan for dev" in issue for issue in result["issues"]) + + +def test_release_missing_checklist_is_rejected() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body(target="master", risk="R3"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["pr_kind"] == "release" + assert result["ok"] is False + assert any("Release 清单" in issue for issue in result["issues"]) + + +def test_release_checklist_outside_release_section_cannot_satisfy_branch_contract() -> None: + checker = _load_checker() + body = """\ +- **Release 清单**: v1.2.3 changelog, regression results, and rollback point + +## Governance declaration + +- **目标分支**: master +- **风险等级**: R3 +- **测试证据**: pytest scripts/ci/tests/test_check_pr_governance.py -q passed + +## Release 清单 +""" + pr = _pr(base="master", head="release/v1.2.3", body=body) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert any("completed release checklist" in issue for issue in result["issues"]) + + +def test_placeholder_high_risk_test_evidence_is_rejected() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/ci-cleanup", + body=_body(target="dev", risk="R3", test_evidence="TBD"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + [".github/workflows/ci.yml"], + ) + + assert result["ok"] is False + assert any("R3 change requires concrete test evidence" in issue for issue in result["issues"]) + + +def test_placeholder_release_checklist_and_validation_are_rejected() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body(target="master", risk="R3", test_evidence="none", release_checklist="TBD"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["ok"] is False + assert any("completed release checklist" in issue for issue in result["issues"]) + assert any("release validation evidence" in issue for issue in result["issues"]) + + +def test_latest_non_author_review_state_controls_approval_floor_and_blocks_changes_requested() -> ( + None +): + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + result = _evaluate( + checker, + pr, [ _review("contributor", "APPROVED", "2026-08-24T00:00:00Z"), _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), @@ -316,6 +885,275 @@ def test_latest_non_author_review_state_controls_approval_floor_and_blocks_chang assert any("requires 2 non-author approvals but has 1" in issue for issue in result["issues"]) +def test_nonterminal_review_after_changes_requested_does_not_clear_the_block() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + for nonterminal_state in ("COMMENTED", "PENDING"): + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "CHANGES_REQUESTED", "2026-08-24T00:01:00Z"), + _review("maintainer-a", nonterminal_state, "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + _review("maintainer-c", "APPROVED", "2026-08-24T00:03:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["changes_requested_by"] == ["maintainer-a"] + assert result["effective_approvals"] == ["maintainer-b", "maintainer-c"] + assert any( + "latest review from maintainer-a is CHANGES_REQUESTED" in issue + for issue in result["issues"] + ) + + +def test_approved_review_after_changes_requested_clears_the_block_by_time_and_input_order() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "CHANGES_REQUESTED", "2026-08-24T00:01:00Z"), + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["ok"] is True + assert result["changes_requested_by"] == [] + assert result["effective_approvals"] == ["maintainer-a", "maintainer-b"] + + +def test_dismissed_review_after_changes_requested_clears_the_block_without_counting_as_approval() -> ( + None +): + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "CHANGES_REQUESTED", "2026-08-24T00:01:00Z"), + _review("maintainer-a", "DISMISSED", "2026-08-24T00:02:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:03:00Z"), + _review("maintainer-c", "APPROVED", "2026-08-24T00:04:00Z"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["ok"] is True + assert result["changes_requested_by"] == [] + assert result["effective_approvals"] == ["maintainer-b", "maintainer-c"] + + +def test_comment_after_approval_does_not_remove_the_approval() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer", "COMMENTED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is True + assert result["effective_approvals"] == ["maintainer"] + + +def test_owner_approval_counts_toward_the_dev_floor() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z", "OWNER")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is True + assert result["effective_approvals"] == ["maintainer"] + + +def test_unverified_none_approval_does_not_count_toward_the_floor() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("random-reviewer", "APPROVED", "2026-08-24T00:01:00Z", "NONE")], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert any("authorization not verified" in issue for issue in result["issues"]) + assert any("request verified maintainer review" in issue for issue in result["issues"]) + + +def test_unverified_review_is_metadata_only_when_verified_owner_floor_is_already_met() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [ + _review("repository-owner", "APPROVED", "2026-08-24T00:01:00Z", "OWNER"), + _review("member", "APPROVED", "2026-08-24T00:02:00Z", "MEMBER"), + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is True + assert result["effective_approvals"] == ["repository-owner"] + assert result["unverified_reviews"] == ["member"] + assert not any("authorization not verified" in issue for issue in result["issues"]) + + +def test_author_owner_approval_does_not_count_toward_the_floor() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + author="repository-owner", + ) + + result = _evaluate( + checker, + pr, + [ + _review("repository-owner", "APPROVED", "2026-08-24T00:01:00Z", "OWNER"), + _review("random-reviewer", "APPROVED", "2026-08-24T00:02:00Z", "MEMBER"), + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + + +def test_missing_review_authorization_fails_closed() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z", None)], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert any("authorization not verified" in issue for issue in result["issues"]) + + +def test_master_floor_stays_red_without_two_verified_owner_approvals() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + + result = _evaluate( + checker, + pr, + [ + _review("repository-owner", "APPROVED", "2026-08-24T00:01:00Z", "OWNER"), + _review("member", "APPROVED", "2026-08-24T00:02:00Z", "MEMBER"), + ], + ["scripts/ops/release.sh"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == ["repository-owner"] + assert any("requires 2 non-author approvals but has 1" in issue for issue in result["issues"]) + + +def test_requested_reviewer_does_not_satisfy_r2_protective_review_requirement() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/auth-refresh", + body=_body(target="dev", risk="R2"), + requested_reviewers=("unverified-request",), + ) + + result = _evaluate( + checker, + pr, + [], + ["src/backend/app/api/auth.py"], + ) + + assert result["ok"] is False + assert result["protective_reviewers"] == [] + assert any("verified OWNER protective approval" in issue for issue in result["issues"]) + + def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result(tmp_path: Path) -> None: pr_path = tmp_path / "pr.json" reviews_path = tmp_path / "reviews.json" diff --git a/scripts/ci/tests/test_classify_pr_risk.py b/scripts/ci/tests/test_classify_pr_risk.py index b91ed3846..824d71c06 100644 --- a/scripts/ci/tests/test_classify_pr_risk.py +++ b/scripts/ci/tests/test_classify_pr_risk.py @@ -9,6 +9,8 @@ from pathlib import Path from types import ModuleType +import pytest + REPO_ROOT = Path(__file__).resolve().parents[3] SCRIPTS_CI = REPO_ROOT / "scripts" / "ci" CLASSIFIER_PATH = SCRIPTS_CI / "classify_pr_risk.py" @@ -81,6 +83,64 @@ def test_workflow_lock_and_release_changes_raise_risk_to_r3() -> None: assert result["labels_can_lower_risk"] is False +def test_renaming_a_workflow_to_docs_retains_the_r3_source_path_risk() -> None: + classifier = _load_classifier() + paths = classifier.changed_file_paths( + [ + { + "filename": "docs/governance/ci-notes.md", + "previous_filename": ".github/workflows/ci.yml", + "status": "renamed", + }, + {"filename": "docs/governance/ci-notes.md"}, + ] + ) + + result = classifier.classify_changed_files(paths, _risk_map()) + + assert paths == ["docs/governance/ci-notes.md", ".github/workflows/ci.yml"] + assert result["risk"] == "R3" + + +def test_renaming_auth_to_an_unmapped_path_retains_the_r2_source_path_risk() -> None: + classifier = _load_classifier() + paths = classifier.changed_file_paths( + [ + { + "filename": "src/backend/app/services/market_data.py", + "previous_filename": "src/backend/app/api/auth.py", + "status": "renamed", + } + ] + ) + + result = classifier.classify_changed_files(paths, _risk_map()) + + assert paths == [ + "src/backend/app/services/market_data.py", + "src/backend/app/api/auth.py", + ] + assert result["risk"] == "R2" + + +@pytest.mark.parametrize("previous_filename", [None, "", 195]) +def test_renamed_entry_without_a_valid_previous_filename_fails_closed( + previous_filename: object, +) -> None: + classifier = _load_classifier() + + with pytest.raises(ValueError, match="previous_filename"): + classifier.changed_file_paths( + [ + { + "filename": "docs/governance/ci-notes.md", + "previous_filename": previous_filename, + "status": "renamed", + } + ] + ) + + def test_cli_reads_only_a_local_changed_files_payload_and_risk_map(tmp_path: Path) -> None: payload = tmp_path / "changed-files.json" payload.write_text(json.dumps([{"filename": "src/backend/app/api/auth.py"}]), encoding="utf-8") diff --git a/scripts/ci/tests/test_workflow_governance_contract.py b/scripts/ci/tests/test_workflow_governance_contract.py index 9b655b983..752aeca8b 100644 --- a/scripts/ci/tests/test_workflow_governance_contract.py +++ b/scripts/ci/tests/test_workflow_governance_contract.py @@ -82,6 +82,131 @@ def _permission_declaration_issues(declaration: object, path: str) -> list[str]: return [] +def _governance_gate(workflow: str) -> dict[str, object]: + parsed = _workflow_mapping(workflow) + jobs = parsed.get("jobs") + assert isinstance(jobs, dict), "workflow jobs must be a mapping" + gate = jobs.get("governance-gate") + assert isinstance(gate, dict), "workflow must declare governance-gate" + return gate + + +def _all_uses(node: object) -> list[str]: + if isinstance(node, dict): + values = [str(node["uses"])] if "uses" in node else [] + for value in node.values(): + values.extend(_all_uses(value)) + return values + if isinstance(node, list): + return [use for value in node for use in _all_uses(value)] + return [] + + +def _run_steps(gate: dict[str, object]) -> list[tuple[str | None, str]]: + steps = gate.get("steps") + assert isinstance(steps, list), "governance-gate steps must be a list" + runs: list[tuple[str | None, str]] = [] + for step in steps: + if isinstance(step, dict) and isinstance(step.get("run"), str): + name = step.get("name") + runs.append((name if isinstance(name, str) else None, step["run"])) + return runs + + +def _normalized_command(script: str) -> str: + return " ".join(re.sub(r"\\[ \t]*\n[ \t]*", " ", script).split()) + + +def _uses_issues(workflow: str) -> list[str]: + parsed = _workflow_mapping(workflow) + gate = _governance_gate(workflow) + expected = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" + issues: list[str] = [] + if _all_uses(parsed) != [expected]: + issues.append("workflow must use only the pinned trusted checkout action") + for key in ("container", "services"): + if key in gate: + issues.append(f"governance gate must not configure {key}") + return issues + + +def _governance_run_issues(workflow: str) -> list[str]: + gate = _governance_gate(workflow) + run_steps = _run_steps(gate) + issues: list[str] = [] + steps = gate.get("steps") + assert isinstance(steps, list) + if "if" in gate or any(isinstance(step, dict) and "if" in step for step in steps): + issues.append("governance gate and its steps must not conditionally skip evaluation") + expected_names = { + "Read pull request metadata", + "Evaluate trusted governance policy", + } + names = [name for name, _ in run_steps] + if len(names) != len(expected_names) or set(names) != expected_names: + issues.append("gate must contain only the two allowlisted named run steps") + runs = {name: script for name, script in run_steps if name is not None} + + metadata = runs.get("Read pull request metadata") + if metadata is None: + issues.append("missing metadata read step") + else: + actual_lines = [line.strip() for line in metadata.splitlines() if line.strip()] + expected_lines = [ + 'mkdir -p "$GOVERNANCE_TMP"', + 'gh api --method GET "repos/$PR_REPOSITORY/pulls/$PR_NUMBER" > "$GOVERNANCE_TMP/pr.json"', + 'gh api --method GET --paginate --slurp "repos/$PR_REPOSITORY/pulls/$PR_NUMBER/reviews" > "$GOVERNANCE_TMP/reviews.json"', + 'gh api --method GET --paginate --slurp "repos/$PR_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" > "$GOVERNANCE_TMP/files.json"', + ] + if actual_lines != expected_lines: + issues.append( + "metadata step must contain only the allowlisted mkdir and three GET calls" + ) + + evaluate = runs.get("Evaluate trusted governance policy") + expected_evaluate = ( + 'python scripts/ci/check_pr_governance.py --pr "$GOVERNANCE_TMP/pr.json" ' + '--reviews "$GOVERNANCE_TMP/reviews.json" --changed-files "$GOVERNANCE_TMP/files.json" ' + "--risk-map .github/governance/risk-paths.json --manifest-dir .github/governance/rulesets" + ) + if evaluate is None or _normalized_command(evaluate) != expected_evaluate: + issues.append( + "evaluation step must contain only the allowlisted governance checker command" + ) + + prohibited = re.compile( + r"`|\$\(|\b(?:curl|wget|eval|source|bash|sh)\b|\bpython\s+-c\b|" + r"\bgit\s+(?:fetch|checkout|clone)\b|\bgh\s+(?!api\b)|" + r"github\.event\.pull_request\.(?:head|merge)|refs/pull/", + re.IGNORECASE, + ) + if any(prohibited.search(script) for _, script in run_steps): + issues.append("run steps contain a prohibited shell construct") + return issues + + +def _checkout_issues(workflow: str) -> list[str]: + gate = _governance_gate(workflow) + steps = gate.get("steps") + assert isinstance(steps, list) + checkout_steps = [ + step + for step in steps + if isinstance(step, dict) and str(step.get("uses", "")).startswith("actions/checkout@") + ] + issues = _uses_issues(workflow) + if len(checkout_steps) != 1: + return issues + ["exactly one checkout step is required"] + checkout_with = checkout_steps[0].get("with") + if not isinstance(checkout_with, dict): + return issues + ["checkout must include a with mapping"] + if checkout_with.get("ref") != "${{ github.event.pull_request.base.sha }}": + issues.append("checkout must use only the pull request base SHA") + if checkout_with.get("persist-credentials") != "false": + issues.append("checkout must disable persisted credentials") + return issues + + class TestPrCheckWorkflowContract: def test_pr_check_rejects_job_level_write_all_permissions(self) -> None: workflow = _read(PR_CHECK).replace( @@ -160,6 +285,7 @@ def test_governance_uses_only_trusted_pull_request_target_events(self) -> None: workflow = _read(PR_GOVERNANCE) assert _event_issues(workflow, {"pull_request_target"}) == [] + assert set(self._governance_event()) == {"types"} assert set(self._governance_event().get("types", [])) == self.EXPECTED_EVENTS assert "pull_request_review:" not in workflow @@ -196,6 +322,7 @@ def test_governance_workflow_is_read_only_and_has_an_always_running_gate(self) - assert isinstance(gate, dict) assert gate.get("name") == "Governance Gate" assert "if" not in gate + assert all(not isinstance(step, dict) or "if" not in step for step in gate.get("steps", [])) assert "paths" not in self._governance_event() assert "paths-ignore" not in self._governance_event() @@ -224,6 +351,94 @@ def test_governance_checkout_is_pinned_to_the_trusted_base_sha_without_credentia assert checkout_with.get("ref") == "${{ github.event.pull_request.base.sha }}" assert checkout_with.get("persist-credentials") == "false" assert "github.event.pull_request.head" not in workflow + assert _checkout_issues(workflow) == [] + + def test_governance_has_exactly_one_pinned_action_and_no_container_or_services(self) -> None: + workflow = _read(PR_GOVERNANCE) + parsed = _workflow_mapping(workflow) + gate = _governance_gate(workflow) + + assert _all_uses(parsed) == ["actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"] + assert "container" not in gate + assert "services" not in gate + + extra_action = workflow.replace( + " - name: Read pull request metadata\n", + " - uses: actions/setup-python@11bd71901bbe5b1630ceea73d27597364c9af683\n" + " - name: Read pull request metadata\n", + 1, + ) + containerized = workflow.replace( + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n container: alpine:3.20\n", + 1, + ) + serviced = workflow.replace( + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n services: {cache: {image: redis:7}}\n", + 1, + ) + + assert _uses_issues(workflow) == [] + assert _uses_issues(extra_action) + assert _uses_issues(containerized) + assert _uses_issues(serviced) + + def test_governance_shell_steps_match_the_metadata_and_evaluator_allowlists(self) -> None: + workflow = _read(PR_GOVERNANCE) + conditional = workflow.replace( + " - name: Read pull request metadata\n", + " - if: github.event.action != 'opened'\n" + " name: Read pull request metadata\n", + 1, + ) + + assert _governance_run_issues(workflow) == [] + assert _governance_run_issues(conditional) + + def test_governance_rejects_metadata_api_write_or_pr_comment_mutations(self) -> None: + workflow = _read(PR_GOVERNANCE) + api_write = workflow.replace( + 'gh api --method GET "repos/$PR_REPOSITORY/pulls/$PR_NUMBER"', + 'gh api -X POST "repos/$PR_REPOSITORY/pulls/$PR_NUMBER"', + 1, + ) + pr_comment = workflow.replace( + " python scripts/ci/check_pr_governance.py \\\n", + ' gh pr comment "$PR_NUMBER" --body blocked\n' + " python scripts/ci/check_pr_governance.py \\\n", + 1, + ) + pr_checkout = workflow.replace( + ' mkdir -p "$GOVERNANCE_TMP"\n', + ' mkdir -p "$GOVERNANCE_TMP"\n gh pr checkout "$PR_NUMBER"\n', + 1, + ) + + assert _governance_run_issues(api_write) + assert _governance_run_issues(pr_comment) + assert _governance_run_issues(pr_checkout) + + def test_governance_rejects_fetching_pr_head_or_checkout_head_mutations(self) -> None: + workflow = _read(PR_GOVERNANCE) + fetch_head = workflow.replace( + ' mkdir -p "$GOVERNANCE_TMP"\n', + ' mkdir -p "$GOVERNANCE_TMP"\n' + ' git fetch origin "${{ github.event.pull_request.head.sha }}"\n', + 1, + ) + checkout_head = workflow.replace( + "github.event.pull_request.base.sha", "github.event.pull_request.head.sha", 1 + ) + merge_ref = workflow.replace( + ' mkdir -p "$GOVERNANCE_TMP"\n', + ' mkdir -p "$GOVERNANCE_TMP"\n git status refs/pull/195/merge\n', + 1, + ) + + assert _governance_run_issues(fetch_head) + assert _checkout_issues(checkout_head) + assert _governance_run_issues(merge_ref) def test_governance_uses_only_read_only_metadata_requests_and_standard_library_checker( self, From a7ac1090b8c4dbeb3a9649c1860c525d93b428b5 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 04:50:44 +0800 Subject: [PATCH 21/32] fix(ci): allow annotated governance branch targets --- scripts/ci/check_pr_governance.py | 4 +- scripts/ci/tests/test_check_pr_governance.py | 52 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/scripts/ci/check_pr_governance.py b/scripts/ci/check_pr_governance.py index fdf9a5fc0..114414b51 100644 --- a/scripts/ci/check_pr_governance.py +++ b/scripts/ci/check_pr_governance.py @@ -161,8 +161,8 @@ def _section_field_issues(visible_body: str, kind: str) -> list[str]: def _declared_target(visible_body: str) -> str | None: value = _governance_field(visible_body, "目标分支") - match = _TARGET_RE.fullmatch(value or "") - return match.group(1).casefold() if match else None + targets = {target.casefold() for target in _TARGET_RE.findall(value or "")} + return next(iter(targets)) if len(targets) == 1 else None def _declared_risk(visible_body: str) -> str | None: diff --git a/scripts/ci/tests/test_check_pr_governance.py b/scripts/ci/tests/test_check_pr_governance.py index dcad4ab34..48f8fd4d2 100644 --- a/scripts/ci/tests/test_check_pr_governance.py +++ b/scripts/ci/tests/test_check_pr_governance.py @@ -355,6 +355,58 @@ def test_governance_target_and_risk_values_must_be_unambiguous() -> None: assert any("must name R0-R3" in issue for issue in result["issues"]) +def test_governance_target_allows_a_single_template_compatible_branch_explanation() -> None: + checker = _load_checker() + cases = ( + ( + _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev(常规变更走 dev 集成分支)", risk="R1"), + ), + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + ), + ( + _pr( + base="master", + head="hotfix/master-session-expiry", + body=_body( + target="master + hotfix/master-session-expiry 生产事故修复理由", + risk="R3", + hotfix_plan="INC-195: session expiry; dev PR #196 carries the forward-port fix", + ), + ), + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["src/backend/app/api/auth.py"], + ), + ( + _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master + release/v1.2.3 promotion 理由", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ), + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z"), + _review("maintainer-b", "APPROVED", "2026-08-24T00:02:00Z"), + ], + ["scripts/ops/release.sh"], + ), + ) + + for pr, reviews, changed_files in cases: + result = _evaluate(checker, pr, reviews, changed_files) + + assert result["ok"] is True + + def test_common_fields_are_bound_to_governance_section_not_preamble_decoys() -> None: checker = _load_checker() body = """\ From a7e6869dbeda1c6b03ff0932dfc6a71dba134405 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:12:03 +0800 Subject: [PATCH 22/32] fix(release): verify tag provenance and mirror drift --- .../governance/remote-sync-exceptions.json | 15 ++ .github/workflows/deploy-preview.yml | 106 ++------- .github/workflows/docker-publish.yml | 140 +++++------ .github/workflows/nightly.yml | 35 +++ scripts/ci/check_remote_sync.py | 219 ++++++++++++++++++ scripts/ci/tests/test_check_remote_sync.py | 146 ++++++++++++ .../tests/test_release_workflow_contract.py | 115 +++++++++ 7 files changed, 626 insertions(+), 150 deletions(-) create mode 100644 .github/governance/remote-sync-exceptions.json create mode 100644 scripts/ci/check_remote_sync.py create mode 100644 scripts/ci/tests/test_check_remote_sync.py create mode 100644 scripts/ci/tests/test_release_workflow_contract.py diff --git a/.github/governance/remote-sync-exceptions.json b/.github/governance/remote-sync-exceptions.json new file mode 100644 index 000000000..c7ba2c326 --- /dev/null +++ b/.github/governance/remote-sync-exceptions.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "exceptions": [ + { + "branch": "master", + "source_sha": "605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f", + "mirror_sha": "3d05130635f50c45adeaa4514af246380ff00451", + "issue": ".github/governance/decisions/remote-sync-incident.md", + "owner": "release/platform owner", + "reason": "D1-approved temporary master mirror divergence pending administrator review", + "created_at": "2026-08-24T00:00:00Z", + "expires_at": "2026-09-30T00:00:00Z" + } + ] +} diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index a4dda7897..0147444af 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -1,4 +1,4 @@ -name: Deploy - Preview Environment +name: Preview Build Artifact on: pull_request: @@ -10,31 +10,17 @@ on: permissions: contents: read - pull-requests: write - -env: - PYTHON_VERSION: "3.11" - NODE_VERSION: "20" jobs: - # ======================================== - # Build and Deploy Preview - # ======================================== - deploy-preview: - name: Deploy Preview Environment + preview-build: + name: Build non-hosted preview artifact runs-on: ubuntu-latest - if: github.event_name == 'pull_request' steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Generate preview URL - id: preview-url - run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - echo "Preview URL: https://pr-$PR_NUMBER.backtrader-web.preview.dev" - echo "url=https://pr-$PR_NUMBER.backtrader-web.preview.dev" >> $GITHUB_OUTPUT + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Build frontend working-directory: src/frontend @@ -42,76 +28,26 @@ jobs: npm ci npm run build - - name: Create deployment info + - name: Record non-hosted artifact scope + shell: bash run: | - cat > deployment-info.json << EOF + cat > preview-build-metadata.json << EOF { - "pr_number": "${{ github.event.pull_request.number }}", - "commit_sha": "${{ github.sha }}", - "preview_url": "${{ steps.preview-url.outputs.url }}", - "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + "commit_sha": "${GITHUB_SHA}", + "scope": "non-hosted preview build artifact", + "hosting": "not configured" } EOF + { + echo "## Preview Build Artifact" + echo "This is a non-hosted preview artifact. No environment was deployed and no URL is provided." + } >> "$GITHUB_STEP_SUMMARY" - - name: Comment PR with preview info - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const info = JSON.parse(fs.readFileSync('deployment-info.json', 'utf8')); - const previewUrl = `${{ steps.preview-url.outputs.url }}`; - - const comment = ` - ## :rocket: Preview Environment Deployed - - **PR #${info.pr_number}** is now available for testing. - - | Field | Value | - |-------|-------| - | Preview URL | ${previewUrl} | - | Commit | ${info.commit_sha.substring(0, 7)} | - | Created | ${info.created_at} | - - ### Quick Tests - - [ ] Homepage loads correctly - - [ ] Login works - - [ ] API requests succeed - - [ ] No console errors - - *This preview will be available until the PR is closed.* - `; - - // Find existing bot comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes('Preview Environment Deployed') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: comment - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); - } - - - name: Upload preview artifacts + - name: Upload preview build artifact uses: actions/upload-artifact@v4 with: - name: preview-build-${{ github.event.pull_request.number }} - path: src/frontend/dist/ + name: preview-build-${{ github.run_id }} + path: | + src/frontend/dist/ + preview-build-metadata.json retention-days: 7 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bc2eb5c36..bb9b4c27f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker - Publish Release Images +name: Docker - Release Artifact (D6 artifact-only) on: push: @@ -6,90 +6,100 @@ on: - 'v*' workflow_dispatch: inputs: - image_tag: - description: Docker image tag to publish + dry_run: + description: Dry-run artifact build only; it never logs in or publishes an image. required: true - default: v0.2.0-rc1 + default: true + type: boolean permissions: contents: read -concurrency: - group: docker-publish-${{ github.ref_name || inputs.image_tag }} - cancel-in-progress: false - jobs: - docker-publish: - name: Publish ${{ matrix.service }} image + release-artifact: + name: Verify tag provenance and build release metadata runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: backtrader-web-backend - context: . - dockerfile: src/backend/Dockerfile - - service: frontend - image: backtrader-web-frontend - context: src/frontend - dockerfile: src/frontend/Dockerfile + # D6 permits artifact builds only. The protected environment represents the + # future release boundary; this job deliberately receives no credentials. + environment: release steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout complete release history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false - - name: Resolve release metadata + - name: Resolve and verify immutable release provenance id: release shell: bash run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - IMAGE_TAG="${{ inputs.image_tag }}" - else - IMAGE_TAG="${GITHUB_REF_NAME}" + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" != "true" ]; then + echo "::error::workflow_dispatch is dry-run only." + exit 1 fi - - if [[ ! "$IMAGE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]]; then - echo "::error::Invalid image tag '$IMAGE_TAG'. Expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rcN." + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "::error::Release artifact builds must run from a protected version tag." exit 1 fi + TAG="${GITHUB_REF_NAME}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]]; then + echo "::error::Invalid release tag '$TAG'." + exit 1 + fi + TAG_COMMIT="$(git rev-list -n 1 "$TAG")" + MASTER_COMMIT="$(git rev-parse origin/master)" + if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then + echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT." + exit 1 + fi + { + echo "tag=$TAG" + echo "commit_sha=$TAG_COMMIT" + } >> "$GITHUB_OUTPUT" - echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" - echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - - name: Validate Docker Hub credentials + - name: Build non-published backend artifact shell: bash - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | - if [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then - echo "::error::DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets are required." - exit 1 - fi + set -euo pipefail + docker build --file src/backend/Dockerfile --tag "release-artifact/backend:${{ steps.release.outputs.tag }}" . - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Build non-published frontend artifact + shell: bash + run: | + set -euo pipefail + docker build --file src/frontend/Dockerfile --tag "release-artifact/frontend:${{ steps.release.outputs.tag }}" src/frontend - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Write immutable release metadata + shell: bash + run: | + set -euo pipefail + mkdir -p release-metadata + BACKEND_DIGEST="$(docker image inspect --format '{{.Id}}' "release-artifact/backend:${{ steps.release.outputs.tag }}")" + FRONTEND_DIGEST="$(docker image inspect --format '{{.Id}}' "release-artifact/frontend:${{ steps.release.outputs.tag }}")" + jq -n \ + --arg tag "${{ steps.release.outputs.tag }}" \ + --arg commit_sha "${{ steps.release.outputs.commit_sha }}" \ + --arg backend_digest "$BACKEND_DIGEST" \ + --arg frontend_digest "$FRONTEND_DIGEST" \ + --arg mode "artifact-only-d6" \ + '{tag: $tag, commit_sha: $commit_sha, backend_digest: $backend_digest, frontend_digest: $frontend_digest, mode: $mode}' \ + > release-metadata/release-metadata.json + { + echo "## Release artifact metadata" + echo "- Tag: \`${{ steps.release.outputs.tag }}\`" + echo "- Commit: \`${{ steps.release.outputs.commit_sha }}\`" + echo "- Backend immutable digest: \`$BACKEND_DIGEST\`" + echo "- Frontend immutable digest: \`$FRONTEND_DIGEST\`" + echo "- D6 status: artifact-only; no registry login or image publishing occurred." + echo "- A protected release manager must attach this metadata to an approved GitHub Release." + } >> "$GITHUB_STEP_SUMMARY" - - name: Build and push Docker image - uses: docker/build-push-action@v6 + - name: Upload release metadata artifact + uses: actions/upload-artifact@v4 with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - push: true - tags: | - docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}:${{ steps.release.outputs.image_tag }} - docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}:sha-${{ steps.release.outputs.short_sha }} - labels: | - org.opencontainers.image.title=${{ matrix.image }} - org.opencontainers.image.version=${{ steps.release.outputs.image_tag }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - cache-from: type=gha,scope=${{ matrix.service }} - cache-to: type=gha,mode=max,scope=${{ matrix.service }} + name: release-metadata-${{ steps.release.outputs.tag }} + path: release-metadata/release-metadata.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6295dcf5b..38a4a281f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,6 +11,41 @@ env: NODE_VERSION: "20" jobs: + # ======================================== + # Read-only GitHub-to-Gitee mirror evidence + # ======================================== + remote-sync-evidence: + name: Remote Sync - Read-only Evidence + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout trusted checker + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Record remote head comparison + run: | + python scripts/ci/check_remote_sync.py \ + --source https://github.com/cloudQuant/backtrader_web.git \ + --mirror https://gitee.com/yunjinqi/backtrader_web.git \ + --branches master dev \ + --exceptions .github/governance/remote-sync-exceptions.json \ + --output remote-sync-evidence.json | tee remote-sync-summary.txt + + - name: Upload remote-sync evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: nightly-remote-sync-evidence + path: | + remote-sync-evidence.json + remote-sync-summary.txt + if-no-files-found: warn + retention-days: 30 + # ======================================== # Full Test Suite # ======================================== diff --git a/scripts/ci/check_remote_sync.py b/scripts/ci/check_remote_sync.py new file mode 100644 index 000000000..82652300c --- /dev/null +++ b/scripts/ci/check_remote_sync.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Compare GitHub-authoritative refs with a read-only mirror.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from collections.abc import Callable, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +REF_PREFIX = "refs/heads/" + + +class RemoteSyncError(ValueError): + """Raised when remote evidence or its approved exception file is invalid.""" + + +RunCommand = Callable[..., subprocess.CompletedProcess[str]] + + +def parse_ls_remote_heads(output: str) -> dict[str, str]: + """Parse the exact head-ref rows emitted by ``git ls-remote --heads``.""" + heads: dict[str, str] = {} + for line in output.splitlines(): + if not line: + continue + parts = line.split("\t") + if len(parts) != 2 or not SHA_RE.fullmatch(parts[0]) or not parts[1].startswith(REF_PREFIX): + raise RemoteSyncError(f"malformed git ls-remote --heads output: {line!r}") + branch = parts[1][len(REF_PREFIX) :] + if not branch or branch in heads: + raise RemoteSyncError(f"malformed git ls-remote --heads ref: {parts[1]!r}") + heads[branch] = parts[0] + return heads + + +def _read_remote_heads(remote: str, run: RunCommand) -> dict[str, str]: + command = ["git", "ls-remote", "--heads", remote] + completed = run(command, check=False, capture_output=True, text=True, timeout=60) + if completed.returncode != 0: + detail = completed.stderr.strip() or "no stderr" + raise RemoteSyncError(f"git ls-remote --heads failed for {remote}: {detail}") + return parse_ls_remote_heads(completed.stdout) + + +def _parse_timestamp(value: object, field: str) -> datetime: + if not isinstance(value, str): + raise RemoteSyncError(f"exception {field} must be an RFC3339 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise RemoteSyncError(f"exception {field} is not a valid timestamp") from error + if parsed.tzinfo is None: + raise RemoteSyncError(f"exception {field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def load_exceptions(path: Path | None) -> list[dict[str, str]]: + """Load and validate finite, exact-SHA exception entries.""" + if path is None: + return [] + try: + payload: Any = json.loads(path.read_text(encoding="utf-8")) + except OSError as error: + raise RemoteSyncError(f"cannot read exceptions file {path}: {error}") from error + except json.JSONDecodeError as error: + raise RemoteSyncError(f"exceptions file {path} is not valid JSON") from error + if not isinstance(payload, dict) or payload.get("version") != 1: + raise RemoteSyncError("exceptions file must be a version 1 JSON object") + entries = payload.get("exceptions") + if not isinstance(entries, list): + raise RemoteSyncError("exceptions file must contain an exceptions list") + + required = { + "branch", + "source_sha", + "mirror_sha", + "issue", + "owner", + "reason", + "created_at", + "expires_at", + } + validated: list[dict[str, str]] = [] + for entry in entries: + if not isinstance(entry, dict) or not required.issubset(entry): + raise RemoteSyncError("each exception must include branch, SHAs, issue, owner, reason, and dates") + normalized = {key: entry[key] for key in required} + if not all(isinstance(value, str) and value.strip() for value in normalized.values()): + raise RemoteSyncError("exception fields must be non-empty strings") + if not SHA_RE.fullmatch(normalized["source_sha"]) or not SHA_RE.fullmatch( + normalized["mirror_sha"] + ): + raise RemoteSyncError("exception SHAs must be complete 40-character lowercase SHAs") + created = _parse_timestamp(normalized["created_at"], "created_at") + expires = _parse_timestamp(normalized["expires_at"], "expires_at") + if expires <= created: + raise RemoteSyncError("exception expires_at must be after created_at") + validated.append(normalized) + return validated + + +def _matching_exception( + exceptions: Sequence[dict[str, str]], branch: str, source_sha: str | None, mirror_sha: str | None +) -> dict[str, str] | None: + return next( + ( + entry + for entry in exceptions + if entry["branch"] == branch + and entry["source_sha"] == source_sha + and entry["mirror_sha"] == mirror_sha + ), + None, + ) + + +def check_remote_sync( + *, + source: str, + mirror: str, + branches: Sequence[str], + exceptions_path: Path | None = None, + now: datetime | None = None, + run: RunCommand = subprocess.run, +) -> dict[str, object]: + """Compare requested branch heads without changing either remote.""" + if not branches or any(not branch or branch.startswith("-") for branch in branches): + raise RemoteSyncError("at least one non-option branch name is required") + source_heads = _read_remote_heads(source, run) + mirror_heads = _read_remote_heads(mirror, run) + exceptions = load_exceptions(exceptions_path) + checked_at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + warnings: list[str] = [] + mismatches: list[dict[str, str | None]] = [] + + for branch in branches: + source_sha = source_heads.get(branch) + mirror_sha = mirror_heads.get(branch) + if source_sha == mirror_sha and source_sha is not None: + continue + exception = _matching_exception(exceptions, branch, source_sha, mirror_sha) + if exception is not None and checked_at <= _parse_timestamp(exception["expires_at"], "expires_at"): + warnings.append( + "approved temporary divergence " + f"branch={branch} issue={exception['issue']} owner={exception['owner']} " + f"expires_at={exception['expires_at']}" + ) + continue + status = "expired_exception" if exception is not None else "failed" + mismatches.append( + { + "branch": branch, + "source_sha": source_sha, + "mirror_sha": mirror_sha, + "status": status, + } + ) + + return { + "ok": not mismatches, + "checked_at": checked_at.isoformat().replace("+00:00", "Z"), + "source": source, + "mirror": mirror, + "branches": list(branches), + "warnings": warnings, + "mismatches": mismatches, + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, help="authoritative GitHub remote URL") + parser.add_argument("--mirror", required=True, help="read-only mirror remote URL") + parser.add_argument("--branches", nargs="+", required=True, help="branch names to compare") + parser.add_argument("--exceptions", type=Path, help="temporary exact-SHA exception JSON") + parser.add_argument("--output", type=Path, help="write machine-readable JSON evidence here") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + result = check_remote_sync( + source=args.source, + mirror=args.mirror, + branches=args.branches, + exceptions_path=args.exceptions, + ) + except RemoteSyncError as error: + print(f"REMOTE_SYNC_ERROR: {error}") + return 2 + evidence = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output is not None: + args.output.write_text(evidence, encoding="utf-8") + else: + print(evidence, end="") + for warning in result["warnings"]: + print(f"REMOTE_SYNC_WARNING: {warning}") + for mismatch in result["mismatches"]: + print( + "REMOTE_SYNC_MISMATCH: " + f"branch={mismatch['branch']} source={mismatch['source_sha']} " + f"mirror={mismatch['mirror_sha']} status={mismatch['status']}" + ) + if result["ok"]: + print("REMOTE_SYNC_SUMMARY: requested branch heads are equal or temporarily excepted") + return 0 + print("REMOTE_SYNC_SUMMARY: unresolved mirror drift requires human review") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/tests/test_check_remote_sync.py b/scripts/ci/tests/test_check_remote_sync.py new file mode 100644 index 000000000..b4e32bcad --- /dev/null +++ b/scripts/ci/tests/test_check_remote_sync.py @@ -0,0 +1,146 @@ +"""Contract tests for the read-only GitHub-to-Gitee ref comparison.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from types import ModuleType + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +CHECKER_PATH = REPO_ROOT / "scripts" / "ci" / "check_remote_sync.py" + +SOURCE_MASTER = "605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f" +MIRROR_MASTER = "3d05130635f50c45adeaa4514af246380ff00451" +DEV_SHA = "ebec2a0adf0f239784edbe4d2f3221ac581bd65e" + + +def _load_checker() -> ModuleType: + assert CHECKER_PATH.is_file(), "Task 5 remote-sync checker is not implemented" + spec = importlib.util.spec_from_file_location("check_remote_sync_under_test", CHECKER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(spec.name, None) + return module + + +def _heads(master: str, dev: str = DEV_SHA) -> str: + return f"{master}\trefs/heads/master\n{dev}\trefs/heads/dev\n" + + +def _fake_runner(outputs: dict[str, str]): + calls: list[list[str]] = [] + + def run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout=outputs[command[-1]], stderr="") + + return run, calls + + +def _exception(expires_at: str = "2026-09-30T00:00:00Z") -> dict[str, object]: + return { + "branch": "master", + "source_sha": SOURCE_MASTER, + "mirror_sha": MIRROR_MASTER, + "issue": ".github/governance/decisions/remote-sync-incident.md", + "owner": "release/platform owner", + "reason": "D1-approved temporary master mirror divergence", + "created_at": "2026-08-24T00:00:00Z", + "expires_at": expires_at, + } + + +def _exceptions_file(tmp_path: Path, entry: dict[str, object]) -> Path: + path = tmp_path / "exceptions.json" + path.write_text(json.dumps({"version": 1, "exceptions": [entry]}), encoding="utf-8") + return path + + +def test_equal_heads_pass_without_warnings_or_network_access() -> None: + checker = _load_checker() + runner, calls = _fake_runner({"source": _heads(DEV_SHA), "mirror": _heads(DEV_SHA)}) + + result = checker.check_remote_sync( + source="source", mirror="mirror", branches=("master", "dev"), run=runner + ) + + assert result["ok"] is True + assert result["warnings"] == [] + assert calls == [ + ["git", "ls-remote", "--heads", "source"], + ["git", "ls-remote", "--heads", "mirror"], + ] + + +def test_unexcepted_mismatch_fails() -> None: + checker = _load_checker() + runner, _ = _fake_runner({"source": _heads(SOURCE_MASTER), "mirror": _heads(MIRROR_MASTER)}) + + result = checker.check_remote_sync( + source="source", mirror="mirror", branches=("master", "dev"), run=runner + ) + + assert result["ok"] is False + assert result["warnings"] == [] + assert result["mismatches"] == [ + { + "branch": "master", + "source_sha": SOURCE_MASTER, + "mirror_sha": MIRROR_MASTER, + "status": "failed", + } + ] + + +def test_unexpired_exact_exception_warns_but_allows_known_divergence(tmp_path: Path) -> None: + checker = _load_checker() + runner, _ = _fake_runner({"source": _heads(SOURCE_MASTER), "mirror": _heads(MIRROR_MASTER)}) + + result = checker.check_remote_sync( + source="source", + mirror="mirror", + branches=("master", "dev"), + exceptions_path=_exceptions_file(tmp_path, _exception()), + now=datetime(2026, 8, 24, tzinfo=timezone.utc), + run=runner, + ) + + assert result["ok"] is True + assert result["mismatches"] == [] + assert len(result["warnings"]) == 1 + assert "expires_at=2026-09-30T00:00:00Z" in result["warnings"][0] + + +def test_expired_exception_fails(tmp_path: Path) -> None: + checker = _load_checker() + runner, _ = _fake_runner({"source": _heads(SOURCE_MASTER), "mirror": _heads(MIRROR_MASTER)}) + + result = checker.check_remote_sync( + source="source", + mirror="mirror", + branches=("master", "dev"), + exceptions_path=_exceptions_file(tmp_path, _exception("2026-08-24T00:00:01Z")), + now=datetime(2026, 8, 25, tzinfo=timezone.utc), + run=runner, + ) + + assert result["ok"] is False + assert result["warnings"] == [] + assert result["mismatches"][0]["status"] == "expired_exception" + + +def test_malformed_remote_output_is_a_specific_error() -> None: + checker = _load_checker() + + with pytest.raises(checker.RemoteSyncError, match="malformed"): + checker.parse_ls_remote_heads("not-a-git-ls-remote-line\n") diff --git a/scripts/ci/tests/test_release_workflow_contract.py b/scripts/ci/tests/test_release_workflow_contract.py new file mode 100644 index 000000000..31aeee0a0 --- /dev/null +++ b/scripts/ci/tests/test_release_workflow_contract.py @@ -0,0 +1,115 @@ +"""Task 5 static contracts for release, preview, and nightly evidence workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +DOCKER_PUBLISH = WORKFLOWS / "docker-publish.yml" +PREVIEW = WORKFLOWS / "deploy-preview.yml" +NIGHTLY = WORKFLOWS / "nightly.yml" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _workflow(path: Path) -> dict[str, object]: + parsed = yaml.load(_read(path), Loader=yaml.BaseLoader) + assert isinstance(parsed, dict), f"{path.name} must be a YAML mapping" + return parsed + + +def _walk(node: object) -> list[object]: + values = [node] + if isinstance(node, dict): + for value in node.values(): + values.extend(_walk(value)) + elif isinstance(node, list): + for value in node: + values.extend(_walk(value)) + return values + + +def _release_issues(workflow: str) -> list[str]: + parsed = yaml.load(workflow, Loader=yaml.BaseLoader) + assert isinstance(parsed, dict) + issues: list[str] = [] + events = parsed.get("on") + if not isinstance(events, dict) or "push" not in events or "workflow_dispatch" not in events: + issues.append("release must support protected tag pushes and an explicit dry-run dispatch") + if "image_tag" in workflow: + issues.append("manual image tag input is prohibited") + if "fetch-depth: 0" not in workflow or "origin/master" not in workflow: + issues.append("release must compare a tag commit to fully fetched origin/master") + if "TAG_COMMIT" not in workflow or "MASTER_COMMIT" not in workflow or '"$TAG_COMMIT" != "$MASTER_COMMIT"' not in workflow: + issues.append("tag provenance must require commit equality, not ancestry") + if "docker/login-action" in workflow or "DOCKERHUB_TOKEN" in workflow or "push: true" in workflow: + issues.append("D6 artifact-only workflow must not use production credentials or push") + if "environment: release" not in workflow: + issues.append("release boundary must be represented by the protected release environment") + if "backend_digest" not in workflow or "frontend_digest" not in workflow or "commit_sha" not in workflow: + issues.append("release metadata must contain immutable image digests, tag, and commit SHA") + if "release-metadata" not in workflow or "GITHUB_STEP_SUMMARY" not in workflow: + issues.append("release metadata must be retained as an artifact and job summary") + if "dry-run" not in workflow.lower(): + issues.append("workflow_dispatch must be visibly dry-run") + return issues + + +def _preview_issues(workflow: str) -> list[str]: + parsed = yaml.load(workflow, Loader=yaml.BaseLoader) + assert isinstance(parsed, dict) + issues: list[str] = [] + if parsed.get("name") != "Preview Build Artifact": + issues.append("preview workflow must identify itself as an artifact build") + if "pull-requests: write" in workflow or "github-script" in workflow: + issues.append("preview build must not comment on pull requests") + if "preview_url" in workflow or "Preview URL" in workflow or "Deployed" in workflow: + issues.append("preview build must not promise a hosted URL or deployment") + if "upload-artifact" not in workflow or "non-hosted preview" not in workflow.lower(): + issues.append("preview workflow must upload and describe a non-hosted artifact") + return issues + + +def _nightly_issues(workflow: str) -> list[str]: + parsed = yaml.load(workflow, Loader=yaml.BaseLoader) + assert isinstance(parsed, dict) + issues: list[str] = [] + if "remote-sync-evidence" not in workflow or "check_remote_sync.py" not in workflow: + issues.append("nightly must retain read-only remote sync evidence") + if "git ls-remote --heads" in workflow: + issues.append("nightly must delegate remote reads to the checked script") + if "notification sent" in workflow.lower() or "notify owner" in workflow.lower(): + issues.append("nightly cannot claim an unavailable D1 notification integration") + return issues + + +def test_workflows_parse_with_yaml_base_loader() -> None: + for path in (DOCKER_PUBLISH, PREVIEW, NIGHTLY): + assert _workflow(path) + + +def test_release_requires_exact_tag_provenance_and_artifact_only_boundary() -> None: + assert _release_issues(_read(DOCKER_PUBLISH)) == [] + + +def test_release_contract_rejects_ancestry_only_provenance() -> None: + unsafe = _read(DOCKER_PUBLISH).replace( + '"$TAG_COMMIT" != "$MASTER_COMMIT"', + '"$TAG_COMMIT" "!=" "$MASTER_COMMIT"', + 1, + ) + + assert "tag provenance" in " ".join(_release_issues(unsafe)) + + +def test_preview_is_non_hosted_artifact_without_pull_request_writes() -> None: + assert _preview_issues(_read(PREVIEW)) == [] + + +def test_nightly_keeps_read_only_remote_sync_artifact() -> None: + assert _nightly_issues(_read(NIGHTLY)) == [] From da53b99a5be0f31706368fd54cfb89dc193840cd Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:24:54 +0800 Subject: [PATCH 23/32] fix(release): harden artifact release contracts --- .../governance/remote-sync-exceptions.json | 4 +- .github/workflows/deploy-preview.yml | 2 +- .github/workflows/docker-publish.yml | 22 +- .github/workflows/nightly.yml | 4 +- scripts/ci/check_remote_sync.py | 29 ++- scripts/ci/tests/test_check_remote_sync.py | 109 ++++++--- .../tests/test_release_workflow_contract.py | 221 +++++++++++++----- 7 files changed, 293 insertions(+), 98 deletions(-) diff --git a/.github/governance/remote-sync-exceptions.json b/.github/governance/remote-sync-exceptions.json index c7ba2c326..99e7486a2 100644 --- a/.github/governance/remote-sync-exceptions.json +++ b/.github/governance/remote-sync-exceptions.json @@ -6,9 +6,9 @@ "source_sha": "605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f", "mirror_sha": "3d05130635f50c45adeaa4514af246380ff00451", "issue": ".github/governance/decisions/remote-sync-incident.md", - "owner": "release/platform owner", + "owner": "@cloudQuant", "reason": "D1-approved temporary master mirror divergence pending administrator review", - "created_at": "2026-08-24T00:00:00Z", + "created_at": "2026-08-23T00:00:00Z", "expires_at": "2026-09-30T00:00:00Z" } ] diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 0147444af..e475b4261 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -44,7 +44,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload preview build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: preview-build-${{ github.run_id }} path: | diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bb9b4c27f..2f8646471 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,6 +39,10 @@ jobs: echo "::error::workflow_dispatch is dry-run only." exit 1 fi + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then + echo "::error::Tag-push artifact builds require a protected release tag." + exit 1 + fi if [ "${GITHUB_REF_TYPE}" != "tag" ]; then echo "::error::Release artifact builds must run from a protected version tag." exit 1 @@ -48,6 +52,7 @@ jobs: echo "::error::Invalid release tag '$TAG'." exit 1 fi + git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master TAG_COMMIT="$(git rev-list -n 1 "$TAG")" MASTER_COMMIT="$(git rev-parse origin/master)" if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then @@ -76,28 +81,29 @@ jobs: run: | set -euo pipefail mkdir -p release-metadata - BACKEND_DIGEST="$(docker image inspect --format '{{.Id}}' "release-artifact/backend:${{ steps.release.outputs.tag }}")" - FRONTEND_DIGEST="$(docker image inspect --format '{{.Id}}' "release-artifact/frontend:${{ steps.release.outputs.tag }}")" + BACKEND_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "release-artifact/backend:${{ steps.release.outputs.tag }}")" + FRONTEND_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "release-artifact/frontend:${{ steps.release.outputs.tag }}")" jq -n \ --arg tag "${{ steps.release.outputs.tag }}" \ --arg commit_sha "${{ steps.release.outputs.commit_sha }}" \ - --arg backend_digest "$BACKEND_DIGEST" \ - --arg frontend_digest "$FRONTEND_DIGEST" \ + --arg backend_image_id "$BACKEND_IMAGE_ID" \ + --arg frontend_image_id "$FRONTEND_IMAGE_ID" \ --arg mode "artifact-only-d6" \ - '{tag: $tag, commit_sha: $commit_sha, backend_digest: $backend_digest, frontend_digest: $frontend_digest, mode: $mode}' \ + '{tag: $tag, commit_sha: $commit_sha, backend_image_id: $backend_image_id, frontend_image_id: $frontend_image_id, mode: $mode}' \ > release-metadata/release-metadata.json { echo "## Release artifact metadata" echo "- Tag: \`${{ steps.release.outputs.tag }}\`" echo "- Commit: \`${{ steps.release.outputs.commit_sha }}\`" - echo "- Backend immutable digest: \`$BACKEND_DIGEST\`" - echo "- Frontend immutable digest: \`$FRONTEND_DIGEST\`" + echo "- Backend local Docker image ID: \`$BACKEND_IMAGE_ID\`" + echo "- Frontend local Docker image ID: \`$FRONTEND_IMAGE_ID\`" echo "- D6 status: artifact-only; no registry login or image publishing occurred." + echo "- No registry manifest digest exists because the images were not published." echo "- A protected release manager must attach this metadata to an approved GitHub Release." } >> "$GITHUB_STEP_SUMMARY" - name: Upload release metadata artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-metadata-${{ steps.release.outputs.tag }} path: release-metadata/release-metadata.json diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 38a4a281f..ae0103f4d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -27,7 +27,9 @@ jobs: persist-credentials: false - name: Record remote head comparison + shell: bash run: | + set -euo pipefail python scripts/ci/check_remote_sync.py \ --source https://github.com/cloudQuant/backtrader_web.git \ --mirror https://gitee.com/yunjinqi/backtrader_web.git \ @@ -37,7 +39,7 @@ jobs: - name: Upload remote-sync evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nightly-remote-sync-evidence path: | diff --git a/scripts/ci/check_remote_sync.py b/scripts/ci/check_remote_sync.py index 82652300c..99d583617 100644 --- a/scripts/ci/check_remote_sync.py +++ b/scripts/ci/check_remote_sync.py @@ -14,6 +14,12 @@ SHA_RE = re.compile(r"^[0-9a-f]{40}$") REF_PREFIX = "refs/heads/" +APPROVED_BRANCH = "master" +APPROVED_SOURCE_SHA = "605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f" +APPROVED_MIRROR_SHA = "3d05130635f50c45adeaa4514af246380ff00451" +APPROVED_INCIDENT = ".github/governance/decisions/remote-sync-incident.md" +APPROVED_OWNER = "@cloudQuant" +MAX_EXCEPTION_EXPIRY = datetime(2026, 9, 30, tzinfo=timezone.utc) class RemoteSyncError(ValueError): @@ -60,8 +66,8 @@ def _parse_timestamp(value: object, field: str) -> datetime: return parsed.astimezone(timezone.utc) -def load_exceptions(path: Path | None) -> list[dict[str, str]]: - """Load and validate finite, exact-SHA exception entries.""" +def load_exceptions(path: Path | None, *, now: datetime | None = None) -> list[dict[str, str]]: + """Load the single D1-approved exception without widening its scope.""" if path is None: return [] try: @@ -86,6 +92,7 @@ def load_exceptions(path: Path | None) -> list[dict[str, str]]: "created_at", "expires_at", } + checked_at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) validated: list[dict[str, str]] = [] for entry in entries: if not isinstance(entry, dict) or not required.issubset(entry): @@ -97,10 +104,24 @@ def load_exceptions(path: Path | None) -> list[dict[str, str]]: normalized["mirror_sha"] ): raise RemoteSyncError("exception SHAs must be complete 40-character lowercase SHAs") + if normalized["branch"] != APPROVED_BRANCH: + raise RemoteSyncError("only the approved master branch may be excepted") + if normalized["source_sha"] != APPROVED_SOURCE_SHA: + raise RemoteSyncError("exception source SHA must equal the approved source SHA") + if normalized["mirror_sha"] != APPROVED_MIRROR_SHA: + raise RemoteSyncError("exception mirror SHA must equal the approved mirror SHA") + if normalized["issue"] != APPROVED_INCIDENT: + raise RemoteSyncError("exception issue must cite the approved local incident") + if normalized["owner"] != APPROVED_OWNER: + raise RemoteSyncError("exception owner must be @cloudQuant") created = _parse_timestamp(normalized["created_at"], "created_at") expires = _parse_timestamp(normalized["expires_at"], "expires_at") if expires <= created: raise RemoteSyncError("exception expires_at must be after created_at") + if created > checked_at: + raise RemoteSyncError("exception created_at must not be in the future") + if expires > MAX_EXCEPTION_EXPIRY: + raise RemoteSyncError("exception expires_at must not exceed 2026-09-30T00:00:00Z") validated.append(normalized) return validated @@ -134,8 +155,8 @@ def check_remote_sync( raise RemoteSyncError("at least one non-option branch name is required") source_heads = _read_remote_heads(source, run) mirror_heads = _read_remote_heads(mirror, run) - exceptions = load_exceptions(exceptions_path) checked_at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + exceptions = load_exceptions(exceptions_path, now=checked_at) warnings: list[str] = [] mismatches: list[dict[str, str | None]] = [] @@ -145,7 +166,7 @@ def check_remote_sync( if source_sha == mirror_sha and source_sha is not None: continue exception = _matching_exception(exceptions, branch, source_sha, mirror_sha) - if exception is not None and checked_at <= _parse_timestamp(exception["expires_at"], "expires_at"): + if exception is not None and checked_at < _parse_timestamp(exception["expires_at"], "expires_at"): warnings.append( "approved temporary divergence " f"branch={branch} issue={exception['issue']} owner={exception['owner']} " diff --git a/scripts/ci/tests/test_check_remote_sync.py b/scripts/ci/tests/test_check_remote_sync.py index b4e32bcad..00a604a6c 100644 --- a/scripts/ci/tests/test_check_remote_sync.py +++ b/scripts/ci/tests/test_check_remote_sync.py @@ -1,4 +1,4 @@ -"""Contract tests for the read-only GitHub-to-Gitee ref comparison.""" +"""Offline contracts for the read-only GitHub-to-Gitee ref comparison.""" from __future__ import annotations @@ -18,6 +18,7 @@ SOURCE_MASTER = "605d4d0e1cf1ad6627483aab6c4cef2a742b3d0f" MIRROR_MASTER = "3d05130635f50c45adeaa4514af246380ff00451" DEV_SHA = "ebec2a0adf0f239784edbe4d2f3221ac581bd65e" +CHECKED_AT = datetime(2026, 8, 24, tzinfo=timezone.utc) def _load_checker() -> ModuleType: @@ -37,27 +38,33 @@ def _heads(master: str, dev: str = DEV_SHA) -> str: return f"{master}\trefs/heads/master\n{dev}\trefs/heads/dev\n" -def _fake_runner(outputs: dict[str, str]): +def _fake_runner( + outputs: dict[str, str], *, returncode: int = 0, stderr: str = "" +) -> tuple[object, list[list[str]]]: calls: list[list[str]] = [] def run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: calls.append(command) - return subprocess.CompletedProcess(command, 0, stdout=outputs[command[-1]], stderr="") + return subprocess.CompletedProcess( + command, returncode, stdout=outputs.get(command[-1], ""), stderr=stderr + ) return run, calls -def _exception(expires_at: str = "2026-09-30T00:00:00Z") -> dict[str, object]: - return { +def _exception(**changes: str) -> dict[str, object]: + entry: dict[str, object] = { "branch": "master", "source_sha": SOURCE_MASTER, "mirror_sha": MIRROR_MASTER, "issue": ".github/governance/decisions/remote-sync-incident.md", - "owner": "release/platform owner", + "owner": "@cloudQuant", "reason": "D1-approved temporary master mirror divergence", - "created_at": "2026-08-24T00:00:00Z", - "expires_at": expires_at, + "created_at": "2026-08-23T00:00:00Z", + "expires_at": "2026-09-30T00:00:00Z", } + entry.update(changes) + return entry def _exceptions_file(tmp_path: Path, entry: dict[str, object]) -> Path: @@ -66,12 +73,12 @@ def _exceptions_file(tmp_path: Path, entry: dict[str, object]) -> Path: return path -def test_equal_heads_pass_without_warnings_or_network_access() -> None: +def test_equal_heads_pass_and_only_execute_ls_remote_heads() -> None: checker = _load_checker() runner, calls = _fake_runner({"source": _heads(DEV_SHA), "mirror": _heads(DEV_SHA)}) result = checker.check_remote_sync( - source="source", mirror="mirror", branches=("master", "dev"), run=runner + source="source", mirror="mirror", branches=("master", "dev"), now=CHECKED_AT, run=runner ) assert result["ok"] is True @@ -82,24 +89,23 @@ def test_equal_heads_pass_without_warnings_or_network_access() -> None: ] -def test_unexcepted_mismatch_fails() -> None: +def test_unexcepted_mismatch_and_changed_sha_with_old_exception_fail(tmp_path: Path) -> None: checker = _load_checker() - runner, _ = _fake_runner({"source": _heads(SOURCE_MASTER), "mirror": _heads(MIRROR_MASTER)}) + changed_source = "a" * 40 + runner, _ = _fake_runner({"source": _heads(changed_source), "mirror": _heads(MIRROR_MASTER)}) result = checker.check_remote_sync( - source="source", mirror="mirror", branches=("master", "dev"), run=runner + source="source", + mirror="mirror", + branches=("master", "dev"), + exceptions_path=_exceptions_file(tmp_path, _exception()), + now=CHECKED_AT, + run=runner, ) assert result["ok"] is False assert result["warnings"] == [] - assert result["mismatches"] == [ - { - "branch": "master", - "source_sha": SOURCE_MASTER, - "mirror_sha": MIRROR_MASTER, - "status": "failed", - } - ] + assert result["mismatches"][0]["status"] == "failed" def test_unexpired_exact_exception_warns_but_allows_known_divergence(tmp_path: Path) -> None: @@ -111,17 +117,40 @@ def test_unexpired_exact_exception_warns_but_allows_known_divergence(tmp_path: P mirror="mirror", branches=("master", "dev"), exceptions_path=_exceptions_file(tmp_path, _exception()), - now=datetime(2026, 8, 24, tzinfo=timezone.utc), + now=CHECKED_AT, run=runner, ) assert result["ok"] is True assert result["mismatches"] == [] - assert len(result["warnings"]) == 1 - assert "expires_at=2026-09-30T00:00:00Z" in result["warnings"][0] + assert result["warnings"] == [ + "approved temporary divergence " + "branch=master issue=.github/governance/decisions/remote-sync-incident.md " + "owner=@cloudQuant expires_at=2026-09-30T00:00:00Z" + ] + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"branch": "dev"}, "master"), + ({"source_sha": "a" * 40}, "approved source"), + ({"mirror_sha": "b" * 40}, "approved mirror"), + ({"owner": "release/platform owner"}, "owner"), + ({"created_at": "2026-08-25T00:00:00Z"}, "future"), + ({"expires_at": "2026-10-01T00:00:00Z"}, "2026-09-30"), + ], +) +def test_exception_policy_rejects_any_scope_widening( + tmp_path: Path, changes: dict[str, str], message: str +) -> None: + checker = _load_checker() + + with pytest.raises(checker.RemoteSyncError, match=message): + checker.load_exceptions(_exceptions_file(tmp_path, _exception(**changes)), now=CHECKED_AT) -def test_expired_exception_fails(tmp_path: Path) -> None: +def test_exception_is_inactive_at_its_exact_expiry(tmp_path: Path) -> None: checker = _load_checker() runner, _ = _fake_runner({"source": _heads(SOURCE_MASTER), "mirror": _heads(MIRROR_MASTER)}) @@ -129,16 +158,40 @@ def test_expired_exception_fails(tmp_path: Path) -> None: source="source", mirror="mirror", branches=("master", "dev"), - exceptions_path=_exceptions_file(tmp_path, _exception("2026-08-24T00:00:01Z")), - now=datetime(2026, 8, 25, tzinfo=timezone.utc), + exceptions_path=_exceptions_file(tmp_path, _exception()), + now=datetime(2026, 9, 30, tzinfo=timezone.utc), run=runner, ) assert result["ok"] is False - assert result["warnings"] == [] assert result["mismatches"][0]["status"] == "expired_exception" +def test_nonzero_git_command_is_reported_without_generic_exception() -> None: + checker = _load_checker() + runner, calls = _fake_runner({}, returncode=128, stderr="remote unavailable") + + with pytest.raises(checker.RemoteSyncError, match="remote unavailable"): + checker.check_remote_sync( + source="source", mirror="mirror", branches=("master",), now=CHECKED_AT, run=runner + ) + assert calls == [["git", "ls-remote", "--heads", "source"]] + + +def test_main_returns_machine_readable_error_status(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + checker = _load_checker() + + def fail(**_: object) -> dict[str, object]: + raise checker.RemoteSyncError("fake command failure") + + monkeypatch.setattr(checker, "check_remote_sync", fail) + + status = checker.main(["--source", "source", "--mirror", "mirror", "--branches", "master"]) + + assert status == 2 + assert "REMOTE_SYNC_ERROR: fake command failure" in capsys.readouterr().out + + def test_malformed_remote_output_is_a_specific_error() -> None: checker = _load_checker() diff --git a/scripts/ci/tests/test_release_workflow_contract.py b/scripts/ci/tests/test_release_workflow_contract.py index 31aeee0a0..bd065d778 100644 --- a/scripts/ci/tests/test_release_workflow_contract.py +++ b/scripts/ci/tests/test_release_workflow_contract.py @@ -1,7 +1,8 @@ -"""Task 5 static contracts for release, preview, and nightly evidence workflows.""" +"""Task 5 structural contracts for release, preview, and nightly workflows.""" from __future__ import annotations +import re from pathlib import Path import yaml @@ -11,105 +12,217 @@ DOCKER_PUBLISH = WORKFLOWS / "docker-publish.yml" PREVIEW = WORKFLOWS / "deploy-preview.yml" NIGHTLY = WORKFLOWS / "nightly.yml" +CHECKOUT_PIN = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" +UPLOAD_PIN = "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" +PINNED_ACTION = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") def _read(path: Path) -> str: return path.read_text(encoding="utf-8") -def _workflow(path: Path) -> dict[str, object]: - parsed = yaml.load(_read(path), Loader=yaml.BaseLoader) - assert isinstance(parsed, dict), f"{path.name} must be a YAML mapping" +def _workflow(workflow: str) -> dict[str, object]: + parsed = yaml.load(workflow, Loader=yaml.BaseLoader) + assert isinstance(parsed, dict), "workflow must be a YAML mapping" return parsed -def _walk(node: object) -> list[object]: - values = [node] - if isinstance(node, dict): - for value in node.values(): - values.extend(_walk(value)) - elif isinstance(node, list): - for value in node: - values.extend(_walk(value)) - return values +def _jobs(workflow: str) -> dict[str, object]: + jobs = _workflow(workflow).get("jobs") + assert isinstance(jobs, dict), "workflow jobs must be a mapping" + return jobs + + +def _job(workflow: str, job_id: str) -> dict[str, object]: + job = _jobs(workflow).get(job_id) + assert isinstance(job, dict), f"missing {job_id} job" + return job + + +def _steps(job: dict[str, object]) -> list[dict[str, object]]: + steps = job.get("steps") + assert isinstance(steps, list), "job steps must be a list" + return [step for step in steps if isinstance(step, dict)] + + +def _step(job: dict[str, object], name: str) -> tuple[int, dict[str, object]]: + for index, step in enumerate(_steps(job)): + if step.get("name") == name: + return index, step + raise AssertionError(f"missing step: {name}") + + +def _run(step: dict[str, object]) -> str: + run = step.get("run") + assert isinstance(run, str), f"{step.get('name')} must be a run step" + return "\n".join(line for line in run.splitlines() if not line.lstrip().startswith("#")) + + +def _uses(step: dict[str, object]) -> str: + uses = step.get("uses") + assert isinstance(uses, str), f"{step.get('name')} must use an action" + return uses.split(" #", 1)[0] def _release_issues(workflow: str) -> list[str]: - parsed = yaml.load(workflow, Loader=yaml.BaseLoader) - assert isinstance(parsed, dict) issues: list[str] = [] + parsed = _workflow(workflow) events = parsed.get("on") - if not isinstance(events, dict) or "push" not in events or "workflow_dispatch" not in events: - issues.append("release must support protected tag pushes and an explicit dry-run dispatch") + if not isinstance(events, dict) or not {"push", "workflow_dispatch"}.issubset(events): + issues.append("release triggers must contain tag push and dry-run dispatch") + elif not isinstance(events["push"], dict) or events["push"].get("tags") != ["v*"]: + issues.append("release must only trigger on version tags") + + release = _job(workflow, "release-artifact") + if release.get("environment") != "release": + issues.append("release boundary must use the protected release environment") + try: + _, checkout = _step(release, "Checkout complete release history") + checkout_with = checkout.get("with") + if _uses(checkout) != CHECKOUT_PIN or not isinstance(checkout_with, dict): + issues.append("release checkout must use the reviewed immutable action pin") + elif checkout_with.get("fetch-depth") != "0" or checkout_with.get("persist-credentials") != "false": + issues.append("release checkout must fully fetch without persisted credentials") + resolve_index, resolve = _step(release, "Resolve and verify immutable release provenance") + resolve_script = _run(resolve) + protected_gate = ( + 'github.event_name }}" = "push"' in resolve_script + and 'github.ref_protected }}" != "true"' in resolve_script + ) + if not protected_gate: + issues.append("tag-push release must reject an unprotected ref before build") + if 'git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master' not in resolve_script: + issues.append("release must explicitly fetch origin/master before comparing it") + if 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then' not in resolve_script: + issues.append("tag provenance must require exact equality, not ancestry") + if "merge-base --is-ancestor" in resolve_script: + issues.append("tag provenance must not accept ancestry in place of equality") + if 'github.event_name }}" = "workflow_dispatch"' not in resolve_script or "dry-run only" not in resolve_script: + issues.append("workflow_dispatch must remain visibly dry-run") + except AssertionError as error: + issues.append(str(error)) + resolve_index = -1 + + raw = "\n".join(_run(step) for step in _steps(release) if isinstance(step.get("run"), str)) if "image_tag" in workflow: issues.append("manual image tag input is prohibited") - if "fetch-depth: 0" not in workflow or "origin/master" not in workflow: - issues.append("release must compare a tag commit to fully fetched origin/master") - if "TAG_COMMIT" not in workflow or "MASTER_COMMIT" not in workflow or '"$TAG_COMMIT" != "$MASTER_COMMIT"' not in workflow: - issues.append("tag provenance must require commit equality, not ancestry") - if "docker/login-action" in workflow or "DOCKERHUB_TOKEN" in workflow or "push: true" in workflow: - issues.append("D6 artifact-only workflow must not use production credentials or push") - if "environment: release" not in workflow: - issues.append("release boundary must be represented by the protected release environment") - if "backend_digest" not in workflow or "frontend_digest" not in workflow or "commit_sha" not in workflow: - issues.append("release metadata must contain immutable image digests, tag, and commit SHA") - if "release-metadata" not in workflow or "GITHUB_STEP_SUMMARY" not in workflow: - issues.append("release metadata must be retained as an artifact and job summary") - if "dry-run" not in workflow.lower(): - issues.append("workflow_dispatch must be visibly dry-run") + if re.search(r"\b(?:docker\s+push|docker/login-action)\b|\bpush:\s*true\b|secrets\.", workflow): + issues.append("artifact-only release must not publish or access production credentials") + if "backend_digest" in raw.lower() or "frontend_digest" in raw.lower() or "immutable digest" in raw.lower(): + issues.append("artifact-only metadata must not call local image IDs registry digests") + if "backend_image_id" not in raw or "frontend_image_id" not in raw or "commit_sha" not in raw: + issues.append("release metadata must record tag, commit SHA, and both local image IDs") + if "docker image inspect --format '{{.Id}}'" not in raw or "local Docker image ID" not in raw: + issues.append("release metadata must identify local image IDs without claiming a registry digest") + if "release-metadata" not in workflow or "GITHUB_STEP_SUMMARY" not in raw: + issues.append("release metadata must be retained as an artifact and summary") + + build_indexes = [ + index + for index, step in enumerate(_steps(release)) + if isinstance(step.get("run"), str) and "docker build" in _run(step) + ] + if not build_indexes or any(index <= resolve_index for index in build_indexes): + issues.append("all artifact builds must follow protected tag provenance") + try: + _, upload = _step(release, "Upload release metadata artifact") + if _uses(upload) != UPLOAD_PIN: + issues.append("release artifact upload must use the reviewed immutable action pin") + except AssertionError as error: + issues.append(str(error)) return issues def _preview_issues(workflow: str) -> list[str]: - parsed = yaml.load(workflow, Loader=yaml.BaseLoader) - assert isinstance(parsed, dict) issues: list[str] = [] + parsed = _workflow(workflow) if parsed.get("name") != "Preview Build Artifact": issues.append("preview workflow must identify itself as an artifact build") - if "pull-requests: write" in workflow or "github-script" in workflow: - issues.append("preview build must not comment on pull requests") - if "preview_url" in workflow or "Preview URL" in workflow or "Deployed" in workflow: - issues.append("preview build must not promise a hosted URL or deployment") - if "upload-artifact" not in workflow or "non-hosted preview" not in workflow.lower(): - issues.append("preview workflow must upload and describe a non-hosted artifact") + preview = _job(workflow, "preview-build") + raw = _read(PREVIEW) if workflow == _read(PREVIEW) else workflow + if re.search(r"pull-requests:\s*write|github-script|preview_url|Preview URL|Deployed", raw): + issues.append("preview build must not write PR comments or promise a hosted deployment") + try: + _, upload = _step(preview, "Upload preview build artifact") + if _uses(upload) != UPLOAD_PIN: + issues.append("preview artifact upload must use the reviewed immutable action pin") + _, scope = _step(preview, "Record non-hosted artifact scope") + if "non-hosted preview" not in _run(scope).lower(): + issues.append("preview artifact must explicitly state it is non-hosted") + except AssertionError as error: + issues.append(str(error)) return issues def _nightly_issues(workflow: str) -> list[str]: - parsed = yaml.load(workflow, Loader=yaml.BaseLoader) - assert isinstance(parsed, dict) issues: list[str] = [] - if "remote-sync-evidence" not in workflow or "check_remote_sync.py" not in workflow: - issues.append("nightly must retain read-only remote sync evidence") - if "git ls-remote --heads" in workflow: - issues.append("nightly must delegate remote reads to the checked script") - if "notification sent" in workflow.lower() or "notify owner" in workflow.lower(): - issues.append("nightly cannot claim an unavailable D1 notification integration") + remote_sync = _job(workflow, "remote-sync-evidence") + raw = "\n".join(_run(step) for step in _steps(remote_sync) if isinstance(step.get("run"), str)) + if "check_remote_sync.py" not in raw or "git ls-remote --heads" in raw: + issues.append("nightly must call the checked read-only sync script, not inline Git") + if re.search(r"notification sent|notify owner", workflow, flags=re.IGNORECASE): + issues.append("nightly cannot claim an unavailable notification integration") + try: + _, evidence = _step(remote_sync, "Record remote head comparison") + evidence_run = _run(evidence) + if evidence.get("shell") != "bash" or "set -euo pipefail" not in evidence_run: + issues.append("nightly remote sync pipeline must fail closed with Bash pipefail") + if "| tee remote-sync-summary.txt" not in evidence_run: + issues.append("nightly remote sync must retain its human summary") + _, upload = _step(remote_sync, "Upload remote-sync evidence") + if upload.get("if") != "always()" or _uses(upload) != UPLOAD_PIN: + issues.append("nightly must always upload evidence with the reviewed immutable action pin") + except AssertionError as error: + issues.append(str(error)) return issues def test_workflows_parse_with_yaml_base_loader() -> None: for path in (DOCKER_PUBLISH, PREVIEW, NIGHTLY): - assert _workflow(path) + assert _workflow(_read(path)) -def test_release_requires_exact_tag_provenance_and_artifact_only_boundary() -> None: +def test_release_requires_protected_tag_exact_provenance_and_image_ids() -> None: assert _release_issues(_read(DOCKER_PUBLISH)) == [] -def test_release_contract_rejects_ancestry_only_provenance() -> None: +def test_release_contract_rejects_unprotected_tag_bypass() -> None: + unsafe = _read(DOCKER_PUBLISH).replace('github.ref_protected }}" != "true"', '"true" != "true"', 1) + + assert "unprotected ref" in " ".join(_release_issues(unsafe)) + + +def test_release_contract_rejects_dead_or_ancestry_only_equality_snippet() -> None: unsafe = _read(DOCKER_PUBLISH).replace( - '"$TAG_COMMIT" != "$MASTER_COMMIT"', - '"$TAG_COMMIT" "!=" "$MASTER_COMMIT"', + 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then', + '# if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n if git merge-base --is-ancestor "$TAG_COMMIT" "$MASTER_COMMIT"; then', 1, ) - assert "tag provenance" in " ".join(_release_issues(unsafe)) + assert "exact equality" in " ".join(_release_issues(unsafe)) + + +def test_release_contract_rejects_digest_claims_or_mutable_upload_action() -> None: + unsafe = _read(DOCKER_PUBLISH).replace("backend_image_id", "backend_digest", 1) + unsafe = unsafe.replace(UPLOAD_PIN, "actions/upload-artifact@v4", 1) + + issues = " ".join(_release_issues(unsafe)) + assert "registry digests" in issues + assert "immutable action pin" in issues def test_preview_is_non_hosted_artifact_without_pull_request_writes() -> None: assert _preview_issues(_read(PREVIEW)) == [] -def test_nightly_keeps_read_only_remote_sync_artifact() -> None: +def test_nightly_failure_pipeline_is_fail_closed_and_evidence_is_always_uploaded() -> None: assert _nightly_issues(_read(NIGHTLY)) == [] + + +def test_nightly_contract_rejects_missing_pipefail_or_mutable_upload_action() -> None: + unsafe = _read(NIGHTLY).replace("set -euo pipefail", "set -eu", 1) + unsafe = unsafe.replace(UPLOAD_PIN, "actions/upload-artifact@v4", 1) + + issues = " ".join(_nightly_issues(unsafe)) + assert "fail closed" in issues + assert "immutable action pin" in issues From 9ca9068e25b1fc50137f41081da2db05ccbd1881 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:31:07 +0800 Subject: [PATCH 24/32] test(release): verify artifact gate execution paths --- .github/workflows/nightly.yml | 2 +- .../tests/test_release_workflow_contract.py | 127 +++++++++++++++++- 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ae0103f4d..311b90772 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -35,7 +35,7 @@ jobs: --mirror https://gitee.com/yunjinqi/backtrader_web.git \ --branches master dev \ --exceptions .github/governance/remote-sync-exceptions.json \ - --output remote-sync-evidence.json | tee remote-sync-summary.txt + --output remote-sync-evidence.json 2>&1 | tee remote-sync-summary.txt - name: Upload remote-sync evidence if: always() diff --git a/scripts/ci/tests/test_release_workflow_contract.py b/scripts/ci/tests/test_release_workflow_contract.py index bd065d778..a830145af 100644 --- a/scripts/ci/tests/test_release_workflow_contract.py +++ b/scripts/ci/tests/test_release_workflow_contract.py @@ -2,7 +2,10 @@ from __future__ import annotations +import os import re +import subprocess +import tempfile from pathlib import Path import yaml @@ -64,6 +67,75 @@ def _uses(step: dict[str, object]) -> str: return uses.split(" #", 1)[0] +def _provenance_result( + workflow: str, *, protected: bool, tag_commit: str, master_commit: str +) -> tuple[subprocess.CompletedProcess[str], str]: + """Run the extracted workflow provenance step against a fake local Git.""" + release = _job(workflow, "release-artifact") + _, provenance = _step(release, "Resolve and verify immutable release provenance") + script = _run(provenance) + script = script.replace("${{ github.event_name }}", "push") + script = script.replace("${{ inputs.dry_run }}", "true") + script = script.replace("${{ github.ref_protected }}", str(protected).lower()) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fake_bin = root / "bin" + fake_bin.mkdir() + fake_git = fake_bin / "git" + fake_git.write_text( + "#!/bin/sh\n" + "case \"$1\" in\n" + " fetch) exit 0 ;;\n" + " rev-list) printf '%s\\n' \"$FAKE_TAG_COMMIT\" ;;\n" + " rev-parse) printf '%s\\n' \"$FAKE_MASTER_COMMIT\" ;;\n" + " *) echo \"unexpected fake git command: $*\" >&2; exit 64 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_git.chmod(0o755) + output = root / "github-output" + environment = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "FAKE_TAG_COMMIT": tag_commit, + "FAKE_MASTER_COMMIT": master_commit, + "GITHUB_REF_TYPE": "tag", + "GITHUB_REF_NAME": "v1.2.3", + "GITHUB_OUTPUT": str(output), + } + result = subprocess.run( + ["bash", "-c", script], + cwd=root, + env=environment, + check=False, + capture_output=True, + text=True, + ) + return result, output.read_text(encoding="utf-8") if output.exists() else "" + + +def _provenance_semantic_issues(workflow: str) -> list[str]: + equal_sha = "a" * 40 + unprotected, unprotected_output = _provenance_result( + workflow, protected=False, tag_commit=equal_sha, master_commit=equal_sha + ) + mismatch, mismatch_output = _provenance_result( + workflow, protected=True, tag_commit="b" * 40, master_commit=equal_sha + ) + accepted, accepted_output = _provenance_result( + workflow, protected=True, tag_commit=equal_sha, master_commit=equal_sha + ) + issues: list[str] = [] + if unprotected.returncode == 0 or unprotected_output: + issues.append("semantic protection gate must fail before writing success output") + if mismatch.returncode == 0 or mismatch_output: + issues.append("semantic equality gate must fail before writing success output") + if accepted.returncode != 0 or "tag=v1.2.3" not in accepted_output: + issues.append("semantic protected exact tag must write success output") + return issues + + def _release_issues(workflow: str) -> list[str]: issues: list[str] = [] parsed = _workflow(workflow) @@ -99,6 +171,7 @@ def _release_issues(workflow: str) -> list[str]: issues.append("tag provenance must not accept ancestry in place of equality") if 'github.event_name }}" = "workflow_dispatch"' not in resolve_script or "dry-run only" not in resolve_script: issues.append("workflow_dispatch must remain visibly dry-run") + issues.extend(_provenance_semantic_issues(workflow)) except AssertionError as error: issues.append(str(error)) resolve_index = -1 @@ -106,7 +179,11 @@ def _release_issues(workflow: str) -> list[str]: raw = "\n".join(_run(step) for step in _steps(release) if isinstance(step.get("run"), str)) if "image_tag" in workflow: issues.append("manual image tag input is prohibited") - if re.search(r"\b(?:docker\s+push|docker/login-action)\b|\bpush:\s*true\b|secrets\.", workflow): + if re.search( + r"\bdocker\s+(?:push|login)\b|\bdocker\s+buildx\s+build\b[^\n]*\B--push\b|" + r"\B--output\s+type=registry\b|docker/login-action|\bpush:\s*true\b|secrets\.", + workflow, + ): issues.append("artifact-only release must not publish or access production credentials") if "backend_digest" in raw.lower() or "frontend_digest" in raw.lower() or "immutable digest" in raw.lower(): issues.append("artifact-only metadata must not call local image IDs registry digests") @@ -167,8 +244,8 @@ def _nightly_issues(workflow: str) -> list[str]: evidence_run = _run(evidence) if evidence.get("shell") != "bash" or "set -euo pipefail" not in evidence_run: issues.append("nightly remote sync pipeline must fail closed with Bash pipefail") - if "| tee remote-sync-summary.txt" not in evidence_run: - issues.append("nightly remote sync must retain its human summary") + if "2>&1 | tee remote-sync-summary.txt" not in evidence_run: + issues.append("nightly remote sync must retain both stdout and stderr in its human summary") _, upload = _step(remote_sync, "Upload remote-sync evidence") if upload.get("if") != "always()" or _uses(upload) != UPLOAD_PIN: issues.append("nightly must always upload evidence with the reviewed immutable action pin") @@ -202,6 +279,38 @@ def test_release_contract_rejects_dead_or_ancestry_only_equality_snippet() -> No assert "exact equality" in " ".join(_release_issues(unsafe)) +def test_release_contract_rejects_unreachable_protection_and_equality_guards() -> None: + unsafe = _read(DOCKER_PUBLISH).replace( + 'if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' + ' echo "::error::Tag-push artifact builds require a protected release tag."\n' + " exit 1\n" + " fi", + 'if false; then\n' + ' if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' + ' echo "::error::Tag-push artifact builds require a protected release tag."\n' + " exit 1\n" + " fi\n" + " fi", + 1, + ).replace( + 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' + ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' + " exit 1\n" + " fi", + 'if false; then\n' + ' if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' + ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' + " exit 1\n" + " fi\n" + " fi", + 1, + ) + + issues = " ".join(_release_issues(unsafe)) + assert "semantic protection gate" in issues + assert "semantic equality gate" in issues + + def test_release_contract_rejects_digest_claims_or_mutable_upload_action() -> None: unsafe = _read(DOCKER_PUBLISH).replace("backend_image_id", "backend_digest", 1) unsafe = unsafe.replace(UPLOAD_PIN, "actions/upload-artifact@v4", 1) @@ -211,6 +320,14 @@ def test_release_contract_rejects_digest_claims_or_mutable_upload_action() -> No assert "immutable action pin" in issues +def test_release_contract_rejects_buildx_push_or_registry_login() -> None: + unsafe = _read(DOCKER_PUBLISH).replace( + "docker build --file", "docker buildx build --push --file", 1 + ) + + assert "must not publish" in " ".join(_release_issues(unsafe)) + + def test_preview_is_non_hosted_artifact_without_pull_request_writes() -> None: assert _preview_issues(_read(PREVIEW)) == [] @@ -219,10 +336,12 @@ def test_nightly_failure_pipeline_is_fail_closed_and_evidence_is_always_uploaded assert _nightly_issues(_read(NIGHTLY)) == [] -def test_nightly_contract_rejects_missing_pipefail_or_mutable_upload_action() -> None: +def test_nightly_contract_rejects_missing_pipefail_stderr_capture_or_mutable_upload_action() -> None: unsafe = _read(NIGHTLY).replace("set -euo pipefail", "set -eu", 1) + unsafe = unsafe.replace("2>&1 | tee remote-sync-summary.txt", "| tee remote-sync-summary.txt", 1) unsafe = unsafe.replace(UPLOAD_PIN, "actions/upload-artifact@v4", 1) issues = " ".join(_nightly_issues(unsafe)) assert "fail closed" in issues + assert "stdout and stderr" in issues assert "immutable action pin" in issues From d50717c0f74765e19252f7182da62d1a50b2524c Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:38:22 +0800 Subject: [PATCH 25/32] docs(governance): record rollout evidence and rollback runbook --- .../evidence/dry-run-matrix.md" | 54 +++++++++++++ .../evidence/rollback-runbook.md" | 66 ++++++++++++++++ .../evidence/ruleset-readback.md" | 77 +++++++++++++++++++ .../evidence/weekly-metrics-schema.md" | 41 ++++++++++ 4 files changed, 238 insertions(+) create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/rollback-runbook.md" create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" create mode 100644 "docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/weekly-metrics-schema.md" diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" index 178ea06f0..15976ed5f 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" @@ -21,3 +21,57 @@ Run 证据;该 relay 未获批前不得作为 D4 通过依据。 - 不得把 `Governance Gate` 填入 `dev.json` 或 `master.json` 的 required-check context; - 不得将本地 workflow job id 视为 GitHub 实际 Check Run context; - 不得把 shadow 观察期、D3 Ruleset 应用或 D6 tag/release-environment 状态标为完成。 + +## 未执行的 10 个工作日 shadow 观察日志模板 + +状态:**模板未执行。** `PR Governance` 在 shadow 期只能报告,不得成为 required check。每个 +工作日由治理负责人填一行,并为异常附 incident/PR 证据的受控引用;空白或 `N/A` 不得被解释为 +“无问题”。观察期结束前,任何未解释的 P0/P1 误阻塞都必须重开相应任务,D4 继续保持 blocked。 + +| 工作日序号 | 收集范围/数据截止说明 | 错误分类 | 遗漏或重复 check | fork PR 反馈 | 误投 `master` | owner 请求 | 执行时间 | P0/P1、处置与证据引用 | 审阅结论 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 2 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 3 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 4 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 5 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 6 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 7 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 8 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 9 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 10 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | + +## 三类 PR 演练证据记录(均未执行) + +以下是待真实草稿 PR 演练后填写的记录,不含真实 URL、SHA、check context、参与者或时间。 +不得以本模板、截图占位符或本地 job 名称解除 D4。 + +### PR-E1:R1 常规 PR → `dev` + +| 字段 | 待真实演练填写 | +| --- | --- | +| PR 证据引用 | 待填写 | +| base/head 提交证据 | 待填写 | +| 实际 Check Run display name/context | 待填写 | +| 受信任事件与维护者重跑观察 | 待填写 | +| 审批、结果与无不必要重型阻断证据 | 待填写 | + +### PR-E2:R2 核心路径 PR → `dev` + +| 字段 | 待真实演练填写 | +| --- | --- | +| PR 证据引用 | 待填写 | +| base/head 提交证据 | 待填写 | +| 自动升级、owner 请求与额外测试证据 | 待填写 | +| 实际 Check Run display name/context | 待填写 | +| 审批、结果与可读诊断 | 待填写 | + +### PR-E3:`hotfix/master-*` 或 `release/vX.Y.Z` PR → `master` + +| 字段 | 待真实演练填写 | +| --- | --- | +| PR 证据引用 | 待填写 | +| 来源命名与 base/head 提交证据 | 待填写 | +| 实际 Check Run display name/context | 待填写 | +| 两人审批、完整 checks、前移或 release metadata 证据 | 待填写 | +| 结果与未使用直接 push 的证据 | 待填写 | diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/rollback-runbook.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/rollback-runbook.md" new file mode 100644 index 000000000..664c0ee18 --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/rollback-runbook.md" @@ -0,0 +1,66 @@ +# 治理回滚手册(模板;未执行) + +状态:**未执行。** 本地文档变更不等同于 GitHub 平台变更。本手册为被授权人员在真实 incident +中使用的程序,不授权本任务执行外部设置、远端同步、发布、push、merge 或 bypass。 + +## 责任与授权 + +| 场景 | 负责人 | 所需授权 | 不可替代的证据 | +| --- | --- | --- | --- | +| workflow/doc 回滚 | 变更 PR owner + 维护者 | 常规 PR 审核与合并授权 | revert PR、失败证据和 review 记录 | +| Ruleset evaluate/停用 | 仓库管理员 | GitHub Ruleset 管理权限与 incident 授权 | 受影响规则读回、incident 和恢复检查 | +| CODEOWNERS 修复 | CODEOWNERS owner + 维护者 | 常规 PR 审核;重新启用前由仓库管理员确认 | 修复 PR、CODEOWNERS errors 读回、owner 可用性证据 | +| 镜像/镜像发布异常 | 发布负责人 + D1 指定负责人 | incident 响应与明确的人工同步/发布授权 | 停止记录、只读差异证据和后续决策 | + +## Incident 记录模板 + +| 字段 | 内容 | +| --- | --- | +| incident 编号/受控链接 | 待真实 incident 填写 | +| 发现方式与影响开始范围 | 待填写 | +| 受影响 PR、ref、workflow 或发布对象 | 待填写 | +| 严重度、用户/贡献者影响和证据 | 待填写 | +| 事件指挥、执行者与授权依据 | 待填写 | +| 临时缓解、恢复条件与验证结果 | 待填写 | +| 后续修复 PR、复盘责任人和到期日 | 待填写 | + +## 1. Workflow 或文档回滚 + +1. 保存失败的 workflow run、PR 诊断或文档问题证据,并创建 incident(如达到 incident 门槛)。 +2. 通过新的 PR 对目标变更执行 `revert`;不要直接推送修改历史。 +3. 在 revert PR 中说明影响、保留原失败证据引用,并按受影响风险级别完成 review 与 checks。 +4. 合并后验证受影响文档/工作流恢复到预期行为;失败证据、revert PR 和验证结论一并保存。 + +## 2. Ruleset 误阻塞 + +1. 仓库管理员建立 incident,列出受影响 PR、阻断表现、规则范围、临时负责人和恢复条件。 +2. 先读取并保存规则现状与受影响 PR 证据。若平台支持 evaluate,管理员可将**对应**规则改为 + evaluate;否则可暂时停用**对应** Ruleset。不得扩大到无关规则或用常规 bypass 代替处置。 +3. 修正 desired manifest、workflow 或 required-check 合同的根因后,通过 PR 评审。 +4. 重新启用前,确认真实 required-check context 已稳定、D2/D3/D4/D6 前置仍满足,并执行 API、 + CODEOWNERS errors 与 verifier 读回;不一致则保持停用/评估并升级。 +5. 更新 incident:影响 PR、停用窗口、恢复证据和复盘到期日。此过程不使 D3/D4/D6 自动通过。 + +## 3. CODEOWNERS 错误 + +1. 保持 required code-owner review 未启用或在受影响 Ruleset 中暂停,避免把贡献者锁死。 +2. 通过 PR 修正 CODEOWNERS 路径或 owner;不得用不存在的账户、全局管理员常规 bypass 或手工批准 + 冒充 code-owner review。 +3. 合并修复后读取 CODEOWNERS errors,并确认每个受影响保护域有真实、可访问且满足 D2 的 owner。 +4. 只有 errors 为空、owner 证据完备且管理员完成 Ruleset 读回验证后,才重新启用该要求。 + +## 4. 镜像、远端或镜像发布异常 + +1. 立即停止后续发布/镜像动作并创建 incident;保存只读 ref、digest 或 workflow 诊断证据。 +2. 不自动覆盖任一远端,不自动重推 tag/image,也不依据本地分支或文档推断权威状态。 +3. 由 D1 指定负责人和具备明确授权的管理员决定人工恢复方案;先明确权威 ref、影响、审批与恢复 + 检查,再单独执行获批动作。 +4. 在 incident 中登记恢复后的只读核验、例外到期日和复盘;无法核验时保持发布停止并升级。 + +## 强制禁止项与边界 + +- 禁止 force-push、删除/重写远端历史、自动外部修复、自动远端覆盖,以及以 bot label 或本地模板 + 代替人工授权。 +- 禁止在回滚期间自动绕过 Ruleset、伪造 check、伪造 release/tag/digest 或把未执行的命令记为完成。 +- 本地文档或 manifest 的 PR 只能改变仓库内容;Ruleset、CODEOWNERS API 状态、受保护环境、tag、 + 镜像和远端 ref 仍属于独立的平台/发布变更,必须按本手册取得授权并保留读回证据。 diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" new file mode 100644 index 000000000..5c66df9b4 --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" @@ -0,0 +1,77 @@ +# Ruleset 读回记录(未执行模板) + +状态:**未执行。** 本文件不是 GitHub Ruleset、CODEOWNERS 或 verifier 的运行输出,也不能证明 +任何远端设置已激活、读回成功或与 desired manifest 一致。 + +## 当前前置阻塞清单 + +| Gate | 状态 | 读回前必须如实处理的阻塞 | +| --- | --- | --- | +| D2 | 未满足完整审批池条件 | R2/R3 与 `master` 的第二独立维护者审批池尚未确认;不得据此启用 required code-owner review。 | +| D3 | 有到期日的例外 | Ruleset 字段能力、evaluate 支持和 emergency bypass 的外部能力验证尚未完成;本地 manifest 仅是 desired state。 | +| D4 | blocked | 三类真实草稿 PR 尚未产生经读取确认的稳定 required-check context;不得猜测或回填 context。 | +| D6 | 有到期日的例外 | 受保护 release environment、tag 权限与真实授权主体尚未由管理员配置和验证;不得激活 `release-tags`。 | + +## 后续的只读读回命令(不得在本任务执行) + +当授权管理员完成相应外部设置后,按每次设置立即执行以下只读命令,并把**实际输出的受控摘要** +附到对应记录,而不是填写猜测值: + +```bash +gh api repos/cloudQuant/backtrader_web/rulesets --paginate +gh api repos/cloudQuant/backtrader_web/codeowners/errors +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python \ + scripts/ci/verify_github_governance.py --live \ + --repo cloudQuant/backtrader_web --manifest-dir .github/governance/rulesets +``` + +这些命令用于读回和比对;它们不授权创建、修改、删除 Ruleset,也不授权 bypass、push 或 merge。 + +## 待填读回记录 + +每条记录均须有一次 API Ruleset 清单、一次 CODEOWNERS errors 结果和一次 verifier 输出的关联证据。 +“无记录”“本地 JSON 存在”或人工口头确认均不是读回。 + +### `dev` + +| 预期证据字段 | 待真实读回填写 | +| --- | --- | +| 读回范围与收集时点 | 待填写 | +| API 返回中匹配的 ref/规则摘要(不填造 ID) | 待填写 | +| enforcement、PR/审批、force/delete、code-owner、bypass 字段 | 待填写 | +| 实际 required checks 及其 D4 证据来源 | 待填写 | +| CODEOWNERS errors 结果 | 待填写 | +| verifier 退出状态与差异摘要 | 待填写 | +| 与 `.github/governance/rulesets/dev.json` 的逐字段比较 | 待填写 | + +### `master` + +| 预期证据字段 | 待真实读回填写 | +| --- | --- | +| 读回范围与收集时点 | 待填写 | +| API 返回中匹配的 ref/规则摘要(不填造 ID) | 待填写 | +| enforcement、PR/审批、force/delete、code-owner、bypass 字段 | 待填写 | +| 实际 required checks 及其 D4 证据来源 | 待填写 | +| CODEOWNERS errors 结果 | 待填写 | +| verifier 退出状态与差异摘要 | 待填写 | +| 与 `.github/governance/rulesets/master.json` 的逐字段比较 | 待填写 | + +### `release-tags` + +| 预期证据字段 | 待真实读回填写 | +| --- | --- | +| 读回范围与收集时点 | 待填写 | +| API 返回中匹配的 tag ref/规则摘要(不填造 ID) | 待填写 | +| enforcement、tag 创建/更新/删除、bypass 字段 | 待填写 | +| D6 tag 权限、受保护 environment 与 provenance 证据来源 | 待填写 | +| verifier 退出状态与差异摘要 | 待填写 | +| 与 `.github/governance/rulesets/release-tags.json` 的逐字段比较 | 待填写 | + +## 比对、差异与升级规则 + +1. 逐字段将读回的归一化结果与同名 desired manifest 比较;required check 还必须回链到 + `dry-run-matrix.md` 中真实的 D4 证据。 +2. 任何缺失、额外、值不一致、CODEOWNERS error、verifier 非零或无法读取均为未通过;保留原始受控 + 证据、记录影响范围,并升级给仓库管理员和对应 gate owner。 +3. 禁止填造 Ruleset ID、URL、SHA、actor、check context 或时间来让比对看似通过。外部能力或权限 + 不足时,保留为阻塞/例外,不能以本地 manifest 替代平台证据。 diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/weekly-metrics-schema.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/weekly-metrics-schema.md" new file mode 100644 index 000000000..fde6ee8a7 --- /dev/null +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/weekly-metrics-schema.md" @@ -0,0 +1,41 @@ +# 四周治理度量口径(未开始模板) + +状态:**未开始。** 以下口径规定后续可复现的收集方法;没有样本或尚未收集时必须填写 `N/A`, +不得推断为零、100% 或治理完成。 + +## 通用收集规则 + +- **source system:**GitHub PR、review、check 与 Ruleset 审计记录为主;GitHub/Gitee 的只读 + remote-sync 检查输出用于同步延迟。每个单元格同时记录提取日期、查询范围和保存位置。 +- **纳入范围:**收集周内创建、关闭或合并的本仓库 PR/治理事件,具体按各指标分子分母的事件时间 + 计算;同一 PR 的重跑不重复计作 PR 总数。 +- **排除:**训练、测试、垃圾、重复、管理员明确标记为无效且保留理由的记录;删除的、无法审计的或 + 个人敏感评论不进入统计。排除项必须列出数量和理由。 +- **数据最小化:**周报只保留 PR 编号/受控证据引用、分支类别、事件时间差和聚合数;不复制私人 + 评论、token、邮箱、个人访问令牌或不必要的身份信息。 + +## 指标定义 + +| 指标 | 定义与公式 | 分子 | 分母 | source system | 排除规则 | 收集日期 | +| --- | --- | --- | --- | --- | --- | --- | +| PR 总数 | 收集周内纳入范围 PR 的计数 | 纳入 PR 数 | 不适用 | GitHub PR 列表/导出 | 按通用排除 | 待填写 | +| 错误目标分支率 | `错误目标分支 PR ÷ 纳入 PR` | 常规变更目标为 `master` 等违反分支语义的 PR | 纳入 PR 总数 | GitHub PR base/head 元数据与治理结果 | 合法 `release/*`/`hotfix/master-*` 目标 `master` 不计错 | 待填写 | +| 首次实质评审时间 | 每个已获实质人工评审 PR 的 `首次实质评审 - PR 创建`;报告中位数及样本数 | 时间差样本之和/分布 | 获得实质人工评审的纳入 PR 数 | GitHub PR/review 时间线 | 作者自评、bot、仅标签或空泛评论不算实质评审 | 待填写 | +| 合并周期 | 每个已合并 PR 的 `合并 - PR 创建`;报告中位数及样本数 | 时间差样本之和/分布 | 已合并纳入 PR 数 | GitHub PR 时间线 | 未合并、关闭未合并 PR 不进该指标 | 待填写 | +| 治理门禁误报/漏报 | 分别为 `误报 ÷ 门禁阻断判定`、`漏报 ÷ 事后确认应阻断判定` | 经复核的误报数/漏报数 | 对应经复核判定总数 | Governance Gate 结果、incident、人工复核 | 未完成复核的事件单列待定,不混入分母 | 待填写 | +| R2/R3 owner 覆盖率 | `满足所需 owner 请求及有效审批的 R2/R3 PR ÷ 纳入 R2/R3 PR` | 证据完整的 R2/R3 PR 数 | 纳入 R2/R3 PR 总数 | 风险分类、review request、review 记录 | 风险分类尚未可审计者列为 N/A/数据质量问题 | 待填写 | +| 热修复前移完成率 | `已按约定前移并留证的已合并 master hotfix ÷ 已合并 master hotfix` | 有前移 PR 或批准的 release metadata 证据的 hotfix 数 | 已合并 `hotfix/master-*` PR 数 | GitHub PR、release metadata | 未闭环的 hotfix 仍在分母;无 hotfix 样本为 N/A | 待填写 | +| remote sync 延迟 | 每个允许监控的同步事件 `镜像观察到对应权威 ref - 权威 ref 变更`;报告中位数、最大值及样本数 | 时间差样本之和/分布 | 可比较同步事件数 | 只读 remote-sync JSON/incident 记录 | D1 已批准且未到期的例外单列;不可比较事件为 N/A | 待填写 | +| bypass 数 | 收集周内可审计的 Ruleset/管理员 bypass 事件计数 | 有 incident、理由、授权与 24 小时复盘关联的 bypass 数 | 不适用;同时报告未关联 incident 的异常数 | Ruleset 审计、incident、PR 记录 | 本地文档示例、非平台事件不计入 | 待填写 | + +## 四周记录模板 + +| 周次 | 提取日期与范围 | PR 总数 | 错误目标分支率 | 首次实质评审时间 | 合并周期 | 误报/漏报 | R2/R3 owner 覆盖 | 热修复前移 | remote sync 延迟 | bypass 数 | 排除、N/A 与数据质量说明 | 证据保存位置 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 第 1 周 | 待填写 | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 尚未开始收集 | 待填写 | +| 第 2 周 | 待填写 | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 尚未开始收集 | 待填写 | +| 第 3 周 | 待填写 | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 尚未开始收集 | 待填写 | +| 第 4 周 | 待填写 | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 尚未开始收集 | 待填写 | + +`N/A` 仅表示分母为零、没有可审计样本或数据源不可用,且必须在“排除、N/A 与数据质量说明”中 +写明原因;它不能表示成功、失败或零 bypass。 From f2ad157d52d36c9f6e85ff315a6a471789c1eb68 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:43:53 +0800 Subject: [PATCH 26/32] docs(governance): clarify rollout gate evidence --- .../evidence/dry-run-matrix.md" | 22 +++++++++---------- .../evidence/ruleset-readback.md" | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" index 15976ed5f..c4206f796 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/dry-run-matrix.md" @@ -28,18 +28,18 @@ Run 证据;该 relay 未获批前不得作为 D4 通过依据。 工作日由治理负责人填一行,并为异常附 incident/PR 证据的受控引用;空白或 `N/A` 不得被解释为 “无问题”。观察期结束前,任何未解释的 P0/P1 误阻塞都必须重开相应任务,D4 继续保持 blocked。 -| 工作日序号 | 收集范围/数据截止说明 | 错误分类 | 遗漏或重复 check | fork PR 反馈 | 误投 `master` | owner 请求 | 执行时间 | P0/P1、处置与证据引用 | 审阅结论 | +| 工作日序号 | 收集范围/数据截止说明 | 错误分类 | 遗漏或重复 check | fork PR 反馈 | 误投 `master` | owner 请求结果(成功/失败/N/A)与证据引用 | 执行时间 | P0/P1、处置与证据引用 | 审阅结论 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 2 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 3 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 4 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 5 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 6 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 7 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 8 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 9 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | -| 10 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | 待填写 | +| 1 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 2 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 3 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 4 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 5 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 6 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 7 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 8 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 9 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | +| 10 | 待真实观察 | 待填写 | 待填写 | 待填写 | 待填写 | 待真实观察:结果与证据引用待填写 | 待填写 | 待填写 | 待填写 | ## 三类 PR 演练证据记录(均未执行) diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" index 5c66df9b4..cc2a6c247 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/evidence/ruleset-readback.md" @@ -5,12 +5,12 @@ ## 当前前置阻塞清单 -| Gate | 状态 | 读回前必须如实处理的阻塞 | -| --- | --- | --- | -| D2 | 未满足完整审批池条件 | R2/R3 与 `master` 的第二独立维护者审批池尚未确认;不得据此启用 required code-owner review。 | -| D3 | 有到期日的例外 | Ruleset 字段能力、evaluate 支持和 emergency bypass 的外部能力验证尚未完成;本地 manifest 仅是 desired state。 | -| D4 | blocked | 三类真实草稿 PR 尚未产生经读取确认的稳定 required-check context;不得猜测或回填 context。 | -| D6 | 有到期日的例外 | 受保护 release environment、tag 权限与真实授权主体尚未由管理员配置和验证;不得激活 `release-tags`。 | +| Gate | 状态 | 读回前必须如实处理的阻塞 | 责任/到期日 | 受控来源 | +| --- | --- | --- | --- | --- | +| D2 | 有到期日的例外 | R2/R3 与 `master` 的第二独立维护者审批池尚未确认;不得据此启用 required code-owner review。 | 核心维护者 `@cloudQuant`;2026-10-01 前补充备用 owner 并复核可访问性。 | `.github/governance/decisions/iteration-195.md`(D2);[preflight](preflight.md) §4。 | +| D3 | 有到期日的例外 | Ruleset 字段能力、evaluate 支持和 emergency bypass 的外部能力验证尚未完成;本地 manifest 仅是 desired state。 | 仓库管理员 `@cloudQuant`;2026-10-15 前完成沙盒验证及只读 verifier 对比。 | `.github/governance/decisions/iteration-195.md`(D3);[preflight](preflight.md) §4。 | +| D4 | blocked | 三类真实草稿 PR 尚未产生经读取确认的稳定 required-check context;不得猜测或回填 context。 | CI owner `@cloudQuant`;无已批准的到期日。 | `.github/governance/decisions/iteration-195.md`(D4);[preflight](preflight.md) §4。 | +| D6 | 有到期日的例外 | 受保护 release environment、tag 权限与真实授权主体尚未由管理员配置和验证;不得激活 `release-tags`。 | 发布负责人 + 仓库管理员 `@cloudQuant`;2026-10-15 前配置受保护 `release` environment 及审批规则。 | `.github/governance/decisions/iteration-195.md`(D6);[preflight](preflight.md) §4。 | ## 后续的只读读回命令(不得在本任务执行) From b1fe4c6b72fb8a65e7ed492c4376ea2867989a6f Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Mon, 24 Aug 2026 05:46:09 +0800 Subject: [PATCH 27/32] style(release): format governance contracts --- scripts/ci/check_remote_sync.py | 13 ++- scripts/ci/tests/test_check_remote_sync.py | 4 +- .../tests/test_release_workflow_contract.py | 101 +++++++++++------- 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/scripts/ci/check_remote_sync.py b/scripts/ci/check_remote_sync.py index 99d583617..fc06e7c8e 100644 --- a/scripts/ci/check_remote_sync.py +++ b/scripts/ci/check_remote_sync.py @@ -96,7 +96,9 @@ def load_exceptions(path: Path | None, *, now: datetime | None = None) -> list[d validated: list[dict[str, str]] = [] for entry in entries: if not isinstance(entry, dict) or not required.issubset(entry): - raise RemoteSyncError("each exception must include branch, SHAs, issue, owner, reason, and dates") + raise RemoteSyncError( + "each exception must include branch, SHAs, issue, owner, reason, and dates" + ) normalized = {key: entry[key] for key in required} if not all(isinstance(value, str) and value.strip() for value in normalized.values()): raise RemoteSyncError("exception fields must be non-empty strings") @@ -127,7 +129,10 @@ def load_exceptions(path: Path | None, *, now: datetime | None = None) -> list[d def _matching_exception( - exceptions: Sequence[dict[str, str]], branch: str, source_sha: str | None, mirror_sha: str | None + exceptions: Sequence[dict[str, str]], + branch: str, + source_sha: str | None, + mirror_sha: str | None, ) -> dict[str, str] | None: return next( ( @@ -166,7 +171,9 @@ def check_remote_sync( if source_sha == mirror_sha and source_sha is not None: continue exception = _matching_exception(exceptions, branch, source_sha, mirror_sha) - if exception is not None and checked_at < _parse_timestamp(exception["expires_at"], "expires_at"): + if exception is not None and checked_at < _parse_timestamp( + exception["expires_at"], "expires_at" + ): warnings.append( "approved temporary divergence " f"branch={branch} issue={exception['issue']} owner={exception['owner']} " diff --git a/scripts/ci/tests/test_check_remote_sync.py b/scripts/ci/tests/test_check_remote_sync.py index 00a604a6c..12e8393f5 100644 --- a/scripts/ci/tests/test_check_remote_sync.py +++ b/scripts/ci/tests/test_check_remote_sync.py @@ -178,7 +178,9 @@ def test_nonzero_git_command_is_reported_without_generic_exception() -> None: assert calls == [["git", "ls-remote", "--heads", "source"]] -def test_main_returns_machine_readable_error_status(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: +def test_main_returns_machine_readable_error_status( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: checker = _load_checker() def fail(**_: object) -> dict[str, object]: diff --git a/scripts/ci/tests/test_release_workflow_contract.py b/scripts/ci/tests/test_release_workflow_contract.py index a830145af..3fc1a6432 100644 --- a/scripts/ci/tests/test_release_workflow_contract.py +++ b/scripts/ci/tests/test_release_workflow_contract.py @@ -85,11 +85,11 @@ def _provenance_result( fake_git = fake_bin / "git" fake_git.write_text( "#!/bin/sh\n" - "case \"$1\" in\n" + 'case "$1" in\n' " fetch) exit 0 ;;\n" " rev-list) printf '%s\\n' \"$FAKE_TAG_COMMIT\" ;;\n" " rev-parse) printf '%s\\n' \"$FAKE_MASTER_COMMIT\" ;;\n" - " *) echo \"unexpected fake git command: $*\" >&2; exit 64 ;;\n" + ' *) echo "unexpected fake git command: $*" >&2; exit 64 ;;\n' "esac\n", encoding="utf-8", ) @@ -153,7 +153,10 @@ def _release_issues(workflow: str) -> list[str]: checkout_with = checkout.get("with") if _uses(checkout) != CHECKOUT_PIN or not isinstance(checkout_with, dict): issues.append("release checkout must use the reviewed immutable action pin") - elif checkout_with.get("fetch-depth") != "0" or checkout_with.get("persist-credentials") != "false": + elif ( + checkout_with.get("fetch-depth") != "0" + or checkout_with.get("persist-credentials") != "false" + ): issues.append("release checkout must fully fetch without persisted credentials") resolve_index, resolve = _step(release, "Resolve and verify immutable release provenance") resolve_script = _run(resolve) @@ -163,13 +166,19 @@ def _release_issues(workflow: str) -> list[str]: ) if not protected_gate: issues.append("tag-push release must reject an unprotected ref before build") - if 'git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master' not in resolve_script: + if ( + "git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master" + not in resolve_script + ): issues.append("release must explicitly fetch origin/master before comparing it") if 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then' not in resolve_script: issues.append("tag provenance must require exact equality, not ancestry") if "merge-base --is-ancestor" in resolve_script: issues.append("tag provenance must not accept ancestry in place of equality") - if 'github.event_name }}" = "workflow_dispatch"' not in resolve_script or "dry-run only" not in resolve_script: + if ( + 'github.event_name }}" = "workflow_dispatch"' not in resolve_script + or "dry-run only" not in resolve_script + ): issues.append("workflow_dispatch must remain visibly dry-run") issues.extend(_provenance_semantic_issues(workflow)) except AssertionError as error: @@ -185,12 +194,18 @@ def _release_issues(workflow: str) -> list[str]: workflow, ): issues.append("artifact-only release must not publish or access production credentials") - if "backend_digest" in raw.lower() or "frontend_digest" in raw.lower() or "immutable digest" in raw.lower(): + if ( + "backend_digest" in raw.lower() + or "frontend_digest" in raw.lower() + or "immutable digest" in raw.lower() + ): issues.append("artifact-only metadata must not call local image IDs registry digests") if "backend_image_id" not in raw or "frontend_image_id" not in raw or "commit_sha" not in raw: issues.append("release metadata must record tag, commit SHA, and both local image IDs") if "docker image inspect --format '{{.Id}}'" not in raw or "local Docker image ID" not in raw: - issues.append("release metadata must identify local image IDs without claiming a registry digest") + issues.append( + "release metadata must identify local image IDs without claiming a registry digest" + ) if "release-metadata" not in workflow or "GITHUB_STEP_SUMMARY" not in raw: issues.append("release metadata must be retained as an artifact and summary") @@ -245,10 +260,14 @@ def _nightly_issues(workflow: str) -> list[str]: if evidence.get("shell") != "bash" or "set -euo pipefail" not in evidence_run: issues.append("nightly remote sync pipeline must fail closed with Bash pipefail") if "2>&1 | tee remote-sync-summary.txt" not in evidence_run: - issues.append("nightly remote sync must retain both stdout and stderr in its human summary") + issues.append( + "nightly remote sync must retain both stdout and stderr in its human summary" + ) _, upload = _step(remote_sync, "Upload remote-sync evidence") if upload.get("if") != "always()" or _uses(upload) != UPLOAD_PIN: - issues.append("nightly must always upload evidence with the reviewed immutable action pin") + issues.append( + "nightly must always upload evidence with the reviewed immutable action pin" + ) except AssertionError as error: issues.append(str(error)) return issues @@ -264,7 +283,9 @@ def test_release_requires_protected_tag_exact_provenance_and_image_ids() -> None def test_release_contract_rejects_unprotected_tag_bypass() -> None: - unsafe = _read(DOCKER_PUBLISH).replace('github.ref_protected }}" != "true"', '"true" != "true"', 1) + unsafe = _read(DOCKER_PUBLISH).replace( + 'github.ref_protected }}" != "true"', '"true" != "true"', 1 + ) assert "unprotected ref" in " ".join(_release_issues(unsafe)) @@ -280,30 +301,34 @@ def test_release_contract_rejects_dead_or_ancestry_only_equality_snippet() -> No def test_release_contract_rejects_unreachable_protection_and_equality_guards() -> None: - unsafe = _read(DOCKER_PUBLISH).replace( - 'if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' - ' echo "::error::Tag-push artifact builds require a protected release tag."\n' - " exit 1\n" - " fi", - 'if false; then\n' - ' if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' - ' echo "::error::Tag-push artifact builds require a protected release tag."\n' - " exit 1\n" - " fi\n" - " fi", - 1, - ).replace( - 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' - ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' - " exit 1\n" - " fi", - 'if false; then\n' - ' if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' - ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' - " exit 1\n" - " fi\n" - " fi", - 1, + unsafe = ( + _read(DOCKER_PUBLISH) + .replace( + 'if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' + ' echo "::error::Tag-push artifact builds require a protected release tag."\n' + " exit 1\n" + " fi", + "if false; then\n" + ' if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref_protected }}" != "true" ]; then\n' + ' echo "::error::Tag-push artifact builds require a protected release tag."\n' + " exit 1\n" + " fi\n" + " fi", + 1, + ) + .replace( + 'if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' + ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' + " exit 1\n" + " fi", + "if false; then\n" + ' if [ "$TAG_COMMIT" != "$MASTER_COMMIT" ]; then\n' + ' echo "::error::Tag $TAG resolves to $TAG_COMMIT, not origin/master $MASTER_COMMIT."\n' + " exit 1\n" + " fi\n" + " fi", + 1, + ) ) issues = " ".join(_release_issues(unsafe)) @@ -336,9 +361,13 @@ def test_nightly_failure_pipeline_is_fail_closed_and_evidence_is_always_uploaded assert _nightly_issues(_read(NIGHTLY)) == [] -def test_nightly_contract_rejects_missing_pipefail_stderr_capture_or_mutable_upload_action() -> None: +def test_nightly_contract_rejects_missing_pipefail_stderr_capture_or_mutable_upload_action() -> ( + None +): unsafe = _read(NIGHTLY).replace("set -euo pipefail", "set -eu", 1) - unsafe = unsafe.replace("2>&1 | tee remote-sync-summary.txt", "| tee remote-sync-summary.txt", 1) + unsafe = unsafe.replace( + "2>&1 | tee remote-sync-summary.txt", "| tee remote-sync-summary.txt", 1 + ) unsafe = unsafe.replace(UPLOAD_PIN, "actions/upload-artifact@v4", 1) issues = " ".join(_nightly_issues(unsafe)) From 57643c55680625441d0954df4c11608736e43a21 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Tue, 1 Sep 2026 22:51:08 +0800 Subject: [PATCH 28/32] fix(governance): harden iteration 195 acceptance --- .github/CODEOWNERS | 8 + .../governance/reviewer-authorization.json | 22 ++ .github/governance/risk-paths.json | 9 +- .github/governance/rulesets/dev.json | 2 + .github/governance/rulesets/master.json | 2 + .github/workflows/docker-publish.yml | 4 +- .github/workflows/pr-governance.yml | 3 +- ...55\344\273\243\350\256\241\345\210\222.md" | 2 +- scripts/ci/check_pr_governance.py | 235 +++++++++++++++-- scripts/ci/governance_contract.py | 7 + .../ci/tests/fixtures/github-rulesets.json | 4 + scripts/ci/tests/test_check_pr_governance.py | 245 +++++++++++++++++- scripts/ci/tests/test_governance_contract.py | 25 ++ .../tests/test_release_workflow_contract.py | 16 +- .../ci/tests/test_verify_github_governance.py | 69 +++++ .../test_workflow_governance_contract.py | 27 +- scripts/ci/verify_github_governance.py | 43 ++- 17 files changed, 670 insertions(+), 53 deletions(-) create mode 100644 .github/governance/reviewer-authorization.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1d8e37f7..3a3c6a10b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,11 +56,19 @@ **/SECURITY.md @cloudQuant # Narrow R2/R3 domains +/.github/CODEOWNERS @cloudQuant +/.github/PULL_REQUEST_TEMPLATE.md @cloudQuant /.github/workflows/** @cloudQuant +/.github/workflows/pr-governance.yml @cloudQuant /.github/governance/** @cloudQuant +/.github/governance/risk-paths.json @cloudQuant +/.github/governance/reviewer-authorization.json @cloudQuant /.github/dependabot.yml @cloudQuant /scripts/ci/governance_contract.py @cloudQuant /scripts/ci/verify_github_governance.py @cloudQuant +/scripts/ci/check_pr_governance.py @cloudQuant +/scripts/ci/classify_pr_risk.py @cloudQuant +/scripts/ci/check_pr_template.py @cloudQuant /scripts/ci/security_scan.sh @cloudQuant /.pre-commit-config.yaml @cloudQuant /.dockerignore @cloudQuant diff --git a/.github/governance/reviewer-authorization.json b/.github/governance/reviewer-authorization.json new file mode 100644 index 000000000..bb41dca4a --- /dev/null +++ b/.github/governance/reviewer-authorization.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "decision": { + "gate": "D2", + "status": "owner_baseline_pending_second_maintainer", + "reference": ".github/governance/decisions/iteration-195.md", + "reason": "D2 currently verifies only the repository owner. A collaborator may be authorized only after D2 records independent maintainer evidence." + }, + "reviewers": [ + { + "login": "cloudQuant", + "author_associations": [ + "OWNER" + ], + "evidence": { + "gate": "D2", + "status": "owner_baseline", + "reference": ".github/governance/decisions/iteration-195.md" + } + } + ] +} diff --git a/.github/governance/risk-paths.json b/.github/governance/risk-paths.json index ddd114a4c..9a5a7a41c 100644 --- a/.github/governance/risk-paths.json +++ b/.github/governance/risk-paths.json @@ -77,9 +77,11 @@ "level": "R3", "globs": [ ".github/workflows/**", - ".github/governance/**" + ".github/governance/**", + ".github/CODEOWNERS", + ".github/PULL_REQUEST_TEMPLATE.md" ], - "description": "CI execution and governance boundaries require the highest review." + "description": "CI execution, trusted governance policy, ownership, and PR declaration templates require the highest review." }, { "id": "r3-ci-and-security-controls", @@ -87,6 +89,9 @@ "globs": [ "scripts/ci/governance_contract.py", "scripts/ci/verify_github_governance.py", + "scripts/ci/check_pr_governance.py", + "scripts/ci/classify_pr_risk.py", + "scripts/ci/check_pr_template.py", "scripts/ci/security_scan.sh", "scripts/ci/governance_*.py", "scripts/ci/security_*.sh", diff --git a/.github/governance/rulesets/dev.json b/.github/governance/rulesets/dev.json index c95653e88..f9daa3f71 100644 --- a/.github/governance/rulesets/dev.json +++ b/.github/governance/rulesets/dev.json @@ -28,6 +28,8 @@ "pull_request": { "required": true, "required_approvals": 1, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, "conversation_resolution": true, "code_owner_review": { "desired_when_d2_ready": true, diff --git a/.github/governance/rulesets/master.json b/.github/governance/rulesets/master.json index 12f933ec2..ba062fcc3 100644 --- a/.github/governance/rulesets/master.json +++ b/.github/governance/rulesets/master.json @@ -28,6 +28,8 @@ "pull_request": { "required": true, "required_approvals": 2, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, "conversation_resolution": true, "code_owner_review": { "desired_when_d2_ready": true, diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2f8646471..99cca6cdd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -19,9 +19,7 @@ jobs: release-artifact: name: Verify tag provenance and build release metadata runs-on: ubuntu-latest - # D6 permits artifact builds only. The protected environment represents the - # future release boundary; this job deliberately receives no credentials. - environment: release + # D6 permits artifact builds only; this job deliberately receives no production credentials. steps: - name: Checkout complete release history diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 49a8b02c1..10e8f81a8 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -45,4 +45,5 @@ jobs: --reviews "$GOVERNANCE_TMP/reviews.json" \ --changed-files "$GOVERNANCE_TMP/files.json" \ --risk-map .github/governance/risk-paths.json \ - --manifest-dir .github/governance/rulesets + --manifest-dir .github/governance/rulesets \ + --reviewer-authorization .github/governance/reviewer-authorization.json diff --git "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" index 779f13c1f..fb4e0d6b1 100644 --- "a/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" +++ "b/docs/iterations/\350\277\255\344\273\243195-\347\244\276\345\214\272PR\346\262\273\347\220\206\344\270\216\345\217\214\345\210\206\346\224\257\345\215\217\344\275\234/\346\255\243\345\274\217\350\277\255\344\273\243\350\256\241\345\210\222.md" @@ -2,7 +2,7 @@ > 计划日期:2026-08-23 > 最近验收:2026-08-24 -> 状态:**已验收 / 实施中**(Task 0 已完成;D4、D7 仍阻塞;D3、D6 为有到期日的例外) +> 状态:**仓库侧候选实现已复核 / 运营验收待完成**(Task 0 已完成;D4、D7 仍阻塞;D3、D6 为有到期日的例外) > 适用仓库:`cloudQuant/backtrader_web` > 目标分支模型:`master`(发布主干)与 `dev`(日常集成) > 计划边界:本文件只定义治理、文档、GitHub 配置与相关自动化的交付方案;不实施业务功能、不修改远端设置、不推送、不合并。 diff --git a/scripts/ci/check_pr_governance.py b/scripts/ci/check_pr_governance.py index 114414b51..2f0e6ed1b 100644 --- a/scripts/ci/check_pr_governance.py +++ b/scripts/ci/check_pr_governance.py @@ -61,7 +61,12 @@ ) _TARGET_RE = re.compile(r"\b(dev|master)\b", re.IGNORECASE) _RISK_RE = re.compile(r"\b(R[0-3])\b", re.IGNORECASE) +_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) _TERMINAL_REVIEW_STATES = {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"} +_REVIEWER_AUTHORIZATION_SCHEMA_VERSION = 1 +_PENDING_REVIEWER_AUTHORIZATION_STATUS = "owner_baseline_pending_second_maintainer" +_APPROVED_REVIEWER_AUTHORIZATION_STATUS = "approved" +_ALLOWED_REVIEW_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) def _mapping(value: Any, name: str) -> Mapping[str, Any]: @@ -139,7 +144,11 @@ def _governance_field(visible_body: str, label: str) -> str | None: def _section_field_issues(visible_body: str, kind: str) -> list[str]: """Reject ambiguous or duplicated policy fields in their designated sections.""" requirements: list[tuple[str, re.Pattern[str], tuple[str, ...]]] = [ - ("Governance declaration", _GOVERNANCE_HEADING_RE, ("目标分支", "风险等级", "测试证据")) + ( + "Governance declaration", + _GOVERNANCE_HEADING_RE, + ("目标分支", "风险等级", "测试证据"), + ) ] if kind == "hotfix": requirements.append(("Hotfix 前移计划", _HOTFIX_HEADING_RE, ("前移计划",))) @@ -274,15 +283,142 @@ def _review_authorization(value: Any) -> str | None: return None -def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, tuple[str, str | None]]: - """Return each reviewer's effective terminal state and authorization. +def _required_string(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must be a non-empty string") + return value.strip() + + +def validate_reviewer_authorization(value: Any) -> dict[str, frozenset[str]]: + """Validate the D2-backed reviewer allowlist and return its lookup table. + + The policy is deliberately identity and association based. GitHub's + association alone is insufficient: a future collaborator must be named + explicitly in a D2-approved record before their review can count. + """ + config = _mapping(value, "reviewer authorization") + expected_root = {"schema_version", "decision", "reviewers"} + if set(config) != expected_root: + raise ValueError("reviewer authorization contains unknown or missing root fields") + if config.get("schema_version") != _REVIEWER_AUTHORIZATION_SCHEMA_VERSION: + raise ValueError("reviewer authorization schema_version must be 1") + + decision = _mapping(config.get("decision"), "reviewer authorization decision") + expected_decision = {"gate", "status", "reference", "reason"} + if set(decision) != expected_decision: + raise ValueError("reviewer authorization decision contains unknown or missing fields") + if decision.get("gate") != "D2": + raise ValueError("reviewer authorization decision.gate must be 'D2'") + status = decision.get("status") + if status not in { + _PENDING_REVIEWER_AUTHORIZATION_STATUS, + _APPROVED_REVIEWER_AUTHORIZATION_STATUS, + }: + raise ValueError("reviewer authorization decision.status is not recognized") + _required_string(decision.get("reference"), "reviewer authorization decision.reference") + _required_string(decision.get("reason"), "reviewer authorization decision.reason") + + reviewers = config.get("reviewers") + if not isinstance(reviewers, list) or not reviewers: + raise ValueError("reviewer authorization reviewers must be a non-empty list") + + authorized: dict[str, frozenset[str]] = {} + for index, reviewer in enumerate(reviewers): + entry = _mapping(reviewer, f"reviewer authorization reviewers[{index}]") + if set(entry) != {"login", "author_associations", "evidence"}: + raise ValueError( + f"reviewer authorization reviewers[{index}] contains unknown or missing fields" + ) + login = _required_string( + entry.get("login"), f"reviewer authorization reviewers[{index}].login" + ) + normalized_login = login.casefold() + if normalized_login in authorized: + raise ValueError(f"reviewer authorization contains duplicate login {login!r}") + + associations = entry.get("author_associations") + if not isinstance(associations, list) or not associations: + raise ValueError( + f"reviewer authorization reviewers[{index}].author_associations must be non-empty" + ) + normalized_associations = frozenset(_review_authorization(item) for item in associations) + if None in normalized_associations or not normalized_associations.issubset( + _ALLOWED_REVIEW_ASSOCIATIONS + ): + raise ValueError( + f"reviewer authorization reviewers[{index}].author_associations is invalid" + ) + if len(normalized_associations) != len(associations): + raise ValueError( + f"reviewer authorization reviewers[{index}].author_associations contains duplicates" + ) + if status == _PENDING_REVIEWER_AUTHORIZATION_STATUS and normalized_associations != { + "OWNER" + }: + raise ValueError( + "pending D2 reviewer authorization may authorize only a verified OWNER" + ) + + evidence = _mapping( + entry.get("evidence"), f"reviewer authorization reviewers[{index}].evidence" + ) + if set(evidence) != {"gate", "status", "reference"}: + raise ValueError( + f"reviewer authorization reviewers[{index}].evidence contains unknown or missing fields" + ) + if evidence.get("gate") != "D2": + raise ValueError( + f"reviewer authorization reviewers[{index}].evidence.gate must be 'D2'" + ) + expected_evidence_status = ( + "owner_baseline" + if status == _PENDING_REVIEWER_AUTHORIZATION_STATUS + else _APPROVED_REVIEWER_AUTHORIZATION_STATUS + ) + if evidence.get("status") != expected_evidence_status: + raise ValueError( + f"reviewer authorization reviewers[{index}].evidence.status must be " + f"{expected_evidence_status!r}" + ) + _required_string( + evidence.get("reference"), + f"reviewer authorization reviewers[{index}].evidence.reference", + ) + authorized[normalized_login] = normalized_associations + return authorized + + +def _is_authorized_reviewer( + login: str, + authorization: str | None, + authorized_reviewers: Mapping[str, frozenset[str]], +) -> bool: + return authorization is not None and authorization in authorized_reviewers.get( + login.casefold(), frozenset() + ) + - Comments and pending reviews are non-terminal. They cannot erase an - earlier approval or change request. An OWNER terminal review has - precedence over subsequent unverified terminal events so an unverified - actor cannot clear an OWNER change request or approval. +def _is_current_head_commit(commit_id: Any, head_sha: str) -> bool: + return ( + isinstance(commit_id, str) + and _GIT_SHA_RE.fullmatch(commit_id.strip()) is not None + and commit_id.strip().casefold() == head_sha.casefold() + ) + + +def _latest_reviews( + reviews: Sequence[Any], + author: str, + authorized_reviewers: Mapping[str, frozenset[str]], +) -> dict[str, tuple[str, str | None, Any]]: + """Return each reviewer's effective terminal state and provenance. + + Comments and pending reviews are non-terminal. They cannot erase an + earlier terminal state. An explicitly authorized terminal review has + precedence over later unrecognized terminal events, so an unauthorized + actor cannot clear a valid change request or approval. """ - histories: dict[str, list[tuple[tuple[datetime, int], str, str | None]]] = {} + histories: dict[str, list[tuple[tuple[datetime, int], str, str | None, Any]]] = {} for position, review in enumerate(reviews): if not isinstance(review, Mapping): continue @@ -298,49 +434,67 @@ def _latest_reviews(reviews: Sequence[Any], author: str) -> dict[str, tuple[str, _review_timestamp(review.get("submitted_at"), position), state.strip().upper(), _review_authorization(review.get("author_association")), + review.get("commit_id"), ) ) - effective: dict[str, tuple[str, str | None]] = {} + effective: dict[str, tuple[str, str | None, Any]] = {} for login, history in sorted(histories.items()): - latest_terminal: tuple[str, str | None] | None = None - latest_nonterminal: tuple[str, str | None] | None = None - owner_terminal: tuple[str, str | None] | None = None - for _, state, authorization in sorted(history, key=lambda entry: entry[0]): + latest_terminal: tuple[str, str | None, Any] | None = None + latest_nonterminal: tuple[str, str | None, Any] | None = None + authorized_terminal: tuple[str, str | None, Any] | None = None + for _, state, authorization, commit_id in sorted(history, key=lambda entry: entry[0]): if state in _TERMINAL_REVIEW_STATES: - latest_terminal = (state, authorization) - if authorization == "OWNER": - owner_terminal = (state, authorization) + latest_terminal = (state, authorization, commit_id) + if _is_authorized_reviewer(login, authorization, authorized_reviewers): + authorized_terminal = (state, authorization, commit_id) else: - latest_nonterminal = (state, authorization) - state = owner_terminal or latest_terminal or latest_nonterminal + latest_nonterminal = (state, authorization, commit_id) + state = authorized_terminal or latest_terminal or latest_nonterminal if state is not None: effective[login] = state return effective -def _review_summary(reviews: Sequence[Any], author: str) -> dict[str, list[str]]: - latest = _latest_reviews(reviews, author) +def _review_summary( + reviews: Sequence[Any], + author: str, + head_sha: str, + authorized_reviewers: Mapping[str, frozenset[str]], +) -> dict[str, list[str]]: + latest = _latest_reviews(reviews, author, authorized_reviewers) approvals = sorted( login - for login, (state, authorization) in latest.items() - if state == "APPROVED" and authorization == "OWNER" + for login, (state, authorization, commit_id) in latest.items() + if state == "APPROVED" + and _is_authorized_reviewer(login, authorization, authorized_reviewers) + and _is_current_head_commit(commit_id, head_sha) ) changes_requested = sorted( login - for login, (state, authorization) in latest.items() - if state == "CHANGES_REQUESTED" and authorization == "OWNER" + for login, (state, authorization, _) in latest.items() + if state == "CHANGES_REQUESTED" + and _is_authorized_reviewer(login, authorization, authorized_reviewers) ) unverified = sorted( login - for login, (state, authorization) in latest.items() - if state == "APPROVED" and authorization != "OWNER" + for login, (state, authorization, _) in latest.items() + if state == "APPROVED" + and not _is_authorized_reviewer(login, authorization, authorized_reviewers) + ) + stale = sorted( + login + for login, (state, authorization, commit_id) in latest.items() + if state == "APPROVED" + and _is_authorized_reviewer(login, authorization, authorized_reviewers) + and not _is_current_head_commit(commit_id, head_sha) ) return { "effective_approvals": approvals, "changes_requested_by": changes_requested, "reviewers": sorted(latest), "unverified_reviews": unverified, + "stale_approvals": stale, } @@ -418,6 +572,7 @@ def evaluate_pr_governance( risk_map: Mapping[str, Any], manifests: Mapping[str, Mapping[str, Any]], changed_file_entry_count: Any, + reviewer_authorization: Any, ) -> dict[str, Any]: """Return an actionable, deterministic Governance Gate result. @@ -430,12 +585,15 @@ def evaluate_pr_governance( author_data = _mapping(pr_data.get("user"), "PR author") base_branch = base.get("ref") head_branch = head.get("ref") + head_sha = head.get("sha") author = _login(author_data) body = pr_data.get("body") if not isinstance(base_branch, str) or not base_branch.strip(): raise ValueError("PR base.ref must be a non-empty string") if not isinstance(head_branch, str) or not head_branch.strip(): raise ValueError("PR head.ref must be a non-empty string") + if not isinstance(head_sha, str) or _GIT_SHA_RE.fullmatch(head_sha.strip()) is None: + raise ValueError("PR head.sha must be a 40-character Git SHA") if author is None: raise ValueError("PR user.login must be a non-empty string") if not isinstance(body, str): @@ -445,12 +603,18 @@ def evaluate_pr_governance( base_branch = base_branch.strip() head_branch = head_branch.strip() + head_sha = head_sha.strip() kind = _pr_kind(base_branch, head_branch) visible_body = _visible_body(body) classification = classify_changed_files(changed_files, risk_map) labels = _labels(pr_data) manifest = _branch_manifest(manifests, base_branch) if isinstance(manifests, Mapping) else None issues = _manifest_policy_issues(manifests) + try: + authorized_reviewers = validate_reviewer_authorization(reviewer_authorization) + except (TypeError, ValueError) as error: + authorized_reviewers = {} + issues.append(f"reviewer authorization configuration is invalid: {error}") issues.extend(_inventory_issues(pr_data, changed_file_entry_count)) approval_floor: int | None = None if manifest is not None: @@ -499,17 +663,24 @@ def evaluate_pr_governance( if base_branch == "master" and kind == "normal": issues.append("regular contribution branches must target dev; retarget this PR to dev") - review_summary = _review_summary(reviews, author) + review_summary = _review_summary(reviews, author, head_sha, authorized_reviewers) effective_approvals = review_summary["effective_approvals"] changes_requested_by = review_summary["changes_requested_by"] if approval_floor is not None and len(effective_approvals) < approval_floor: unverified_reviews = review_summary["unverified_reviews"] + stale_approvals = review_summary["stale_approvals"] if unverified_reviews: reviewers = ", ".join(unverified_reviews) issues.append( f"review approval authorization not verified for {reviewers}; request verified " "maintainer review" ) + if stale_approvals: + reviewers = ", ".join(stale_approvals) + issues.append( + f"review approval is not tied to the current PR head for {reviewers}; " + "request a fresh approval" + ) issues.append( f"{base_branch} requires {approval_floor} non-author approvals but has " f"{len(effective_approvals)}; request an additional maintainer review" @@ -528,7 +699,7 @@ def evaluate_pr_governance( ) if not protective_reviewers: issues.append( - f"{computed_risk} change requires a verified OWNER protective approval; " + f"{computed_risk} change requires a verified authorized protective approval; " "requested reviewers alone do not satisfy it; request verified maintainer review" ) @@ -550,6 +721,7 @@ def evaluate_pr_governance( "effective_approvals": effective_approvals, "changes_requested_by": changes_requested_by, "unverified_reviews": review_summary["unverified_reviews"], + "stale_approvals": review_summary["stale_approvals"], "protective_reviewers": protective_reviewers, "requested_reviewers": requested_reviewers, "code_owner_review": code_owner_review, @@ -562,6 +734,11 @@ def _load_json(path: Path) -> Any: return json.load(handle) +def load_reviewer_authorization(path: Path) -> dict[str, frozenset[str]]: + """Load the trusted-base D2 reviewer authorization allowlist.""" + return validate_reviewer_authorization(_load_json(path)) + + def _flatten_pages(value: Any) -> list[Any]: if not isinstance(value, list): raise ValueError("reviews payload must be a JSON list") @@ -581,6 +758,7 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--changed-files", required=True, type=Path) parser.add_argument("--risk-map", required=True, type=Path) parser.add_argument("--manifest-dir", required=True, type=Path) + parser.add_argument("--reviewer-authorization", required=True, type=Path) return parser.parse_args() @@ -596,6 +774,7 @@ def main() -> int: risk_map=load_risk_map(args.risk_map), manifests=load_manifests(args.manifest_dir), changed_file_entry_count=len(file_entries), + reviewer_authorization=_load_json(args.reviewer_authorization), ) except (OSError, ValueError, json.JSONDecodeError) as error: print(json.dumps({"ok": False, "issues": [str(error)]}, ensure_ascii=False)) diff --git a/scripts/ci/governance_contract.py b/scripts/ci/governance_contract.py index 2d40ea0c6..4f9585e78 100644 --- a/scripts/ci/governance_contract.py +++ b/scripts/ci/governance_contract.py @@ -657,6 +657,9 @@ def _validate_branch_manifest( f"must be {expected['required_approvals']}", ) ) + for field in ("dismiss_stale_reviews_on_push", "require_last_push_approval"): + if pull_request.get(field) is not True: + issues.append(_issue(f"{key}.pull_request.{field}", "must be true")) if pull_request.get("conversation_resolution") is not True: issues.append(_issue(f"{key}.pull_request.conversation_resolution", "must be true")) @@ -952,6 +955,10 @@ def normalized_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: "pull_request": { "required": pr_mapping.get("required"), "required_approvals": pr_mapping.get("required_approvals"), + "dismiss_stale_reviews_on_push": pr_mapping.get( + "dismiss_stale_reviews_on_push" + ), + "require_last_push_approval": pr_mapping.get("require_last_push_approval"), "code_owner_review_enabled": code_owner_mapping.get("enabled"), "conversation_resolution": pr_mapping.get("conversation_resolution"), }, diff --git a/scripts/ci/tests/fixtures/github-rulesets.json b/scripts/ci/tests/fixtures/github-rulesets.json index 4fddfcfd9..ca00998c8 100644 --- a/scripts/ci/tests/fixtures/github-rulesets.json +++ b/scripts/ci/tests/fixtures/github-rulesets.json @@ -21,6 +21,8 @@ "parameters": { "required_approving_review_count": 1, "require_code_owner_review": false, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, "required_review_thread_resolution": true } }, @@ -57,6 +59,8 @@ "parameters": { "required_approving_review_count": 2, "require_code_owner_review": false, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, "required_review_thread_resolution": true } }, diff --git a/scripts/ci/tests/test_check_pr_governance.py b/scripts/ci/tests/test_check_pr_governance.py index 48f8fd4d2..5b1652965 100644 --- a/scripts/ci/tests/test_check_pr_governance.py +++ b/scripts/ci/tests/test_check_pr_governance.py @@ -15,7 +15,42 @@ CHECKER_PATH = SCRIPTS_CI / "check_pr_governance.py" RISK_MAP_PATH = REPO_ROOT / ".github" / "governance" / "risk-paths.json" MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" +REVIEWER_AUTHORIZATION_PATH = REPO_ROOT / ".github" / "governance" / "reviewer-authorization.json" _DEFAULT_CHANGED_FILE_COUNT = object() +_CURRENT_HEAD_SHA = "c" * 40 + + +def _authorization(*, approved: bool = True) -> dict[str, object]: + """Return an explicit D2 authorization fixture for trusted-review tests.""" + status = "approved" if approved else "owner_baseline_pending_second_maintainer" + reviewers = [ + { + "login": login, + "author_associations": ["OWNER"], + "evidence": { + "gate": "D2", + "status": "approved" if approved else "owner_baseline", + "reference": ".github/governance/decisions/iteration-195.md", + }, + } + for login in ( + "maintainer", + "maintainer-a", + "maintainer-b", + "maintainer-c", + "repository-owner", + ) + ] + return { + "schema_version": 1, + "decision": { + "gate": "D2", + "status": status, + "reference": ".github/governance/decisions/iteration-195.md", + "reason": "Synthetic test authorization only.", + }, + "reviewers": reviewers, + } def _load_checker() -> ModuleType: @@ -75,7 +110,7 @@ def _pr( return { "number": 195, "base": {"ref": base}, - "head": {"ref": head}, + "head": {"ref": head, "sha": _CURRENT_HEAD_SHA}, "user": {"login": author}, "body": body, "labels": [{"name": label} for label in labels], @@ -89,6 +124,7 @@ def _review( state: str, submitted_at: str, author_association: str | None = "OWNER", + commit_id: object = _CURRENT_HEAD_SHA, ) -> dict[str, object]: review: dict[str, object] = { "user": {"login": login}, @@ -97,6 +133,8 @@ def _review( } if author_association is not None: review["author_association"] = author_association + if commit_id is not None: + review["commit_id"] = commit_id return review @@ -109,6 +147,7 @@ def _evaluate( metadata_changed_file_count: object = _DEFAULT_CHANGED_FILE_COUNT, raw_file_entry_count: int | None = None, manifests: dict[str, dict[str, object]] | None = None, + reviewer_authorization: dict[str, object] | None = None, ) -> dict[str, object]: pr_with_count = dict(pr) pr_with_count["changed_files"] = ( @@ -125,6 +164,9 @@ def _evaluate( changed_file_entry_count=( len(changed_files) if raw_file_entry_count is None else raw_file_entry_count ), + reviewer_authorization=( + _authorization() if reviewer_authorization is None else reviewer_authorization + ), ) @@ -528,7 +570,11 @@ def test_mixed_length_fences_do_not_expose_hidden_governance_declarations() -> N - **测试证据**: pytest hidden-fence-decoy """ - for opening_fence, inner_fence in (("````", "```"), ("`````", "````"), ("~~~~", "~~~")): + for opening_fence, inner_fence in ( + ("````", "```"), + ("`````", "````"), + ("~~~~", "~~~"), + ): body = f"{opening_fence}markdown\n{inner_fence}\n{hidden_declaration}{opening_fence}\n" pr = _pr(base="dev", head="feature/market-screen", body=body) @@ -630,7 +676,7 @@ def test_r2_auth_change_requires_matching_declaration_and_protective_record() -> assert result["risk"] == "R2" assert result["labels_can_lower_risk"] is False assert any("declares R1 but changed paths require R2" in issue for issue in result["issues"]) - assert any("verified OWNER protective approval" in issue for issue in result["issues"]) + assert any("verified authorized protective approval" in issue for issue in result["issues"]) assert result["code_owner_review"]["verified"] is False @@ -1070,6 +1116,189 @@ def test_owner_approval_counts_toward_the_dev_floor() -> None: assert result["effective_approvals"] == ["maintainer"] +def test_stale_approval_does_not_survive_a_pull_request_head_change() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + pr["head"] = {"ref": "feature/market-screen", "sha": "d" * 40} + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z", commit_id="c" * 40)], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert result["stale_approvals"] == ["maintainer"] + assert any("not tied to the current PR head" in issue for issue in result["issues"]) + + +def test_current_head_approval_replaces_an_older_approval_from_the_same_reviewer() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + pr["head"] = {"ref": "feature/market-screen", "sha": "d" * 40} + + result = _evaluate( + checker, + pr, + [ + _review("maintainer", "APPROVED", "2026-08-24T00:01:00Z", commit_id="c" * 40), + _review("maintainer", "APPROVED", "2026-08-24T00:02:00Z", commit_id="d" * 40), + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is True + assert result["effective_approvals"] == ["maintainer"] + assert result["stale_approvals"] == [] + + +def test_missing_or_malformed_approval_commit_id_fails_closed() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + for commit_id in (None, "not-a-git-sha", 195): + result = _evaluate( + checker, + pr, + [ + _review( + "maintainer", + "APPROVED", + "2026-08-24T00:01:00Z", + commit_id=commit_id, + ) + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert result["stale_approvals"] == ["maintainer"] + assert any("not tied to the current PR head" in issue for issue in result["issues"]) + + +def test_repository_authorization_is_owner_only_while_d2_has_no_second_maintainer() -> None: + checker = _load_checker() + + authorization = checker.load_reviewer_authorization(REVIEWER_AUTHORIZATION_PATH) + + assert authorization == {"cloudquant": frozenset({"OWNER"})} + + +def test_malformed_reviewer_authorization_fails_closed_before_any_review_counts() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + authorization = _authorization() + reviewers = authorization["reviewers"] + assert isinstance(reviewers, list) + reviewers[0]["author_associations"] = ["OWNER", "OWNER"] + + result = _evaluate( + checker, + pr, + [_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")], + ["src/backend/app/services/market_data.py"], + reviewer_authorization=authorization, + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert any( + "reviewer authorization configuration is invalid" in issue for issue in result["issues"] + ) + + +def test_unauthorized_collaborator_approval_does_not_count() -> None: + checker = _load_checker() + pr = _pr( + base="dev", + head="feature/market-screen", + body=_body(target="dev", risk="R1"), + ) + + result = _evaluate( + checker, + pr, + [ + _review( + "unapproved-collaborator", + "APPROVED", + "2026-08-24T00:01:00Z", + "COLLABORATOR", + ) + ], + ["src/backend/app/services/market_data.py"], + ) + + assert result["ok"] is False + assert result["effective_approvals"] == [] + assert result["unverified_reviews"] == ["unapproved-collaborator"] + + +def test_d2_approved_collaborator_can_complete_the_master_two_review_floor() -> None: + checker = _load_checker() + pr = _pr( + base="master", + head="release/v1.2.3", + body=_body( + target="master", + risk="R3", + release_checklist="v1.2.3 changelog, regression results, and rollback point", + ), + ) + authorization = _authorization() + reviewers = authorization["reviewers"] + assert isinstance(reviewers, list) + reviewers.append( + { + "login": "approved-collaborator", + "author_associations": ["COLLABORATOR"], + "evidence": { + "gate": "D2", + "status": "approved", + "reference": ".github/governance/decisions/iteration-195.md", + }, + } + ) + + result = _evaluate( + checker, + pr, + [ + _review("maintainer-a", "APPROVED", "2026-08-24T00:01:00Z", "OWNER"), + _review( + "approved-collaborator", + "APPROVED", + "2026-08-24T00:02:00Z", + "COLLABORATOR", + ), + ], + ["scripts/ops/release.sh"], + reviewer_authorization=authorization, + ) + + assert result["ok"] is True + assert result["effective_approvals"] == ["approved-collaborator", "maintainer-a"] + + def test_unverified_none_approval_does_not_count_toward_the_floor() -> None: checker = _load_checker() pr = _pr( @@ -1203,10 +1432,12 @@ def test_requested_reviewer_does_not_satisfy_r2_protective_review_requirement() assert result["ok"] is False assert result["protective_reviewers"] == [] - assert any("verified OWNER protective approval" in issue for issue in result["issues"]) + assert any("verified authorized protective approval" in issue for issue in result["issues"]) -def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result(tmp_path: Path) -> None: +def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result( + tmp_path: Path, +) -> None: pr_path = tmp_path / "pr.json" reviews_path = tmp_path / "reviews.json" files_path = tmp_path / "files.json" @@ -1221,7 +1452,7 @@ def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result(tmp_pa encoding="utf-8", ) reviews_path.write_text( - json.dumps([_review("maintainer", "APPROVED", "2026-08-24T00:01:00Z")]), + json.dumps([_review("cloudQuant", "APPROVED", "2026-08-24T00:01:00Z")]), encoding="utf-8", ) files_path.write_text( @@ -1243,6 +1474,8 @@ def test_cli_uses_local_json_inputs_and_returns_a_machine_readable_result(tmp_pa str(RISK_MAP_PATH), "--manifest-dir", str(MANIFEST_DIR), + "--reviewer-authorization", + str(REVIEWER_AUTHORIZATION_PATH), ], cwd=REPO_ROOT, capture_output=True, diff --git a/scripts/ci/tests/test_governance_contract.py b/scripts/ci/tests/test_governance_contract.py index c6a27a047..076fee701 100644 --- a/scripts/ci/tests/test_governance_contract.py +++ b/scripts/ci/tests/test_governance_contract.py @@ -201,8 +201,13 @@ def test_security_guides_are_r3_and_have_governance_owner(self) -> None: def test_governance_and_security_controls_are_r3_despite_labels(self) -> None: contract = _load_contract() paths = [ + ".github/CODEOWNERS", + ".github/PULL_REQUEST_TEMPLATE.md", "scripts/ci/governance_contract.py", "scripts/ci/verify_github_governance.py", + "scripts/ci/check_pr_governance.py", + "scripts/ci/classify_pr_risk.py", + "scripts/ci/check_pr_template.py", "scripts/ci/security_scan.sh", ".github/dependabot.yml", ".pre-commit-config.yaml", @@ -223,6 +228,11 @@ def test_governance_and_security_controls_are_r3_despite_labels(self) -> None: if line.strip() and not line.lstrip().startswith("#") } assert { + ("/.github/CODEOWNERS", "@cloudQuant"), + ("/.github/PULL_REQUEST_TEMPLATE.md", "@cloudQuant"), + ("/scripts/ci/check_pr_governance.py", "@cloudQuant"), + ("/scripts/ci/classify_pr_risk.py", "@cloudQuant"), + ("/scripts/ci/check_pr_template.py", "@cloudQuant"), ("/.pre-commit-config.yaml", "@cloudQuant"), ("/.dockerignore", "@cloudQuant"), }.issubset(entries) @@ -419,6 +429,8 @@ def test_repository_manifests_record_current_pending_gate_truth(self) -> None: "strict": True, "do_not_enforce_on_create": False, } + assert manifests[key]["pull_request"]["dismiss_stale_reviews_on_push"] is True + assert manifests[key]["pull_request"]["require_last_push_approval"] is True assert manifests[key]["pull_request"]["code_owner_review"]["enabled"] is False assert manifests["release-tags"]["activation"]["remote_state"] == "not_applied" assert manifests["release-tags"]["bypass"]["actors"] == [] @@ -429,6 +441,19 @@ def test_repository_manifests_record_current_pending_gate_truth(self) -> None: "source": manifests["release-tags"]["tag_protection"]["authorized_actors"]["source"], } + def test_manifest_validation_requires_stale_review_and_last_push_protections( + self, + ) -> None: + contract = _load_contract() + manifests = copy.deepcopy(_manifests(contract)) + manifests["dev"]["pull_request"]["dismiss_stale_reviews_on_push"] = False + manifests["master"]["pull_request"].pop("require_last_push_approval") + + issues = contract.validate_manifests(manifests) + + assert any("dev.pull_request.dismiss_stale_reviews_on_push" in issue for issue in issues) + assert any("master.pull_request.require_last_push_approval" in issue for issue in issues) + def test_manifest_validation_rejects_future_transitions_without_evidence( self, ) -> None: diff --git a/scripts/ci/tests/test_release_workflow_contract.py b/scripts/ci/tests/test_release_workflow_contract.py index 3fc1a6432..1cbe93e62 100644 --- a/scripts/ci/tests/test_release_workflow_contract.py +++ b/scripts/ci/tests/test_release_workflow_contract.py @@ -146,8 +146,10 @@ def _release_issues(workflow: str) -> list[str]: issues.append("release must only trigger on version tags") release = _job(workflow, "release-artifact") - if release.get("environment") != "release": - issues.append("release boundary must use the protected release environment") + if "environment" in release: + issues.append( + "D6 artifact-only release must not configure an environment or create one implicitly" + ) try: _, checkout = _step(release, "Checkout complete release history") checkout_with = checkout.get("with") @@ -353,6 +355,16 @@ def test_release_contract_rejects_buildx_push_or_registry_login() -> None: assert "must not publish" in " ".join(_release_issues(unsafe)) +def test_release_contract_rejects_an_environment_on_the_artifact_only_job() -> None: + unsafe = _read(DOCKER_PUBLISH).replace( + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n environment: release\n", + 1, + ) + + assert "must not configure an environment" in " ".join(_release_issues(unsafe)) + + def test_preview_is_non_hosted_artifact_without_pull_request_writes() -> None: assert _preview_issues(_read(PREVIEW)) == [] diff --git a/scripts/ci/tests/test_verify_github_governance.py b/scripts/ci/tests/test_verify_github_governance.py index 4d15a7705..1dfc64a8d 100644 --- a/scripts/ci/tests/test_verify_github_governance.py +++ b/scripts/ci/tests/test_verify_github_governance.py @@ -126,6 +126,75 @@ def test_real_rule_drift_has_a_human_readable_path(self) -> None: assert report["ok"] is False assert any("dev.pull_request.required_approvals" in diff for diff in report["differences"]) + def test_review_reset_and_last_push_requirements_are_compared(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + pull_request = next(rule for rule in dev_rule["rules"] if rule["type"] == "pull_request") + pull_request["parameters"]["dismiss_stale_reviews_on_push"] = False + pull_request["parameters"].pop("require_last_push_approval", None) + + report = verifier.verify_rulesets(MANIFEST_DIR, actual, source="fixture") + + assert report["ok"] is False + assert any( + "dev.pull_request.dismiss_stale_reviews_on_push" in diff + for diff in report["differences"] + ) + assert any( + "dev.pull_request.require_last_push_approval" in diff for diff in report["differences"] + ) + + def test_unmodeled_actual_rule_type_fails_closed(self) -> None: + verifier = _load_verifier() + actual = copy.deepcopy(_fixture()["rulesets"]) + dev_rule = next(rule for rule in actual if rule["name"] == "Iteration 195: dev") + dev_rule["rules"].append({"type": "required_workflows", "parameters": {}}) + + report = verifier.verify_rulesets(MANIFEST_DIR, actual, source="fixture") + + assert report["ok"] is False + assert any( + "dev.readback_errors" in difference and "unmodeled rule type" in difference + for difference in report["differences"] + ) + + def test_fixture_mode_compares_desired_shape_without_claiming_remote_activation( + self, + ) -> None: + verifier = _load_verifier() + + report = verifier.verify_rulesets( + MANIFEST_DIR, + copy.deepcopy(_fixture()["rulesets"]), + source="fixture", + ) + + assert report == {"ok": True, "differences": []} + + def test_live_mode_rejects_present_rulesets_declared_not_applied(self) -> None: + verifier = _load_verifier() + + report = verifier.verify_rulesets( + MANIFEST_DIR, + copy.deepcopy(_fixture()["rulesets"]), + source="live", + ) + + assert report["ok"] is False + assert any( + "dev.activation.remote_state" in difference for difference in report["differences"] + ) + + def test_live_mode_accepts_the_absence_of_rulesets_declared_not_applied( + self, + ) -> None: + verifier = _load_verifier() + + report = verifier.verify_rulesets(MANIFEST_DIR, [], source="live") + + assert report == {"ok": True, "differences": []} + def test_ref_name_exclude_drift_is_not_ignored(self) -> None: verifier = _load_verifier() actual = copy.deepcopy(_fixture()["rulesets"]) diff --git a/scripts/ci/tests/test_workflow_governance_contract.py b/scripts/ci/tests/test_workflow_governance_contract.py index 752aeca8b..83c8e43e2 100644 --- a/scripts/ci/tests/test_workflow_governance_contract.py +++ b/scripts/ci/tests/test_workflow_governance_contract.py @@ -167,7 +167,8 @@ def _governance_run_issues(workflow: str) -> list[str]: expected_evaluate = ( 'python scripts/ci/check_pr_governance.py --pr "$GOVERNANCE_TMP/pr.json" ' '--reviews "$GOVERNANCE_TMP/reviews.json" --changed-files "$GOVERNANCE_TMP/files.json" ' - "--risk-map .github/governance/risk-paths.json --manifest-dir .github/governance/rulesets" + "--risk-map .github/governance/risk-paths.json --manifest-dir .github/governance/rulesets " + "--reviewer-authorization .github/governance/reviewer-authorization.json" ) if evaluate is None or _normalized_command(evaluate) != expected_evaluate: issues.append( @@ -305,7 +306,9 @@ def test_governance_rejects_job_level_write_permissions(self) -> None: assert _permission_issues(workflow) - def test_governance_workflow_is_read_only_and_has_an_always_running_gate(self) -> None: + def test_governance_workflow_is_read_only_and_has_an_always_running_gate( + self, + ) -> None: workflow = _read(PR_GOVERNANCE) parsed = _workflow_mapping(workflow) jobs = parsed.get("jobs") @@ -353,7 +356,9 @@ def test_governance_checkout_is_pinned_to_the_trusted_base_sha_without_credentia assert "github.event.pull_request.head" not in workflow assert _checkout_issues(workflow) == [] - def test_governance_has_exactly_one_pinned_action_and_no_container_or_services(self) -> None: + def test_governance_has_exactly_one_pinned_action_and_no_container_or_services( + self, + ) -> None: workflow = _read(PR_GOVERNANCE) parsed = _workflow_mapping(workflow) gate = _governance_gate(workflow) @@ -384,7 +389,9 @@ def test_governance_has_exactly_one_pinned_action_and_no_container_or_services(s assert _uses_issues(containerized) assert _uses_issues(serviced) - def test_governance_shell_steps_match_the_metadata_and_evaluator_allowlists(self) -> None: + def test_governance_shell_steps_match_the_metadata_and_evaluator_allowlists( + self, + ) -> None: workflow = _read(PR_GOVERNANCE) conditional = workflow.replace( " - name: Read pull request metadata\n", @@ -396,7 +403,9 @@ def test_governance_shell_steps_match_the_metadata_and_evaluator_allowlists(self assert _governance_run_issues(workflow) == [] assert _governance_run_issues(conditional) - def test_governance_rejects_metadata_api_write_or_pr_comment_mutations(self) -> None: + def test_governance_rejects_metadata_api_write_or_pr_comment_mutations( + self, + ) -> None: workflow = _read(PR_GOVERNANCE) api_write = workflow.replace( 'gh api --method GET "repos/$PR_REPOSITORY/pulls/$PR_NUMBER"', @@ -419,7 +428,9 @@ def test_governance_rejects_metadata_api_write_or_pr_comment_mutations(self) -> assert _governance_run_issues(pr_comment) assert _governance_run_issues(pr_checkout) - def test_governance_rejects_fetching_pr_head_or_checkout_head_mutations(self) -> None: + def test_governance_rejects_fetching_pr_head_or_checkout_head_mutations( + self, + ) -> None: workflow = _read(PR_GOVERNANCE) fetch_head = workflow.replace( ' mkdir -p "$GOVERNANCE_TMP"\n', @@ -428,7 +439,9 @@ def test_governance_rejects_fetching_pr_head_or_checkout_head_mutations(self) -> 1, ) checkout_head = workflow.replace( - "github.event.pull_request.base.sha", "github.event.pull_request.head.sha", 1 + "github.event.pull_request.base.sha", + "github.event.pull_request.head.sha", + 1, ) merge_ref = workflow.replace( ' mkdir -p "$GOVERNANCE_TMP"\n', diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py index 6c45ebf8c..66974c106 100644 --- a/scripts/ci/verify_github_governance.py +++ b/scripts/ci/verify_github_governance.py @@ -32,6 +32,8 @@ "deletion", ) _TAG_SECURITY_CRITICAL_RULE_TYPES = ("creation", "update", "deletion") +_BRANCH_ALLOWED_RULE_TYPES = frozenset(_BRANCH_SECURITY_CRITICAL_RULE_TYPES) +_TAG_ALLOWED_RULE_TYPES = frozenset(_TAG_SECURITY_CRITICAL_RULE_TYPES) def _rules_by_type(rules: Any, rule_type: str) -> list[Mapping[str, Any]]: @@ -67,6 +69,20 @@ def _duplicate_rule_errors(rules: Any, rule_types: Sequence[str]) -> list[str]: ] +def _unmodeled_rule_errors(rules: Any, allowed_rule_types: frozenset[str]) -> list[str]: + """Reject API rule types that this verifier cannot compare safely.""" + if not isinstance(rules, list): + return [] + errors: list[str] = [] + for index, rule in enumerate(rules): + if not isinstance(rule, Mapping): + continue + rule_type = rule.get("type") + if not isinstance(rule_type, str) or rule_type not in allowed_rule_types: + errors.append(f"unmodeled rule type at rules[{index}]: {rule_type!r}") + return errors + + def _normalize_ref_name_patterns(value: Any, *, field: str) -> list[str]: """Preserve malformed ref collections as drift rather than empty lists.""" if not isinstance(value, list): @@ -111,6 +127,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: } if target == "branch": readback_errors.extend(_duplicate_rule_errors(rules, _BRANCH_SECURITY_CRITICAL_RULE_TYPES)) + readback_errors.extend(_unmodeled_rule_errors(rules, _BRANCH_ALLOWED_RULE_TYPES)) pull_request = _rule_parameters(rules, "pull_request") status_checks = _rule_parameters(rules, "required_status_checks") raw_contexts = status_checks.get("required_status_checks", []) @@ -120,6 +137,10 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: "pull_request": { "required": _has_rule(rules, "pull_request"), "required_approvals": pull_request.get("required_approving_review_count"), + "dismiss_stale_reviews_on_push": pull_request.get( + "dismiss_stale_reviews_on_push" + ), + "require_last_push_approval": pull_request.get("require_last_push_approval"), "code_owner_review_enabled": pull_request.get("require_code_owner_review"), "conversation_resolution": pull_request.get( "required_review_thread_resolution" @@ -138,6 +159,7 @@ def normalize_github_ruleset(raw_ruleset: Mapping[str, Any]) -> dict[str, Any]: ) elif target == "tag": readback_errors.extend(_duplicate_rule_errors(rules, _TAG_SECURITY_CRITICAL_RULE_TYPES)) + readback_errors.extend(_unmodeled_rule_errors(rules, _TAG_ALLOWED_RULE_TYPES)) canonical["tag_protection"] = { "block_creation": _has_rule(rules, "creation"), "block_update": _has_rule(rules, "update"), @@ -181,9 +203,14 @@ def _diff(expected: Any, actual: Any, path: str) -> list[str]: def verify_rulesets( - manifest_dir: Path | str, actual_rulesets: Sequence[Mapping[str, Any]] + manifest_dir: Path | str, + actual_rulesets: Sequence[Mapping[str, Any]], + *, + source: str = "fixture", ) -> dict[str, Any]: - """Compare manifest desired state to a Rulesets API readback or fixture.""" + """Compare desired Rulesets with either fixture shape or live activation state.""" + if source not in {"fixture", "live"}: + raise ValueError("verification source must be 'fixture' or 'live'") manifests = load_manifests(manifest_dir) differences = validate_manifests(manifests) expected_by_name = { @@ -204,6 +231,15 @@ def verify_rulesets( ) for name, (key, expected) in sorted(expected_by_name.items()): matching_rulesets = actual_by_name.get(name) + activation = manifests[key].get("activation") + remote_state = activation.get("remote_state") if isinstance(activation, Mapping) else None + if source == "live" and remote_state == "not_applied": + if matching_rulesets is not None: + differences.append( + f"{key}.activation.remote_state: manifest declares 'not_applied' but " + f"live API returned Ruleset {name!r}" + ) + continue if matching_rulesets is None: differences.append(f"{key}: missing actual Ruleset named {name!r}") continue @@ -308,8 +344,9 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) try: + source = "fixture" if args.fixture else "live" rulesets = load_fixture(args.fixture) if args.fixture else load_live_rulesets(args.repo) - report = verify_rulesets(args.manifest_dir, rulesets) + report = verify_rulesets(args.manifest_dir, rulesets, source=source) except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: report = {"ok": False, "differences": [f"verifier error: {error}"]} From c26dc269e1875169a26d4a851fc12963f2592837 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Wed, 2 Sep 2026 08:29:09 +0800 Subject: [PATCH 29/32] feat(backend): modernize fincore and runtime compatibility --- docs/operations/TROUBLESHOOTING.md | 2 +- ...tion-195-cross-platform-product-release.md | 1018 +++++++++++++++++ scripts/ops/start_app.sh | 19 +- src/backend/FINCORE_MIGRATION.md | 83 +- src/backend/README.md | 7 +- src/backend/app/db/database.py | 65 ++ .../app/services/backtest/analyzers.py | 235 +++- .../app/services/fincore_metrics_helper.py | 86 +- src/backend/app/services/metrics_service.py | 5 +- .../services/semantic_retrieval_service.py | 61 + src/backend/pyproject.toml | 4 +- src/backend/requirements.txt | 2 +- .../tests/asset_research/test_orchestrator.py | 7 +- src/backend/tests/test_db.py | 76 ++ src/backend/tests/test_fincore_adapter.py | 39 +- src/backend/tests/test_fincore_integration.py | 2 +- .../tests/test_fincore_metrics_helper.py | 25 +- src/backend/tests/test_metrics_service.py | 20 + .../tests/test_semantic_retrieval_service.py | 128 +++ 19 files changed, 1764 insertions(+), 120 deletions(-) create mode 100644 docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md create mode 100644 src/backend/tests/test_semantic_retrieval_service.py diff --git a/docs/operations/TROUBLESHOOTING.md b/docs/operations/TROUBLESHOOTING.md index b92c3e1ea..5a603fb8b 100644 --- a/docs/operations/TROUBLESHOOTING.md +++ b/docs/operations/TROUBLESHOOTING.md @@ -20,7 +20,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple pip install backtrader==1.9.78.123 # 如果 fincore 安装失败 -pip install "fincore>=0.3.0" +pip install "fincore>=0.5.0,<0.6.0" ``` ### 1.2 Node.js 依赖安装失败 diff --git a/docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md b/docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md new file mode 100644 index 000000000..5c597d477 --- /dev/null +++ b/docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md @@ -0,0 +1,1018 @@ +# 迭代 195:跨平台产品发布与可观测性 Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 backtrader_web 交付为可在 macOS、Ubuntu 和 Windows 11 上安全安装、启动、停止、重启、升级和排障的产品,并把关键操作纳入可检索、可关联、可验收的日志与发布治理体系。 + +**Architecture:** 产品发行版采用 Docker Compose 优先的本地产品档(Linux 容器同时运行于 macOS、Ubuntu、Windows 11 的 Docker Desktop/Engine),以避免用户分别安装 Python、Node、MySQL、Redis 后产生不可复现的组合。根目录的 start_app、stop_app、restart_app 脚本只做稳定入口;平台特定控制器只管理本 Compose 项目,绝不按端口或模糊进程名杀进程。现有原生启动链保留为开发者模式,不能再被描述为生产产品启动方式。 + +**Tech Stack:** Docker Compose v2、Linux amd64/arm64 OCI 镜像、Python 3.11、FastAPI、Alembic、MySQL 8、Redis 7、Vue 3/Vite 静态构建、Nginx、Loguru、OpenTelemetry、pytest、Vitest、GitHub Actions。 + +--- + +## 0. 本计划的边界、决策与完成定义 + +本文件是实施计划,不代表以下能力已经实现。除本计划外,不应通过新增产品入口顺带开放实盘交易、绕过审批、改变行情/模型结论,或把开发环境默认值带入产品环境。 + +### 0.1 推荐的发行策略和决策门 + +| 方案 | 说明 | 结论 | +| --- | --- | --- | +| Docker-first 产品档 | 用户先安装 Docker Desktop/Engine,脚本拉取经签名的多架构镜像,自动配置隔离数据库和运行目录 | **推荐且为 v1.0 发布门槛**;三平台行为最一致 | +| 原生开发档 | 脚本在项目目录创建虚拟环境、安装锁定依赖,使用 SQLite 或显式外部数据库 | 保留给开发/排障;不能作为 v1.0 的“普通用户一键安装”承诺 | +| 原生桌面安装包 | DMG/MSI/AppImage 内嵌运行时与升级器 | 作为 v1.1+ 选项;只有在产品负责人明确要求“不依赖 Docker”后立项 | + +**Gate-0 决策:** 产品负责人必须在实施开始前书面确认 Docker-first 为 v1.0 的对外支持边界,或改为投入原生桌面安装包。若选择后者,后续任务的测试矩阵、签名、升级器和工期必须重新估算;不得把两种发行方式混合后宣称均已验收。 + +### 0.2 v1.0 交付定义 + +1. 新用户在受支持系统中执行根目录入口后,获得可登录的本地产品 URL,且不需单独启动前端、后端、数据库或缓存。 +2. start、stop、restart、status、logs、doctor 都有一致的退出码和可读输出;stop 只能停止本产品拥有的容器,restart 不删除任何诊断日志。 +3. 首次配置生成高熵密钥,禁止默认管理员口令、隐式建表和隐式实盘开关。 +4. 数据库迁移、健康检查、版本信息、日志、备份恢复和升级都有独立、可自动验证的契约。 +5. GitHub Release 中存在可复现的镜像摘要、SBOM、签名/证明、校验和、支持矩阵和已知限制;发布候选在三种目标系统上均有证据。 +6. 研究、回测和模拟盘可作为 v1.0 主路径;实盘交易默认关闭,只有完成迭代 193 的授权、隔离和人工审批门槛后才能作为单独发布的功能档开放。 + +### 0.3 不在本迭代中做的事 + +- 不静默安装 Docker、系统 Python、Node、浏览器驱动、数据库服务或交易终端;doctor 必须给出平台对应的前置条件和链接。 +- 不在 start 命令中执行不可逆的数据清空、数据库降级或强制升级;升级/恢复必须显式确认并先产出备份。 +- 不以“端口可监听”代替应用、数据库、迁移和前端均就绪。 +- 不把请求体、口令、Cookie、API Key、券商凭据、原始模型提示词或完整交易数据写入普通日志。 + +## 1. 当前审计结论与本计划的对应关系 + +| 优先级 | 已核实的现状 | 风险 | 本计划处置 | +| --- | --- | --- | --- | +| P0 | 期望保留的根目录 start_app、stop_app、restart_app 兼容入口当前不存在;迭代 174 的记录却称其已保留 | 用户和文档中的命令立即失效 | Task 1 | +| P0 | scripts/windows/start_app.bat 将 scripts/windows 当作项目根目录,导致 src/backend 和 src/frontend 路径错误;scripts/ops/app.bat 的转发路径也不正确 | Windows 11 无法可靠启动 | Task 1、Task 4 | +| P0 | Unix 启动器只检查少量 import,默认启动 Vite 开发服务器;不会基于锁文件建环境、运行迁移或完成前端健康验证 | 开发脚本被误作产品启动器,安装不可复现 | Task 2、Task 4、Task 5 | +| P0 | Unix/Windows stop 会按端口或宽泛进程名强杀进程;Windows restart 会删除后端、前端和异步日志 | 误杀其他服务且破坏故障证据 | Task 4 | +| P0 | Docker 基础 Compose 文件本身没有服务;local 档依赖宿主 MySQL;prod 档暴露 3306 并启用自动建表/默认管理员 | 不能作为安全的一键产品档 | Task 2、Task 3 | +| P0 | 当前 CI 工作流均运行于 ubuntu-latest;没有 macOS/Windows 生命周期验收 | 无法声明三平台支持 | Task 8 | +| P0 | release.sh 固定为 v0.1.0,发布说明中仍有 docker compose up 一类无效命令,未产生 SBOM/摘要/签名证据 | 发布物不可追溯 | Task 9 | +| P0 | 现有日志基础设施已经有 JSON、脱敏、request_id、task_id、trace_id、审计文件和回归测试;但 AuditLogger 的内建事件只覆盖登录、权限、策略和回测,启动器日志会截断/删除 | 关键产品操作无法统一审计与关联 | Task 6、Task 7 | +| P0 | src/backend/alembic 已有唯一 head,但 docs/operations/DATABASE_INIT.md 仍把 Alembic 描述为未来能力 | 首装、升级和故障恢复会使用冲突流程 | Task 3、Task 11 | +| P0 | 迭代 193 仍列出供应链、授权隔离、e2e 隔离、DR 恢复等未关闭验收项 | 平台即使能启动也不应宣称生产就绪 | Task 9、Task 10、Task 12 | +| P1 | 迭代 194 的后端/前端大文件、N+1、阻塞 I/O、分页与测试债仍在切片治理 | 可维护性和容量风险持续累积 | Task 13,作为持续门禁而非阻塞根安装器 | +| P1 | 多资产真实数据、模型质量、数据许可、PIT 证据仍有未闭合门禁 | 不可将研究输出宣传为已验证投资建议 | Task 12、Task 13 | + +当前已做的定向验证(仅用于建立本计划基线): + +- Unix 生命周期脚本的 Bash 语法检查通过。 +- Alembic 报告唯一 head:20260811_asset_research_task_leases。 +- src/backend/tests/test_app_lifecycle_scripts.py:1 passed。 +- 日志定向套件:83 passed、3 skipped;基础日志能力不能被重复建设或回退。 + +## 2. 目标运行模型 + +### 2.1 目录和所有权契约 + + repository/ + start_app.sh | stop_app.sh | restart_app.sh + start_app.bat | stop_app.bat | restart_app.bat + scripts/product/ + app.sh + app.bat + init_product_env.sh + init_product_env.ps1 + docker/compose/product.yml + runtime/ # Git 忽略、用户数据,不由 restart 清理 + product.env # 0600 / 仅当前 Windows 用户可读 + compose/ # Compose project state + mysql/ + redis/ + backend/logs/ + backups/ + launcher-events.jsonl + +产品 Compose project name 固定为 backtrader-web-product,但每个命令必须从运行目录读取配置,不得操作同机任意其他 Compose project。数据库、Redis 和后端只在内部网络暴露;默认仅将前端绑定到 127.0.0.1:8080。对外网暴露、反向代理、TLS、独立 MySQL 或实盘网关必须使用另一个服务器部署档并明确配置。 + +### 2.2 生命周期状态机 + +| 命令 | 允许的前态 | 成功后状态 | 失败行为 | +| --- | --- | --- | --- | +| start | NOT_INSTALLED、STOPPED、DEGRADED | READY | 保留日志和容器状态,输出失败服务及修复建议 | +| stop | READY、DEGRADED、STOPPED | STOPPED | 幂等;绝不按端口杀非本项目进程 | +| restart | READY、DEGRADED、STOPPED | READY | 先 stop,再 start;不清理日志、数据库、卷或备份 | +| status | 任意 | 不改变状态 | 输出 backend、frontend、db、redis、迁移版本、镜像摘要 | +| doctor | 任意 | 不改变状态 | 验证 Docker/Compose、磁盘、端口、目录权限、版本兼容和密钥文件权限 | +| upgrade | READY、STOPPED | READY 或明确失败 | 拉取已签名摘要,备份,迁移,健康验证;失败进入可回滚状态 | + +## 3. 工作流、责任划分与并行规则 + +| Lane | 主责 | 可并行任务 | 互斥文件/前置条件 | +| --- | --- | --- | --- | +| A:产品运行时 | Runtime Engineer | Task 1、2、4 | Task 1 先于 Task 4;统一拥有 root 脚本和 scripts/product | +| B:后端与数据库 | Backend/DB Engineer | Task 3、5、6、7、10 | Task 3 的迁移契约先于 Task 2 最终 compose;不要改 A 的脚本 | +| C:前端与镜像 | Frontend/Release Engineer | Task 5、9、11 | Task 5 的镜像标签接口需与 Task 9 一致 | +| D:质量与安全 | QA/SRE/Security Engineer | Task 8、9、10、12 | 维护证据目录和发布闸门;不得以局部绿替代 RC 证据 | +| E:持续质量 | 各领域 owner | Task 13 | 不与 v1.0 launcher 改动混合提交 | + +建议以 4 名工程师并行:A/B/C/D 分别负责一条 Lane。预计研发量为 30–42 人日,另加 5–8 人日的三平台 RC、恢复演练和发布审批;这是工作量区间,不是发布日期承诺。 + +## 4. 任务清单 + +### Task 0:锁定产品档架构和对外支持边界 + +**Owner:** 技术负责人 + 产品负责人 +**Files:** + +- Create: docs/adr/014-product-runtime-and-distribution.md +- Modify: docs/adr/README.md +- Modify: docs/iterations/README.md +- Modify: docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md + +**Step 1: 写出待决策的验收表** + +在 ADR 中列出 Docker-first、原生开发档、原生桌面安装包三种方案的依赖、支持系统、升级、数据目录、签名和回滚差异。把 Docker-first 标记为推荐方案,并要求负责人确认。 + +**Step 2: 验证文档在当前导航中可达** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_doc_links.py + +Expected: exit 0,ADR、迭代索引和本计划链接均可解析。 + +**Step 3: 固化不可违反的契约** + +ADR 必须明确以下规则:产品默认 paper/research-only;容器只用 Linux amd64/arm64;Windows 是 Linux containers on Docker Desktop,不是 Windows container;普通用户不必安装 Node/Python;不支持 Docker 时 doctor 失败而不是悄悄转入不受控环境。 + +**Step 4: Commit** + + git add docs/adr/014-product-runtime-and-distribution.md docs/adr/README.md docs/iterations/README.md docs/plans/2026-08-17-iteration-195-cross-platform-product-release.md + git commit -m "docs(release): decide product runtime distribution" + +### Task 1:恢复稳定根入口并修正跨平台路径契约 + +**Owner:** Runtime Engineer +**Files:** + +- Create: start_app.sh +- Create: stop_app.sh +- Create: restart_app.sh +- Create: start_app.bat +- Create: stop_app.bat +- Create: restart_app.bat +- Create: src/backend/tests/test_product_launcher_contract.py +- Modify: scripts/ops/app.sh +- Modify: scripts/ops/app.bat +- Modify: scripts/ops/start_app.sh +- Modify: scripts/ops/stop_app.sh +- Modify: scripts/ops/restart_app.sh +- Modify: scripts/windows/start_app.bat +- Modify: scripts/windows/stop_app.bat +- Modify: scripts/windows/restart_app.bat +- Create: .gitattributes +- Modify: .gitignore + +**Step 1: 写失败的入口契约测试** + +测试必须从任意当前工作目录调用根入口,并断言: + +- 六个根入口真实存在且可执行/可调用; +- Unix 入口以仓库根为锚点,不依赖调用目录; +- Windows 入口把 %~dp0 解释为仓库根,而非 scripts/windows; +- scripts/ops 旧入口只做兼容转发,不能留下第二套生命周期实现; +- .sh 为 LF,.bat 为 CRLF; +- 传递的 start、stop、restart、status、logs、doctor 参数不丢失。 + +示例断言: + + def test_windows_launcher_resolves_repository_root() -> None: + launcher = ROOT / "start_app.bat" + text = launcher.read_text(encoding="utf-8") + assert "scripts/product/app.bat" in text.replace("\\", "/") + assert "scripts/windows/src/backend" not in text.replace("\\", "/") + +**Step 2: 运行测试,确认旧实现失败** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_launcher_contract.py -q + +Expected: 当前仓库因根入口缺失和 Windows 相对根路径错误而失败。 + +**Step 3: 以极薄兼容层实现根入口** + +Unix root shim 的目标形态如下;stop/restart 只替换第一个参数: + + #!/usr/bin/env bash + set -euo pipefail + ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" + exec "$ROOT_DIR/scripts/product/app.sh" start "$@" + +Windows root shim 的目标形态如下: + + @echo off + call "%~dp0scripts/product/app.bat" start %* + exit /b %ERRORLEVEL% + +旧 scripts/ops 与 scripts/windows 文件必须改为同一控制器的兼容 shim 或明确的 developer-only shim。不要复制 start/stop 逻辑。 + +**Step 4: 验证脚本格式和静态契约** + +Run: + + bash -n start_app.sh stop_app.sh restart_app.sh scripts/product/app.sh + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_launcher_contract.py -q + +Expected: 全绿;Windows 的实际调用在 Task 8 的 windows-latest job 中验证。 + +**Step 5: Commit** + + git add start_app.sh stop_app.sh restart_app.sh start_app.bat stop_app.bat restart_app.bat scripts/ops scripts/windows scripts/product .gitattributes .gitignore src/backend/tests/test_product_launcher_contract.py + git commit -m "fix(release): restore cross-platform product entrypoints" + +### Task 2:创建可发行的 product Compose 档和多架构镜像契约 + +**Owner:** Runtime Engineer + Release Engineer +**Files:** + +- Create: docker/compose/product.yml +- Create: docker/product.env.example +- Create: docker/release-images.env.example +- Create: scripts/ci/verify_product_compose.py +- Create: tests/product/test_product_compose.py +- Modify: docker/docker-compose.yml +- Modify: src/backend/Dockerfile +- Modify: src/frontend/Dockerfile +- Modify: docker/nginx.prod.conf +- Modify: .gitignore + +**Step 1: 写失败的 Compose 契约测试** + +测试读取 docker/compose/product.yml 并断言: + +- backend、frontend、mysql、redis、migrate 五个服务存在; +- MySQL、Redis、backend 没有宿主 ports; +- frontend 默认只绑定 127.0.0.1; +- backend 依赖 migrate 成功完成; +- DB_AUTO_CREATE_SCHEMA 和 DB_AUTO_CREATE_DEFAULT_ADMIN 均为 false; +- 所有持久目录位于 runtime/ 或命名 volume; +- 镜像使用发布摘要变量,产品档不得包含 build:; +- product profile 不引用 host.docker.internal 或宿主 MySQL。 + +**Step 2: 运行测试,确认当前 Compose 不能满足产品档** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/product/test_product_compose.py -q + docker compose -f docker/docker-compose.yml -f docker/compose/product.yml config + +Expected: 测试在新文件实现前失败;当前 local/prod 档不被误判为 product 档。 + +**Step 3: 实现 product.yml** + +实现独立产品覆盖文件而不是修改 dev/local/prod 的既有语义。关键形态如下: + + services: + migrate: + image: + command: ["alembic", "upgrade", "head"] + restart: "no" + backend: + image: + depends_on: + migrate: + condition: service_completed_successfully + frontend: + image: + ports: + - "127.0.0.1::80" + +在 release-images.env 中只允许不可变 digest,不允许 latest。Dockerfile 必须能构建 linux/amd64 与 linux/arm64,且镜像内使用非 root 用户。确认前端镜像只服务已构建静态文件,不运行 Vite。 + +**Step 4: 验证配置与启动冒烟** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/verify_product_compose.py + docker compose --project-name backtrader-web-product-ci --env-file docker/product.env.example -f docker/docker-compose.yml -f docker/compose/product.yml config + +Expected: 配置验证通过;示例文件不会含真实凭据,也不会展开为对外暴露数据库。 + +**Step 5: Commit** + + git add docker docker/nginx.prod.conf src/backend/Dockerfile src/frontend/Dockerfile scripts/ci/verify_product_compose.py tests/product/test_product_compose.py .gitignore + git commit -m "feat(release): add isolated product compose profile" + +### Task 3:实现安全首次配置、显式迁移与数据库生命周期 + +**Owner:** Backend/DB Engineer +**Files:** + +- Create: scripts/product/init_product_env.sh +- Create: scripts/product/init_product_env.ps1 +- Create: src/backend/tests/test_product_bootstrap.py +- Create: src/backend/tests/test_product_migration_contract.py +- Create: src/backend/alembic/versions/20260817_product_bootstrap_state.py +- Modify: src/backend/app/config.py +- Modify: src/backend/app/startup/database.py +- Modify: src/backend/app/db/database.py +- Modify: src/backend/app/main_routes.py +- Modify: docker/compose/product.yml +- Modify: docker/product.env.example + +**Step 1: 写失败的安全配置和迁移测试** + +覆盖以下场景: + +- product.env 缺失时初始化生成两个不同的 32 字节以上随机密钥; +- 生成的文件不包含 admin/admin123、replace-with、dev-secret 等默认值; +- Unix 权限为 0600;Windows 初始化脚本只授权当前用户; +- 运行中的 product 档拒绝 DB_AUTO_CREATE_SCHEMA=true 和 DB_AUTO_CREATE_DEFAULT_ADMIN=true; +- 迁移服务在 database 未就绪时失败,ready 后只能执行一次; +- 两个并发 migrate 命令只允许一个获取数据库迁移锁; +- 首次管理员只能由一次性本地 onboarding token 创建,token 不写普通日志。 + +**Step 2: 运行失败测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_bootstrap.py tests/test_product_migration_contract.py -q + +Expected: 新行为未实现前失败;现有开发环境自举不能被视为产品档通过。 + +**Step 3: 最小实现** + +- 初始化脚本只创建 runtime/product.env、runtime/ 目录和一个一次性 onboarding token;不得打印 secret。 +- product Compose 的 migrate 服务在 backend 启动前执行 Alembic upgrade head,并记录当前/目标 revision、镜像 digest 和耗时。 +- 使用数据库 advisory lock 或等价的 MySQL lock 保护迁移;任何失败均非零退出且 backend 不启动。 +- 保留 SQLite、MySQL、PostgreSQL 的应用配置能力,但 v1.0 product profile 固定为容器内 MySQL;原生开发档可继续使用 SQLite。 +- 增加 liveness、readiness、version 三类端点。readiness 必须同时验证迁移 revision、数据库连接与关键依赖;liveness 不访问数据库。 + +**Step 4: 运行回归与迁移检查** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m alembic heads + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_bootstrap.py tests/test_product_migration_contract.py tests/test_app_lifecycle_scripts.py -q + +Expected: 一个 Alembic head;所有 product bootstrap 测试通过;不存在隐式 create_all 成功即继续的产品路径。 + +**Step 5: Commit** + + git add scripts/product/init_product_env.sh scripts/product/init_product_env.ps1 src/backend/app src/backend/alembic/versions docker/compose/product.yml docker/product.env.example src/backend/tests/test_product_bootstrap.py src/backend/tests/test_product_migration_contract.py + git commit -m "feat(release): add secure product bootstrap and migration gate" + +### Task 4:实现无破坏性的跨平台生命周期控制器 + +**Owner:** Runtime Engineer +**Files:** + +- Create: scripts/product/app.sh +- Create: scripts/product/app.bat +- Create: scripts/product/tests/fake_docker.py +- Create: src/backend/tests/test_product_lifecycle_controller.py +- Modify: start_app.sh +- Modify: stop_app.sh +- Modify: restart_app.sh +- Modify: start_app.bat +- Modify: stop_app.bat +- Modify: restart_app.bat +- Modify: scripts/ops/app.sh +- Modify: scripts/ops/app.bat +- Modify: scripts/windows/start_app.bat +- Modify: scripts/windows/stop_app.bat +- Modify: scripts/windows/restart_app.bat + +**Step 1: 写失败的控制器测试** + +使用 fake Docker 二进制记录 argv,覆盖: + +- start 调用 compose pull、migrate、up --wait 与 readiness 检查; +- stop 只使用项目名与产品 compose 文件,不调用 kill、taskkill、lsof、netstat 强杀或 docker system prune; +- restart 顺序为 stop 后 start,且 runtime/backend/logs 文件的内容不变; +- status 输出服务健康、镜像摘要、Alembic revision 与本地 URL; +- logs 只跟随本 project 的服务; +- doctor 返回结构化失败码:缺 Docker、Compose 过旧、端口冲突、权限不足、磁盘不足、配置无效; +- 同时启动两个命令时第二个因 runtime/launch.lock 退出,不能产生竞争。 + +**Step 2: 运行测试,确认旧脚本失败** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_lifecycle_controller.py tests/test_product_launcher_contract.py -q + +Expected: 旧脚本因端口杀进程、日志删除、未等待 readiness 或路径偏差而失败。 + +**Step 3: 实现命令契约** + +Unix 和 Windows 的 app 控制器必须接受相同的子命令: + + start [--port N] [--offline] + stop + restart [--port N] [--offline] + status [--json] + logs [backend|frontend|db|all] + doctor [--json] + upgrade + +start 的成功输出必须包括浏览器 URL、release version、镜像 digest、日志目录、备份目录和 paper-only 状态。失败时输出 service 名称、最近日志路径、request/launch ID 与不含敏感信息的恢复建议。 + +**Step 4: 验证控制器不破坏外部进程** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_lifecycle_controller.py -q + bash -n scripts/product/app.sh start_app.sh stop_app.sh restart_app.sh + +Expected: 测试会使用一个占用同端口但不属于本 Compose project 的假进程,并断言 stop/restart 后该进程仍存活。 + +**Step 5: Commit** + + git add scripts/product start_app.sh stop_app.sh restart_app.sh start_app.bat stop_app.bat restart_app.bat scripts/ops scripts/windows src/backend/tests/test_product_lifecycle_controller.py src/backend/tests/test_product_launcher_contract.py + git commit -m "feat(release): add safe cross-platform lifecycle controller" + +### Task 5:收敛为真正的产品 Web 运行时和可判定健康检查 + +**Owner:** Frontend/Backend Engineer +**Files:** + +- Create: src/backend/tests/test_product_readiness.py +- Create: src/frontend/src/__tests__/buildInfo.test.ts +- Modify: src/backend/app/main.py +- Modify: src/backend/app/main_routes.py +- Modify: src/backend/app/config.py +- Modify: src/backend/Dockerfile +- Modify: src/frontend/Dockerfile +- Modify: docker/nginx.prod.conf +- Modify: src/frontend/vite.config.ts +- Modify: src/frontend/src/main.ts +- Create: src/frontend/src/services/buildInfo.ts +- Modify: docker/compose/product.yml + +**Step 1: 写失败测试** + +后端测试应区分: + +- GET /health 或 /livez 只证明进程存活; +- GET /readyz 在迁移版本不一致、数据库不可用、关键 worker 未准备好时非 200; +- GET /version 返回应用版本、Git SHA、构建时间、镜像 digest 与 schema revision,但不泄露环境变量。 + +前端测试应断言 build info 可显示、API 默认使用同源相对地址、生产构建不依赖 Vite dev server。 + +**Step 2: 运行失败测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_readiness.py -q + cd ../frontend + npm run test -- --run src/__tests__/buildInfo.test.ts + +Expected: 现状中仅健康端点与版本常量不足以满足完整就绪契约。 + +**Step 3: 实现** + +- 前端镜像使用多阶段 build,最终只含 Nginx 和 dist;移除任何产品档中的 npm run dev。 +- Nginx 代理 /api、/ws,并提供 SPA fallback、合理缓存、CSP 基线和静态资源缓存策略。 +- 后端启动前后的状态明确记录;Gunicorn/Uvicorn 优雅停止必须等待任务取消或到期并写入最终事件。 +- 产品档通过 frontend 与 backend 两个独立 readiness 条件后才输出 READY。 + +**Step 4: 运行验证** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_readiness.py -q + cd ../frontend + npm run typecheck + npm run build + +Expected: 后端端点、前端生产构建和 build-info 测试通过。 + +**Step 5: Commit** + + git add src/backend/app src/backend/tests/test_product_readiness.py src/backend/Dockerfile src/frontend docker/nginx.prod.conf docker/compose/product.yml + git commit -m "feat(release): serve verified production web runtime" + +### Task 6:以统一事件契约补齐关键操作日志和审计 + +**Owner:** Backend/Observability Engineer +**Files:** + +- Create: src/backend/app/observability/operation_events.py +- Create: src/backend/app/schemas/operation_event.py +- Create: src/backend/tests/test_operation_events.py +- Create: src/backend/tests/test_operation_event_redaction.py +- Modify: src/backend/app/utils/logger.py +- Modify: src/backend/app/middleware/logging.py +- Modify: src/backend/app/services/audit_service.py +- Modify: src/backend/app/api/auth.py +- Modify: src/backend/app/services/backtest/service.py +- Modify: src/backend/app/services/asset_research/task_runner.py +- Modify: src/backend/app/services/gateway/runtime.py +- Modify: src/backend/app/services/direct_order_service.py +- Modify: src/backend/app/services/ai_strategy_research_service.py +- Modify: src/backend/app/api/audit.py +- Modify: docs/operations/LOGGING.md + +**Step 1: 写失败的事件模式测试** + +定义 schema_version=1。每个关键事件至少具有: + + event_name + outcome + occurred_at + release_version + request_id + task_id + trace_id + actor_type + actor_id + resource_type + resource_id + duration_ms + error_code + +其中 request_id、task_id、trace_id、actor/resource 字段可为空,但必须明确为 null,而不是混用缺失、N/A、空字符串。测试必须验证事件模式、JSON 可解析、敏感字段脱敏、异常中没有密钥、关联 ID 从 HTTP 到后台任务保留。 + +**Step 2: 运行失败测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_operation_events.py tests/test_operation_event_redaction.py -q + +Expected: 新事件 schema 与覆盖范围实现前失败。 + +**Step 3: 最小实现和覆盖清单** + +用一个类型化 emit_operation_event 帮助器接入既有 Loguru、AuditRecord 和 OpenTelemetry,而不是并行创建第三个日志系统。必须至少记录以下事件: + +| 域 | 必须记录的事件 | +| --- | --- | +| 生命周期 | install/config_created, start_requested/ready/failed, stop_requested/completed, restart_requested, upgrade_started/rolled_back, migration_started/completed/failed | +| 身份与授权 | login、logout、refresh、password_changed、role_changed、permission_denied、onboarding_completed | +| 研究与策略 | strategy CRUD、strategy version promote、backtest queued/started/completed/cancelled/failed、report exported | +| 数据与 AI | provider fetch started/completed/timeout/fallback、data refresh、research task lifecycle、model call 的 provider/model/token/cost 摘要和 budget decision | +| 交易高风险边界 | gateway credential configured 的元数据、connect/disconnect、paper order、live order attempt/approval/rejection;永不记录 credentials、完整订单备注或原始账户号 | +| 运维 | backup、restore drill、health degradation、retention cleanup、audit export | + +高风险事件需同时进入现有持久 AuditRecord 路径和带 tags=[audit] 的结构化日志;普通诊断事件只需日志/trace。客户端上传审计事件必须保留服务端 actor 校验,不能让浏览器伪造高风险服务器事件。 + +**Step 4: 运行日志回归** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_enhanced_logger.py tests/test_structured_logging.py tests/test_logging_middleware.py tests/test_audit_and_logging.py tests/test_operation_events.py tests/test_operation_event_redaction.py -q + +Expected: 既有日志能力不回退;新增事件全部通过模式、脱敏和关联测试。 + +**Step 5: Commit** + + git add src/backend/app/observability src/backend/app/schemas src/backend/app/utils/logger.py src/backend/app/middleware/logging.py src/backend/app/services src/backend/app/api src/backend/tests docs/operations/LOGGING.md + git commit -m "feat(observability): audit product operations with typed events" + +### Task 7:将日志、指标、trace、告警和保留策略接成可操作闭环 + +**Owner:** Observability/SRE Engineer +**Files:** + +- Create: config/dashboards/product-runtime-dashboard.json +- Create: config/alerting/product-runtime-alerts.yaml +- Create: src/backend/tests/test_product_observability_contract.py +- Create: scripts/ci/check_alert_metric_alignment.py +- Modify: src/backend/app/telemetry.py +- Modify: src/backend/app/middleware/metrics.py +- Modify: src/backend/app/main.py +- Modify: src/backend/app/config.py +- Modify: docker/compose/product.yml +- Modify: docs/operations/LOGGING.md +- Modify: docs/operations/OPERATIONS.md + +**Step 1: 写失败的可观测性契约测试** + +测试检查启动失败、迁移失败、数据库不可用、慢请求、后台任务超时、备份失败分别产生可查询事件、指标和关联 trace。测试还要断言 LOG_RETENTION_APP_DAYS、LOG_RETENTION_ERROR_DAYS、LOG_RETENTION_AUDIT_DAYS 的配置一致,日志清理不会删除当天活跃文件或审计保留期内文件。 + +**Step 2: 运行失败测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_observability_contract.py tests/test_telemetry_e2e.py -q + +Expected: 当前缺少产品启动器指标、产品专用告警及完整恢复证据时失败。 + +**Step 3: 实现可观测性闭环** + +- 保留现有 JSON stdout、文件轮转、压缩和 trace correlation;产品容器以 JSON 输出,不输出 ANSI 色码。 +- 新增 product_start_total、product_ready、product_migration_duration_seconds、backup_age_seconds、audit_event_write_failures_total、task_timeout_total 等明确指标;每个告警规则都必须有真实 producer。 +- OTEL 由配置显式启用;未配置 exporter 时 trace 必须无副作用地降级。 +- launcher-events.jsonl 采用追加写入和大小/日期轮转,restart 禁止清空它。 +- 定义 RED/USE dashboard、告警阈值、责任人、抑制规则与 runbook 链接;不可只提交未被调用的 metrics 函数或 YAML 名称。 + +**Step 4: 验证** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_observability_contract.py tests/test_ai_observability.py tests/test_telemetry_e2e.py -q + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_alert_metric_alignment.py + +Expected: 事件、指标、告警名和 runbook 100% 对齐。 + +**Step 5: Commit** + + git add config/dashboards config/alerting src/backend/app src/backend/tests docker/compose/product.yml docs/operations + git commit -m "feat(observability): add product runtime monitoring contract" + +### Task 8:建立三平台验证矩阵与生命周期端到端测试 + +**Owner:** QA/SRE Engineer +**Files:** + +- Create: .github/workflows/product-lifecycle.yml +- Create: tests/product/test_product_e2e.py +- Create: tests/product/test_product_windows.bat +- Create: scripts/ci/run_product_smoke.sh +- Create: scripts/ci/run_product_smoke.ps1 +- Modify: .github/CODEOWNERS +- Modify: README.md + +**Step 1: 写失败的 E2E 规格** + +规格要逐条覆盖 macOS、Ubuntu、Windows 11: + +1. fresh workspace 与空 runtime; +2. doctor 显示缺失前置条件或通过; +3. start 完成配置、迁移、数据库与前端就绪; +4. GET /livez、/readyz、/version 以及浏览器 login 页面通过; +5. 创建/登录测试用户并运行一个离线回测或受控 fixture; +6. logs 可找到相同 request_id/task_id; +7. restart 后用户数据和历史日志仍存在; +8. stop 后仅本项目容器停止; +9. upgrade 的备份/迁移/回滚分支在隔离项目中通过。 + +**Step 2: 先实现无 Docker 的脚本静态测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_launcher_contract.py tests/test_product_lifecycle_controller.py -q + +Expected: 每个 PR 都能先在 hosted runner 验证入口和参数,不依赖 Docker Desktop。 + +**Step 3: 实现分层 CI** + +- PR 主矩阵:ubuntu-latest、macos-latest、windows-latest,运行 shell/batch 路径、格式、fake-Docker 和 contract tests。 +- Linux 容器 E2E:每个 PR 运行 product compose 的真实 start/restart/stop。 +- macOS(Intel 与 Apple Silicon)和 Windows 11 的 Docker Desktop E2E:使用受控 self-hosted release runners,或在 RC 期间由指定人工运行同一脚本并上传不可变证据;不得把 Linux 绿冒充三平台绿。 +- workflow 设 timeout、concurrency、最小权限、缓存键绑定 lockfile;收集 compose ps、inspect、健康输出和去敏日志作为失败 artifact。 + +**Step 4: 运行验证** + +Run: + + bash scripts/ci/run_product_smoke.sh --mode linux + pwsh -File scripts/ci/run_product_smoke.ps1 -Mode windows-contract + +Expected: Linux 本地/CI 冒烟全绿;Windows 真实 Docker 证据在 release runner 中产生。 + +**Step 5: Commit** + + git add .github/workflows/product-lifecycle.yml .github/CODEOWNERS tests/product scripts/ci/run_product_smoke.sh scripts/ci/run_product_smoke.ps1 README.md + git commit -m "ci(release): verify product lifecycle across supported platforms" + +### Task 9:完成发布供应链、版本单一事实源与安全发布门 + +**Owner:** Release/Security Engineer +**Files:** + +- Create: scripts/release/build_release_manifest.py +- Create: scripts/release/verify_release_manifest.py +- Create: scripts/ci/check_version_sync.py +- Create: docs/release/RELEASE_CHECKLIST.md +- Create: docs/release/SUPPORTED_PLATFORMS.md +- Create: tests/product/fixtures/invalid-manifest.json +- Modify: scripts/ops/release.sh +- Modify: .github/workflows/docker-publish.yml +- Modify: .github/workflows/ci.yml +- Modify: src/backend/pyproject.toml +- Modify: src/frontend/package.json +- Modify: src/backend/app/main.py +- Modify: src/backend/app/main_routes.py +- Modify: CHANGELOG.md +- Modify: LICENSE +- Create: NOTICE + +**Step 1: 写失败的版本与发布清单测试** + +断言 backend package、frontend package、API version、release manifest、image tag 和 CHANGELOG 指向同一版本。验证 manifest 中有: + +- Git commit、签名 tag、backend/frontend image digest; +- linux/amd64、linux/arm64 架构清单; +- SBOM 路径、许可证清单、校验和、构建时间; +- 目标平台、最低 Docker/Compose 版本、已知限制; +- 完整 RC 证据链接。 + +**Step 2: 运行失败测试** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_version_sync.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/release/verify_release_manifest.py --fixture tests/product/fixtures/invalid-manifest.json + +Expected: 当前硬编码 v0.1.0 的 release.sh 和版本分散状态应被检测为失败。 + +**Step 3: 实现安全发布链** + +- 版本从一个经过评审的文件或生成脚本导出,其他位置由 CI 校验,禁止手工漂移。 +- docker-publish 构建多架构 manifest,生成 CycloneDX 或 SPDX SBOM,执行许可证审阅、pip/npm 漏洞门禁、镜像扫描、digest 校验和 keyless Cosign 签名/证明。 +- release.sh 改为编排验证脚本,不得硬编码仓库名、版本、latest、过期 Quick Start 或无服务的 docker compose up。 +- 发布资产包含 product compose bundle、release-images.env、SHA256SUMS、SBOM、release manifest、升级/回滚说明;用户从 release digest 安装而不是本地 build。 +- 将迭代 193 的未关闭安全项作为严格门:网关资源所有权隔离、admin 保留名、默认密钥/管理员防护、Docker sandbox、覆盖率与安全扫描双轨一致。 + +**Step 4: 验证** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_version_sync.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/release/build_release_manifest.py --dry-run + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/release/verify_release_manifest.py --dry-run + +Expected: 所有版本字段一致;manifest 能验证;不能以 latest 或可变 tag 作为 v1.0 安装源。 + +**Step 5: Commit** + + git add scripts/release scripts/ci/check_version_sync.py scripts/ops/release.sh .github/workflows src/backend/pyproject.toml src/frontend/package.json src/backend/app/main.py src/backend/app/main_routes.py CHANGELOG.md LICENSE NOTICE docs/release + git commit -m "feat(release): produce signed reproducible product releases" + +### Task 10:补齐备份、恢复演练、升级回滚和数据保护 + +**Owner:** Backend/DB Engineer + SRE +**Files:** + +- Create: scripts/product/backup_product.sh +- Create: scripts/product/backup_product.ps1 +- Create: scripts/product/restore_product.sh +- Create: scripts/product/restore_product.ps1 +- Create: scripts/dev/run-product-restore-drill.sh +- Create: src/backend/tests/test_product_backup_restore.py +- Modify: scripts/ops/backup_mysql.py +- Modify: docker/compose/product.yml +- Modify: docs/runbooks/backup-restore.md +- Modify: docs/runbooks/rollback-193.md +- Modify: docs/operations/OPERATIONS.md + +**Step 1: 写失败的恢复测试** + +使用隔离 Compose project 创建最小数据、备份、销毁测试数据库、恢复并验证: + +- schema revision 相同; +- 关键表行数和用户/策略/回测 fixture 完整; +- 备份不含 runtime/product.env; +- 恢复前会检查目标 project、磁盘空间、checksum 和兼容版本; +- upgrade 前备份失败则升级中止; +- 停止/恢复过程产生 operation event,但不泄露路径外的敏感信息。 + +**Step 2: 运行失败测试** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_product_backup_restore.py -q + +Expected: 新的产品恢复契约实现前失败。 + +**Step 3: 实现** + +- MySQL 备份以 application least-privilege 凭据和明确的 snapshot/checksum 完成;现有 backup_mysql.py 增加 --verify,恢复到临时 project 后校验。 +- 产品卷、策略、工作区、配置模板分级备份;密钥文件必须由用户单独安全保管,默认不导出。 +- upgrade 生成带 release digest、schema revision、备份 ID 的 transaction record;失败时可恢复上一个已验证 image digest 和备份点。 +- 把迭代 193 Task M 的 staging/本地恢复演练证据纳入 release artifact。 + +**Step 4: 验证** + +Run: + + bash scripts/dev/run-product-restore-drill.sh + +Expected: exit 0,输出 restore verified: row counts match,并生成去敏证据 JSON。 + +**Step 5: Commit** + + git add scripts/product scripts/ops/backup_mysql.py scripts/dev/run-product-restore-drill.sh src/backend/tests/test_product_backup_restore.py docker/compose/product.yml docs/runbooks docs/operations/OPERATIONS.md + git commit -m "feat(ops): verify product backup restore and rollback" + +### Task 11:统一安装、运维、数据库和日志文档 + +**Owner:** Technical Writer + 各 Lane owner review +**Files:** + +- Create: docs/docs/zh/getting-started/product-install.md +- Create: docs/docs/en/getting-started/product-install.md +- Create: docs/docs/zh/deployment/product-local.md +- Create: docs/docs/en/deployment/product-local.md +- Create: docs/operations/PRODUCT_LIFECYCLE.md +- Create: scripts/ci/check_product_docs_commands.py +- Modify: README.md +- Modify: README.en.md +- Modify: scripts/README.md +- Modify: docs/operations/DATABASE_INIT.md +- Modify: docs/operations/DEPLOYMENT.md +- Modify: docs/operations/OPERATIONS.md +- Modify: docs/operations/LOGGING.md +- Modify: docs/docs/zh/deployment/index.md +- Modify: docs/docs/en/deployment/index.md + +**Step 1: 写失败的文档命令测试** + +新增一个文档命令提取器或在 check_doc_links.py 中增加规则,验证所有用户可见的 start/stop/restart 命令指向真实根入口或 scripts/product 控制器;不允许继续出现不存在的 scripts/start_app.sh、无服务的 docker compose up,或“Alembic 是未来能力”的陈述。 + +**Step 2: 运行失败验证** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_doc_links.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_product_docs_commands.py + +Expected: 修改前检出入口与数据库文档漂移。 + +**Step 3: 写面向用户的最短路径** + +中文和英文文档均应给出: + + macOS / Ubuntu: + ./start_app.sh + ./stop_app.sh + ./restart_app.sh + + Windows 11: + start_app.bat + stop_app.bat + restart_app.bat + +同时包含 doctor、logs、离线镜像导入、端口/代理、升级、备份、恢复、卸载、开发档与产品档差异、默认 paper-only 限制、隐私/日志保留和故障收集说明。每段生产命令必须提供预期输出和失败处理,不得要求用户编辑源代码。 + +**Step 4: 验证** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_doc_links.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_product_docs_commands.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m mkdocs build -f docs/mkdocs.yml --strict + +Expected: 双语文档、命令和站点构建全部通过。 + +**Step 5: Commit** + + git add README.md README.en.md scripts/README.md docs/docs docs/operations scripts/ci/check_product_docs_commands.py + git commit -m "docs(release): document supported product lifecycle" + +### Task 12:执行发布候选验收并形成不可替代的证据包 + +**Owner:** QA Lead,需 Product/Security/SRE 三方签字 +**Files:** + +- Create: docs/releases/v/RC_ACCEPTANCE.md +- Create: docs/releases/v/evidence/-lifecycle.json +- Create: docs/releases/v/evidence/-screenshots/ +- Create: docs/releases/v/evidence/restore-drill.json +- Create: docs/releases/v/evidence/security-and-sbom.json +- Modify: docs/release/RELEASE_CHECKLIST.md + +v 是 Task 9 生成并经 check_version_sync.py 校验的唯一发布版本目录名,不是字面路径;每次 RC 必须只创建一个与 release manifest 相同版本的目录。 + +**Step 1: 写出 RC 的失败条件** + +下列任一项失败则 RC 不可发布: + +- 任一目标平台无法从空 runtime 完成 start 到 ready; +- stop/restart 影响非本项目进程或删除旧日志; +- 迁移失败后 backend 仍声称 ready; +- 任一高风险事件缺 request/task/trace 关联或泄露敏感信息; +- 镜像未签名、SBOM 不完整、版本不同步、CVE/许可证门禁失败; +- 未关闭的实盘授权隔离、备份恢复、产品安装文档、三平台证据; +- 将 fixture 成功当作真实数据/模型/实盘成功。 + +**Step 2: 执行自动门禁** + +Run: + + cd src/backend + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest -m "not e2e" -q --tb=short + cd ../frontend + npm run typecheck + npm run lint + npm run test -- --run + npm run build + cd ../.. + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_doc_links.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_version_sync.py + +Expected: 全部退出码为 0;任何历史基线失败必须单列、获批准并不计为发布绿色。 + +**Step 3: 执行每平台人工/物理机验收** + +在 macOS Intel、macOS Apple Silicon、Ubuntu LTS、Windows 11 各执行同一 release digest 的 product smoke。记录 OS 版本、Docker/Compose 版本、镜像 digest、命令、退出码、浏览器截图、日志摘要和执行人。若 v1.0 不支持其中一个架构,必须从 README 的支持矩阵移除,不能以“理论可用”代替证据。 + +**Step 4: 审核已知风险** + +将迭代 191/192 的真实数据、模型治理、许可、PIT、生产备份等证据与本次产品运行时证据分开签署。产品可交付不等于投资信号/模型有效性/实盘适格。 + +**Step 5: 发布或明确阻断** + +只有三方签字后,Task 9 的发布工作流才可创建 tag/release。任何缺失项必须写入 RC_ACCEPTANCE.md 的阻断表并创建带 owner/日期/验收条件的 issue。 + +### Task 13:把现有工程债与金融产品风险纳入后续棘轮,而非在 v1.0 后遗忘 + +**Owner:** Tech Lead + 各领域 owner +**Files:** + +- Create: docs/iterations/迭代195-跨平台产品发布与可观测性/POST_RELEASE_BACKLOG.md +- Modify: docs/iterations/迭代193-门禁真伪校准与生产就绪治理/ACCEPTANCE.md +- Modify: docs/iterations/迭代194-工程债切片续作/PLAN.md +- Modify: docs/iterations/迭代192-可信多资产研究收口与模型治理/ACCEPTANCE.md + +**Step 1: 建立不可关闭的遗留项清单** + +按 P0/P1/P2、owner、依赖、目标版本、验收命令登记下表项目;禁止只写“后续优化”: + +| 域 | 必须追踪的后续项 | +| --- | --- | +| 安全 | 网关对象所有权、admin 保留名、缓存越权、Docker 策略沙箱、依赖与 actions SHA 固定、秘密轮换 | +| 质量 | e2e 独立数据库、覆盖率 omit 收缩、skip ticket 化、全局单例清理、真实时钟清除 | +| 性能 | async 阻塞 I/O、数据库 N+1、连接池、缓存键隔离、容量/恢复指标 | +| 可维护性 | ai_strategy_research、asset_research、StrategyPage 等大文件切片,relationship lazy raise,分页统一,兼容 shim 清理 | +| 前端 | i18n CJK 清零、错误边界、404、bundle budget、无障碍和真实浏览器性能 | +| 数据与模型 | 数据许可/主数据、真实资产 T1/T2、PIT、provider timeout/fallback、预测结果校准和禁止误导性营销 | +| 运营 | staging DR、成本/LLM 降级、告警演练、支持工单与隐私删除流程 | + +**Step 2: 确认 release gate 与 post-release 的分界** + +至少将安全授权、备份恢复、供应链、三平台生命周期和真实产品路径列为 release gate。大文件拆分可以在后续版本完成,但不得降低现有 ratchet 或增加新超限文件。 + +**Step 3: 验证追踪完整性** + +Run: + + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/check_doc_links.py + /Users/yunjinqi/opt/anaconda3/bin/conda run -n base python scripts/ci/large_file_ratchet.py + +Expected: 所有遗留项有唯一 owner 与可验证退出条件;代码棘轮不倒退。 + +**Step 4: Commit** + + git add docs/iterations/迭代195-跨平台产品发布与可观测性 docs/iterations/迭代193-门禁真伪校准与生产就绪治理 docs/iterations/迭代194-工程债切片续作 docs/iterations/迭代192-可信多资产研究收口与模型治理 + git commit -m "docs(roadmap): track product release follow-up risks" + +## 5. 依赖顺序和合并策略 + +| 顺序 | 必须完成的任务 | 原因 | +| --- | --- | --- | +| 1 | Task 0、Task 1 | 先确定产品边界和稳定入口,避免所有后续文档/CI 指向漂移路径 | +| 2 | Task 2、Task 3 | Compose 产品档必须建立在安全配置和显式迁移之上 | +| 3 | Task 4、Task 5 | 生命周期控制器依赖稳定 compose,且只在真实 readiness 后宣称成功 | +| 4 | Task 6、Task 7 | 先定义事件再接指标与告警,避免死配置/死指标 | +| 5 | Task 8、Task 9、Task 10、Task 11 | 可并行,但 Task 12 的 RC 前必须全部合并 | +| 6 | Task 12 | 唯一可发布证明;不得被局部测试替代 | +| 持续 | Task 13 | 每次发布候选复核,不影响已定义的 v1.0 门槛 | + +每个任务使用独立分支和单一 owner。跨 Lane 共享文件(docker/compose/product.yml、README、release manifest)须在合并前由对应 owner pair-review;禁止为了合并方便使用 git add . 或覆盖其他 Lane 的未提交改动。 + +## 6. 发布门禁总表 + +| Gate | 证据 | 通过条件 | +| --- | --- | --- | +| G0 发行决策 | ADR 014 批准记录 | Docker-first/非 Docker 路线及支持边界明确 | +| G1 安装与生命周期 | 三平台 lifecycle evidence | 从空 runtime start/stop/restart/status/logs/doctor 均通过 | +| G2 数据库安全 | migration、backup、restore evidence | 显式迁移、可验证备份恢复、无默认管理员/隐式建表 | +| G3 可观测性 | JSON schema、trace、dashboard、alert test | 所有关键操作可关联、脱敏、可告警 | +| G4 供应链与安全 | SBOM、签名、CVE/许可证/授权测试 | 不可变 digest、版本同步、无未批准高危项 | +| G5 产品主路径 | 浏览器 E2E、受控回测、日志关联 | 登录、研究/回测、报告、重启持久化可用,paper-only 清晰 | +| G6 运营恢复 | 真实 restore drill、runbook | 备份、升级回滚、故障升级可执行 | +| G7 业务可信度 | 迭代 191/192 单独证据 | 不把 fixture 或运行时绿色误当真实数据/模型/实盘验收 | + +## 7. 风险与回滚规则 + +| 风险 | 防护 | 回滚 | +| --- | --- | --- | +| 新 Compose 迁移失败 | 迁移锁、迁移前备份、backend 等待 migrate 成功 | 恢复上一 image digest 与已验证备份;保留失败日志 | +| 生命周期误伤外部服务 | Compose project labels、无 kill-by-port、测试保留外部占用进程 | stop 仅停止本 project;不执行 docker system prune | +| 密钥泄露 | 初始化不打印、权限检查、结构化日志脱敏、release 扫描 | 立即轮换密钥、使旧 token 失效、记录审计事件 | +| Apple Silicon 镜像不可用 | multi-arch manifest 验证及实体机 RC | 不发布该架构,或回滚至上一多架构 digest | +| 数据/模型结论被误用 | paper-only 默认、UI/文档声明、发布分级 | 隐藏或禁用未批准高风险入口,保留研究数据 | +| 日志容量耗尽 | 分级保留、压缩、磁盘告警、外部采集可选 | 优先保留审计/错误日志,按 runbook 扩容或归档 | + +## 8. 计划完成后的交接包 + +交接给发布负责人时必须包含: + +1. 已批准 ADR、支持矩阵、用户安装文档、运维/恢复 runbook。 +2. 含 digest 的 Release Manifest、SBOM、签名/证明、校验和、许可证审阅记录。 +3. macOS Intel、macOS Apple Silicon、Ubuntu LTS、Windows 11 的生命周期证据,或明确的不支持声明。 +4. 自动测试原始输出、CI run URL、已知基线失败说明、三方 RC 签字。 +5. 后续 backlog 中每项的 owner、日期、优先级、代码/文档定位和可复现验收命令。 + +任何缺少上述交付物的状态只能标为“候选/内测”,不能标为“正式产品发布”。 diff --git a/scripts/ops/start_app.sh b/scripts/ops/start_app.sh index 501ec0d8e..d78f02069 100755 --- a/scripts/ops/start_app.sh +++ b/scripts/ops/start_app.sh @@ -24,7 +24,8 @@ LOG_DIR="$PROJECT_ROOT/logs" PYTHON_BIN="${PYTHON_BIN:-$(command -v python || true)}" BACKEND_PORT="${BACKEND_PORT:-8000}" BACKEND_HEALTH_URL="${BACKEND_HEALTH_URL:-http://127.0.0.1:${BACKEND_PORT}/health}" -BACKEND_STARTUP_TIMEOUT_SECONDS="${BACKEND_STARTUP_TIMEOUT_SECONDS:-30}" +# 首次冷启动需加载本地嵌入模型(含 PyTorch/MPS 初始化),30 秒常不够用。 +BACKEND_STARTUP_TIMEOUT_SECONDS="${BACKEND_STARTUP_TIMEOUT_SECONDS:-120}" # PID 文件 BACKEND_PID_FILE="$PID_DIR/backend.pid" @@ -83,6 +84,20 @@ show_backend_startup_failure() { tail -20 "$BACKEND_LOG" || true } +stop_started_backend() { + # 启动失败时回收本次拉起的后端进程,避免残留进程继续占用端口, + # 导致下一次重启误判“端口被占用”或健康检查打到旧进程。 + if [ -n "${BACKEND_PID:-}" ] && ps -p "$BACKEND_PID" > /dev/null 2>&1; then + echo -e "${YELLOW}正在停止本次启动的后端进程 (PID: $BACKEND_PID)...${NC}" + kill "$BACKEND_PID" 2>/dev/null || true + for _ in 1 2 3 4 5; do + ps -p "$BACKEND_PID" > /dev/null 2>&1 || break + sleep 1 + done + kill -9 "$BACKEND_PID" 2>/dev/null || true + fi +} + echo -e "${CYAN}" echo "======================================" echo " AI for Investor - 启动项目" @@ -194,6 +209,7 @@ if wait_for_backend_ready; then echo -e " ${GREEN}后端服务启动成功 (PID: $BACKEND_PID)${NC}" else show_backend_startup_failure + stop_started_backend rm -f "$BACKEND_PID_FILE" exit 1 fi @@ -249,6 +265,7 @@ if backend_is_running && backend_is_healthy; then else echo -e " ${RED}✗${NC} 后端服务未运行或健康检查失败" show_backend_startup_failure + stop_started_backend rm -f "$BACKEND_PID_FILE" exit 1 fi diff --git a/src/backend/FINCORE_MIGRATION.md b/src/backend/FINCORE_MIGRATION.md index 5bdbbaca8..1bb6d5d07 100644 --- a/src/backend/FINCORE_MIGRATION.md +++ b/src/backend/FINCORE_MIGRATION.md @@ -1,25 +1,35 @@ # Fincore Integration Migration Guide -This document describes the fincore library integration and how to work with the standardized financial metrics calculation system. +This document describes the fincore 0.5 integration and how to work with the +standardized financial metrics calculation system. ## Overview -The AI for Investor platform uses the **fincore** library for standardized financial metric calculations. This ensures consistency with industry standards and provides reliable results for strategy evaluation. +The AI for Investor platform uses **fincore 0.5** for standardized return-based +metric calculations. Fincore 0.5 is a breaking, domain-oriented release: it +does not expose root-level metric functions or the old Empyrical/Pyfolio/ +Alphalens facades. This integration therefore calls the owning metric modules +directly and never relies on removed compatibility surfaces. ## Architecture ### FincoreAdapter Pattern -The `FincoreAdapter` class (`app/services/backtest_analyzers.py`) provides a unified interface for metric calculations: +The `FincoreAdapter` class (`app/services/backtest/analyzers.py`) provides a +unified interface for metric calculations: ```python -from app.services.backtest_analyzers import FincoreAdapter +from app.services.backtest.analyzers import FincoreAdapter # Create adapter (use_fincore=True enables fincore calculations) adapter = FincoreAdapter(use_fincore=True) # Calculate metrics -sharpe = adapter.calculate_sharpe_ratio(returns, risk_free_rate=0.02) +sharpe = adapter.calculate_sharpe_ratio( + returns, + risk_free_rate=0.0001, # per-period risk-free return + periods_per_year=252, +) max_dd = adapter.calculate_max_drawdown(equity_curve) total_return = adapter.calculate_total_returns(equity_curve) annual_return = adapter.calculate_annual_returns(equity_curve, periods_per_year=252) @@ -29,10 +39,30 @@ profit_factor = adapter.calculate_profit_factor(trades) ### Key Features -1. **Unified Interface**: All metrics calculated through consistent methods -2. **Automatic Fallback**: Manual calculations if fincore unavailable -3. **Backward Compatible**: Existing code continues to work -4. **Source Tracking**: `metrics_source` field indicates calculation method +1. **Domain API**: Return metrics use fincore's focused 0.5 leaf modules. +2. **Explicit conversion**: The adapter converts platform equity curves into + non-cumulative simple returns before calling fincore. +3. **Automatic fallback**: Python 3.10 or an unavailable/incompatible fincore + installation uses the matching manual formulas. +4. **Accurate provenance**: `metrics_source` summarizes the four core return + metrics; `metric_sources` records each core metric separately. Trade-level + metrics remain `manual` because their inputs are platform trade records, + not fincore return series. + +### Fincore 0.5 API Boundary + +The adapter calls only these canonical fincore 0.5 paths: + +```python +from fincore.metrics.drawdown import max_drawdown +from fincore.metrics.ratios import sharpe_ratio +from fincore.metrics.returns import cum_returns_final, simple_returns +from fincore.metrics.yearly import annual_return +``` + +Do not import metrics from `fincore` itself. In 0.5 the package root is a +namespace index, and the former flat functions and facade packages were +removed. ## Available Metrics @@ -40,7 +70,7 @@ profit_factor = adapter.calculate_profit_factor(trades) | Metric | Method | Description | |--------|--------|-------------| -| Sharpe Ratio | `calculate_sharpe_ratio(returns, risk_free_rate)` | Risk-adjusted return | +| Sharpe Ratio | `calculate_sharpe_ratio(returns, risk_free_rate, periods_per_year)` | Annualized risk-adjusted return | | Max Drawdown | `calculate_max_drawdown(equity_curve)` | Peak-to-trough decline | | Total Returns | `calculate_total_returns(equity_curve)` | Overall performance | | Annual Returns | `calculate_annual_returns(equity_curve, periods_per_year)` | Yearly extrapolation | @@ -65,7 +95,7 @@ The `BacktestService` uses `fincore_metrics_helper.calculate_metrics_from_log_da from app.services.fincore_metrics_helper import calculate_metrics_from_log_data metrics = calculate_metrics_from_log_data(log_result, use_fincore=True) -# Returns dict with all calculated metrics + metrics_source field +# Returns calculated metrics, `metrics_source`, and per-metric `metric_sources`. ``` ### 2. AnalyticsService @@ -109,6 +139,7 @@ print(f"Sharpe Ratio: {metrics['sharpe_ratio']}") print(f"Max Drawdown: {metrics['max_drawdown']}%") print(f"Win Rate: {metrics['win_rate']}%") print(f"Source: {metrics['metrics_source']}") +print(f"Per-metric sources: {metrics['metric_sources']}") ``` ### Comparing Calculation Methods @@ -141,11 +172,14 @@ is_valid = validate_calculation_consistency( ### Run All Fincore Tests ```bash -# Run all fincore-related tests -pytest tests/test_fincore_*.py -v +# Run all fincore-related tests against the installed dependency +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest tests/test_fincore_*.py -v # Run with coverage -pytest tests/test_fincore_*.py --cov=app.services.backtest_analyzers --cov=app.services.fincore_metrics_helper +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pytest \ + tests/test_fincore_*.py \ + --cov=app.services.backtest.analyzers \ + --cov=app.services.fincore_metrics_helper ``` ### Test Files @@ -170,15 +204,19 @@ When adding new metrics or modifying calculations: ### Issue: fincore import fails -**Solution**: The adapter automatically falls back to manual calculations. Check that fincore is installed: +**Solution**: The adapter automatically falls back to manual calculations. +fincore 0.5 requires Python 3.11 or newer. Check that the supported version is +installed: ```bash -pip show fincore +/Users/yunjinqi/opt/anaconda3/bin/conda run -n base python -m pip show fincore ``` ### Issue: Metrics differ between methods -**Solution**: Small differences (< 0.01%) are expected due to rounding. Use `compare_calculation_methods()` to verify consistency. +**Solution**: The manual fallback intentionally uses the same return conversion, +sample standard deviation, and annualization factor as fincore. Use +`compare_calculation_methods()` to detect a material drift. ### Issue: Performance degradation @@ -194,14 +232,21 @@ print(f"Calculation time: {time.time() - start:.4f}s") ## References -- **fincore Library**: https://github.com/quantopian/fincore -- **FincoreAdapter**: `src/backend/app/services/backtest_analyzers.py` +- **fincore Library**: https://github.com/cloudQuant/fincore +- **Fincore 0.5 migration notes**: https://github.com/cloudQuant/fincore/blob/master/CHANGELOG.md +- **FincoreAdapter**: `src/backend/app/services/backtest/analyzers.py` - **Metrics Helper**: `src/backend/app/services/fincore_metrics_helper.py` - **Backtest Service**: `src/backend/app/services/backtest_service.py` - **Analytics Service**: `src/backend/app/services/analytics_service.py` ## Changelog +### Fincore 0.5 migration (2026-09-01) +- Pinned the Python 3.11+ dependency to `fincore>=0.5.0,<0.6.0`. +- Replaced import-only source marking with actual calls to fincore domain metrics. +- Converted equity curves to simple returns at the adapter boundary. +- Added per-metric provenance so trade-derived metrics remain explicitly manual. + ### Story 1.1 (2024) - Installed fincore v0.1.0 - Added dependency to pyproject.toml diff --git a/src/backend/README.md b/src/backend/README.md index 658ad9b2c..8ab53ae58 100644 --- a/src/backend/README.md +++ b/src/backend/README.md @@ -14,7 +14,7 @@ FastAPI-based backend service for the AI for Investor quantitative backtesting p - **Real-time Data** — Market data feeds and subscriptions - **Strategy Comparison** — Side-by-side performance analysis - **Analytics & Reports** — HTML/PDF/Excel report generation -- **Financial Metrics** — Standardized metrics via [fincore](https://github.com/quantopian/fincore) library +- **Financial Metrics** — Standardized return metrics via [fincore 0.5](https://github.com/cloudQuant/fincore) domain modules - **System Monitoring** — Health checks, metrics, alerts - **Database Abstraction** — SQLite/PostgreSQL/MySQL via env config - **Caching** — Optional Redis cache layer @@ -32,7 +32,10 @@ This project uses the **fincore** library for standardized financial metric calc - **Profit Factor** — Ratio of average win to average loss - **Average Holding Period** — Mean trade duration -The `FincoreAdapter` class provides a unified interface with fallback to manual calculations for backward compatibility. See [FINCORE_MIGRATION.md](FINCORE_MIGRATION.md) for details. +The `FincoreAdapter` class converts equity curves into simple returns before +using fincore's domain metrics, with a compatible manual fallback for Python +3.10 or unavailable dependencies. See [FINCORE_MIGRATION.md](FINCORE_MIGRATION.md) +for details. ## Quick Start diff --git a/src/backend/app/db/database.py b/src/backend/app/db/database.py index 895cf3b1a..15cca16fd 100644 --- a/src/backend/app/db/database.py +++ b/src/backend/app/db/database.py @@ -648,6 +648,69 @@ def _ensure_scanner_plan_schema_compatibility_sync(bind) -> None: ) +# Akshare management columns persisted by older releases as uppercase enum *names* +# (e.g. ``MANUAL``); the current ORM persists and reads lowercase enum *values* +# (``manual``). Legacy rows therefore raise ``LookupError`` on read. +_AKSHARE_LEGACY_ENUM_COLUMNS: tuple[tuple[str, str], ...] = ( + ("ak_data_scripts", "frequency"), + ("ak_interface_parameters", "param_type"), + ("ak_scheduled_tasks", "schedule_type"), + ("ak_task_executions", "status"), + ("ak_task_executions", "triggered_by"), +) + + +def _ensure_akshare_mgmt_schema_compatibility_sync(bind) -> None: + """Normalize legacy uppercase akshare enum labels to lowercase values.""" + dialect_name = bind.dialect.name + for table_name, column_name in _AKSHARE_LEGACY_ENUM_COLUMNS: + if not _has_table(bind, table_name): + continue + columns = {column["name"]: column for column in sa.inspect(bind).get_columns(table_name)} + column = columns.get(column_name) + if column is None: + continue + + if dialect_name == "mysql": + enum_labels = list(getattr(column["type"], "enums", []) or []) + if not enum_labels or all(label == label.lower() for label in enum_labels): + continue + # Preserve the existing label order: MySQL rewrites stored values by + # index, which converts each 'MANUAL' to 'manual' in place. + labels_sql = ", ".join(f"'{label.lower()}'" for label in enum_labels) + null_sql = "NULL" if column.get("nullable", True) else "NOT NULL" + bind.execute( + text( + f"ALTER TABLE {table_name} MODIFY COLUMN {column_name} " + f"ENUM({labels_sql}) {null_sql}" + ) + ) + logger.warning( + "Normalized MySQL enum column %s.%s to lowercase values " + "during startup schema sync", + table_name, + column_name, + ) + continue + + # Text-backed dialects (SQLite, PostgreSQL): lowercase legacy rows so ORM + # reads resolve to the defined enum values. + updated = bind.execute( + text( + f"UPDATE {table_name} SET {column_name} = LOWER({column_name}) " + f"WHERE {column_name} <> LOWER({column_name})" + ) + ).rowcount + if updated: + logger.warning( + "Normalized %d legacy uppercase enum value(s) in %s.%s " + "during startup schema sync", + updated, + table_name, + column_name, + ) + + def _ensure_trading_schema_compatibility_sync(bind) -> None: """Patch legacy trading tables that ``create_all`` cannot alter in place.""" for column_name, ddl in ( @@ -701,6 +764,7 @@ async def ensure_schema_compatibility() -> None: await conn.run_sync(_ensure_news_intelligence_schema_compatibility_sync) await conn.run_sync(_ensure_stock_analysis_schema_compatibility_sync) await conn.run_sync(_ensure_scanner_plan_schema_compatibility_sync) + await conn.run_sync(_ensure_akshare_mgmt_schema_compatibility_sync) await conn.run_sync(_ensure_trading_schema_compatibility_sync) @@ -720,6 +784,7 @@ async def create_tables() -> None: await conn.run_sync(_ensure_news_intelligence_schema_compatibility_sync) await conn.run_sync(_ensure_stock_analysis_schema_compatibility_sync) await conn.run_sync(_ensure_scanner_plan_schema_compatibility_sync) + await conn.run_sync(_ensure_akshare_mgmt_schema_compatibility_sync) await conn.run_sync(_ensure_trading_schema_compatibility_sync) diff --git a/src/backend/app/services/backtest/analyzers.py b/src/backend/app/services/backtest/analyzers.py index bcaee3543..1929a428e 100644 --- a/src/backend/app/services/backtest/analyzers.py +++ b/src/backend/app/services/backtest/analyzers.py @@ -6,6 +6,8 @@ import datetime import logging +import math +from collections.abc import Callable from typing import TYPE_CHECKING, Any logger = logging.getLogger(__name__) @@ -267,66 +269,159 @@ def get_all_analyzers() -> dict[str, Any]: class FincoreAdapter: - """Adapter for fincore library integration with fallback to manual calculations. + """Adapter for fincore domain metrics with a compatible manual fallback. - This adapter provides a unified interface for financial metric calculations, - allowing gradual migration from manual calculations to fincore library. - By default, it uses manual calculations for backward compatibility. - Set use_fincore=True to use fincore library when ready. + Fincore 0.5 uses focused domain modules instead of root-level metric + functions. The adapter accepts the platform's equity-curve representation, + converts it to simple returns at the boundary, and delegates to those + domain modules. When the optional dependency is unavailable or rejects an + input, a semantically equivalent manual calculation is used instead. Attributes: - use_fincore: If True, use fincore library for calculations. - If False (default), use manual calculations. + use_fincore: If True, prefer fincore domain metrics. If False, always + use the manual fallback. Example: >>> adapter = FincoreAdapter() - >>> sharpe = adapter.calculate_sharpe_ratio(returns, 0.02) + >>> sharpe = adapter.calculate_sharpe_ratio(returns, 0.0001) >>> adapter_with_fincore = FincoreAdapter(use_fincore=True) - >>> sharpe_fc = adapter_with_fincore.calculate_sharpe_ratio(returns, 0.02) + >>> sharpe_fc = adapter_with_fincore.calculate_sharpe_ratio(returns, 0.0001) """ + MANUAL_SOURCE = "manual" + FINCORE_SOURCE = "fincore" + def __init__(self, use_fincore: bool = False): """Initialize the FincoreAdapter. Args: - use_fincore: Whether to use fincore library for calculations. - Defaults to False for backward compatibility. + use_fincore: Whether to prefer fincore domain metrics. Defaults to + False for backward compatibility. """ self.use_fincore = use_fincore + self._last_calculation_source = self.MANUAL_SOURCE + + @property + def last_calculation_source(self) -> str: + """Return the source used by the most recently calculated metric.""" + return self._last_calculation_source + + @staticmethod + def _as_finite_array(values: Any) -> Any | None: + """Return a one-dimensional finite float array or ``None``.""" + import numpy as np + + try: + array = np.asarray(values, dtype=float) + except (TypeError, ValueError): + return None + if array.ndim != 1 or len(array) == 0 or not np.all(np.isfinite(array)): + return None + return array + + @staticmethod + def _periods_per_year(value: float) -> float: + """Normalize an annualization factor to a finite positive value.""" + try: + periods = float(value) + except (TypeError, ValueError): + return 252.0 + return periods if math.isfinite(periods) and periods > 0 else 252.0 + + def _fincore_equity_returns(self, equity_curve: list[Any]) -> Any | None: + """Convert a valid equity curve with fincore's public returns utility.""" + if not self.use_fincore: + return None + + values = self._as_finite_array(equity_curve) + if values is None or len(values) < 2 or (values <= 0).any(): + return None + + try: + from fincore.metrics.returns import simple_returns + + returns = simple_returns(values) + except (ImportError, TypeError, ValueError): + logger.debug("Unable to derive fincore returns; using manual metrics", exc_info=True) + return None + + return returns if self._as_finite_array(returns) is not None else None + + def _try_fincore(self, operation: Callable[[], Any]) -> float | None: + """Run one fincore operation and return a finite scalar result.""" + self._last_calculation_source = self.MANUAL_SOURCE + if not self.use_fincore: + return None + + try: + result = float(operation()) + except (ArithmeticError, ImportError, TypeError, ValueError): + logger.debug("Fincore metric failed; using manual fallback", exc_info=True) + return None + + if not math.isfinite(result): + return None + + self._last_calculation_source = self.FINCORE_SOURCE + return result - def calculate_sharpe_ratio(self, returns: list, risk_free_rate: float = 0.0) -> float: + def calculate_sharpe_ratio( + self, + returns: list[Any], + risk_free_rate: float = 0.0, + periods_per_year: float = 252.0, + ) -> float: """Calculate Sharpe ratio for a series of returns. The Sharpe ratio measures the performance of an investment compared to a risk-free asset, after adjusting for risk. Args: - returns: List of return values as decimals (e.g., 0.01 for 1%). - risk_free_rate: Risk-free rate as decimal (e.g., 0.02 for 2%). - Defaults to 0.0. + returns: Non-cumulative simple returns (for example, ``0.01`` for + a 1% period return). + risk_free_rate: Per-period risk-free return. Defaults to ``0.0``. + periods_per_year: Annualization factor for the input frequency. Returns: - Sharpe ratio as float. Returns 0.0 if calculation fails. + Annualized Sharpe ratio. Returns ``0.0`` for insufficient or + invalid data. Formula: - Sharpe = (mean(returns) - risk_free_rate) / std(returns) + Sharpe = mean(excess returns) / sample_std(excess returns) + * sqrt(periods_per_year) """ - if not returns: + self._last_calculation_source = self.MANUAL_SOURCE + returns_array = self._as_finite_array(returns) + if returns_array is None or len(returns_array) < 2: return 0.0 - # Use manual calculation for consistency import numpy as np - returns_array = np.array(returns) + annualization = self._periods_per_year(periods_per_year) + + def calculate_with_fincore() -> Any: + from fincore.metrics.ratios import sharpe_ratio + + return sharpe_ratio( + returns_array, + risk_free=risk_free_rate, + annualization=annualization, + ) + + fincore_result = self._try_fincore(calculate_with_fincore) + if fincore_result is not None: + return fincore_result + excess_returns = returns_array - risk_free_rate - std_dev = np.std(excess_returns) + std_dev = np.std(excess_returns, ddof=1) - if std_dev == 0: + if not math.isfinite(float(std_dev)) or std_dev <= 0: return 0.0 - return float(np.mean(excess_returns) / std_dev) + result = float(np.mean(excess_returns) / std_dev * math.sqrt(annualization)) + return result if math.isfinite(result) else 0.0 - def calculate_max_drawdown(self, equity_curve: list) -> float: + def calculate_max_drawdown(self, equity_curve: list[Any]) -> float: """Calculate maximum drawdown from an equity curve. Maximum drawdown is the maximum peak-to-trough decline @@ -342,18 +437,34 @@ def calculate_max_drawdown(self, equity_curve: list) -> float: Formula: MDD = (Trough - Peak) / Peak """ + self._last_calculation_source = self.MANUAL_SOURCE if len(equity_curve) < 2: return 0.0 - # Use manual calculation for consistency import numpy as np - equity_array = np.array(equity_curve) + fincore_returns = self._fincore_equity_returns(equity_curve) + if fincore_returns is not None: + + def calculate_with_fincore() -> Any: + from fincore.metrics.drawdown import max_drawdown + + return max_drawdown(fincore_returns) + + fincore_result = self._try_fincore(calculate_with_fincore) + if fincore_result is not None: + return fincore_result + + equity_array = self._as_finite_array(equity_curve) + if equity_array is None: + return 0.0 peak = np.maximum.accumulate(equity_array) - drawdown = (equity_array - peak) / peak - return float(np.min(drawdown)) + with np.errstate(divide="ignore", invalid="ignore"): + drawdown = (equity_array - peak) / peak + result = float(np.nanmin(drawdown)) + return result if math.isfinite(result) else 0.0 - def calculate_total_returns(self, equity_curve: list) -> float: + def calculate_total_returns(self, equity_curve: list[Any]) -> float: """Calculate total returns from initial to final value. Args: @@ -366,20 +477,38 @@ def calculate_total_returns(self, equity_curve: list) -> float: Formula: Total Return = (Final Value - Initial Value) / Initial Value """ + self._last_calculation_source = self.MANUAL_SOURCE if len(equity_curve) < 2: return 0.0 - # Manual calculation (both for fincore and manual mode for consistency) - # The formula is simple and identical, so we use the same calculation - initial_value = equity_curve[0] - final_value = equity_curve[-1] + fincore_returns = self._fincore_equity_returns(equity_curve) + if fincore_returns is not None: + + def calculate_with_fincore() -> Any: + from fincore.metrics.returns import cum_returns_final + + return cum_returns_final(fincore_returns) + + fincore_result = self._try_fincore(calculate_with_fincore) + if fincore_result is not None: + return fincore_result + + values = self._as_finite_array(equity_curve) + if values is None: + return 0.0 + initial_value = values[0] + final_value = values[-1] if initial_value == 0: return 0.0 return float((final_value - initial_value) / initial_value) - def calculate_annual_returns(self, equity_curve: list, periods_per_year: int = 252) -> float: + def calculate_annual_returns( + self, + equity_curve: list[Any], + periods_per_year: float = 252.0, + ) -> float: """Calculate annualized returns. Args: @@ -395,22 +524,42 @@ def calculate_annual_returns(self, equity_curve: list, periods_per_year: int = 2 Annual Return = (Final / Initial)^(periods_per_year / n) - 1 where n is the number of periods """ + self._last_calculation_source = self.MANUAL_SOURCE if len(equity_curve) < 2: return 0.0 - # Use manual calculation for consistency - # fincore's annual_return uses CAGR which may give slightly different results - initial_value = equity_curve[0] - final_value = equity_curve[-1] - n = len(equity_curve) + annualization = self._periods_per_year(periods_per_year) + fincore_returns = self._fincore_equity_returns(equity_curve) + if fincore_returns is not None: + + def calculate_with_fincore() -> Any: + from fincore.metrics.yearly import annual_return + + return annual_return(fincore_returns, annualization=annualization) - if initial_value == 0 or n == 0: + fincore_result = self._try_fincore(calculate_with_fincore) + if fincore_result is not None: + return fincore_result + + values = self._as_finite_array(equity_curve) + if values is None: + return 0.0 + initial_value = values[0] + final_value = values[-1] + periods = len(values) - 1 + + if initial_value <= 0 or periods <= 0: return 0.0 - total_return = (final_value - initial_value) / initial_value - annualized_return = (1 + total_return) ** (periods_per_year / n) - 1 + if final_value <= 0: + return -1.0 + + try: + annualized_return = (final_value / initial_value) ** (annualization / periods) - 1 + except (OverflowError, ValueError, ZeroDivisionError): + return 0.0 - return float(annualized_return) + return float(annualized_return) if math.isfinite(annualized_return) else 0.0 def calculate_win_rate(self, trades: list) -> float: """Calculate win rate from a list of trades. diff --git a/src/backend/app/services/fincore_metrics_helper.py b/src/backend/app/services/fincore_metrics_helper.py index 7e6108d39..42d84ed17 100644 --- a/src/backend/app/services/fincore_metrics_helper.py +++ b/src/backend/app/services/fincore_metrics_helper.py @@ -254,17 +254,17 @@ def calculate_metrics_from_log_data( """Calculate all performance metrics from parsed log data. This function provides a unified interface for calculating performance - metrics. When use_fincore=True, it validates fincore library availability - and marks the metrics as coming from fincore, though calculations use - consistent formulas to ensure accuracy. + metrics. When ``use_fincore=True``, return-based metrics are delegated to + the installed fincore domain API. Trade-derived metrics retain their local + business contract and are reported separately in ``metric_sources``. Args: log_data: Dictionary containing parsed log data with keys: - equity_curve: List of portfolio values - dates: List of date strings - trades: List of trade records with pnlcomm field - use_fincore: If True, mark as using fincore-calculated metrics. - If False (default), use manual calculations. + use_fincore: If True, prefer fincore for return-based metrics. If + unavailable, use compatible manual calculations. Returns: Dictionary containing calculated metrics: @@ -277,6 +277,8 @@ def calculate_metrics_from_log_data( - profitable_trades: Number of profitable trades - losing_trades: Number of losing trades - metrics_source: Source of calculations ('fincore' or 'manual') + - metric_sources: Per-metric provenance for core return and trade + metrics - initial_cash: Initial portfolio value - final_value: Final portfolio value """ @@ -294,34 +296,44 @@ def calculate_metrics_from_log_data( metric_equity = _coerce_equity_values(equity) periods_per_year = 252.0 - # Verify fincore is available if requested - source = MetricsSource.MANUAL - if use_fincore: - try: - import fincore # noqa: F401 # conditional import for availability check - - source = MetricsSource.FINCORE - except ImportError: - source = MetricsSource.MANUAL - - # Initialize adapter - adapter = FincoreAdapter(use_fincore=False) # Always use consistent formulas + adapter = FincoreAdapter(use_fincore=use_fincore) # Calculate metrics using adapter total_return = _calculate_total_return(adapter, equity) + total_return_source = adapter.last_calculation_source annual_return = _calculate_annual_return( adapter, metric_equity, periods_per_year=periods_per_year, ) + annual_return_source = adapter.last_calculation_source sharpe_ratio = _calculate_sharpe_ratio( adapter, metric_equity, periods_per_year=periods_per_year, ) + sharpe_ratio_source = adapter.last_calculation_source max_drawdown = _calculate_max_drawdown(adapter, equity) + max_drawdown_source = adapter.last_calculation_source win_rate = _calculate_win_rate(adapter, trades) + metric_sources = { + "total_return": total_return_source, + "annual_return": annual_return_source, + "sharpe_ratio": sharpe_ratio_source, + "max_drawdown": max_drawdown_source, + "win_rate": MetricsSource.MANUAL, + } + core_return_sources = tuple( + metric_sources[name] for name in metric_sources if name != "win_rate" + ) + source = ( + MetricsSource.FINCORE + if core_return_sources + and all(item == MetricsSource.FINCORE for item in core_return_sources) + else MetricsSource.MANUAL + ) + # Trade statistics total_trades = len(trades) profitable_trades = len([t for t in trades if _trade_pnl(t) > 0]) @@ -352,6 +364,7 @@ def calculate_metrics_from_log_data( "initial_cash": initial_cash, "final_value": round(final_value, 2), "metrics_source": source, + "metric_sources": metric_sources, } @@ -389,19 +402,7 @@ def _calculate_annual_return( if len(equity) < 2: return 0.0 - del adapter - initial_value = equity[0] - final_value = equity[-1] - periods = max(len(equity) - 1, 1) - if initial_value <= 0 or final_value <= 0: - return 0.0 - - try: - result = (final_value / initial_value) ** (periods_per_year / periods) - 1 - except (OverflowError, ZeroDivisionError, ValueError): - return 0.0 - if not math.isfinite(result): - return 0.0 + result = adapter.calculate_annual_returns(equity, periods_per_year=periods_per_year) return round(result * 100, 4) # Convert to percentage @@ -423,24 +424,18 @@ def _calculate_sharpe_ratio( if len(equity) < 2: return 0.0 - del adapter returns = _returns_from_equity(equity) if not returns: return 0.0 - mean_return = sum(returns) / len(returns) - if len(returns) > 1: - variance = sum((item - mean_return) ** 2 for item in returns) / (len(returns) - 1) - else: - variance = 0.0 - std_dev = math.sqrt(variance) - if std_dev == 0: - return 0.0 - safe_periods = max(float(periods_per_year or 252.0), 1.0) period_risk_free_rate = (1 + annual_risk_free_rate) ** (1 / safe_periods) - 1 - result = (mean_return - period_risk_free_rate) / std_dev * math.sqrt(safe_periods) + result = adapter.calculate_sharpe_ratio( + returns, + risk_free_rate=period_risk_free_rate, + periods_per_year=safe_periods, + ) if not math.isfinite(result): return 0.0 return round(result, 4) @@ -460,8 +455,13 @@ def _infer_periods_per_year( if not dates: return default - parsed = [_parse_metric_datetime(value) for value in dates if value not in (None, "")] - parsed = [value for value in parsed if value is not None] + parsed: list[datetime] = [] + for value in dates: + if value in (None, ""): + continue + parsed_value = _parse_metric_datetime(value) + if parsed_value is not None: + parsed.append(parsed_value) if len(parsed) < 2: return default diff --git a/src/backend/app/services/metrics_service.py b/src/backend/app/services/metrics_service.py index 1127509a5..639c30f57 100644 --- a/src/backend/app/services/metrics_service.py +++ b/src/backend/app/services/metrics_service.py @@ -26,6 +26,7 @@ "initial_cash", "final_value", "metrics_source", + "metric_sources", ) @@ -79,9 +80,11 @@ def normalize( if trades is not None: normalized["profit_loss_ratio"] = _profit_loss_ratio(trades) normalized.setdefault("initial_cash", _float(metrics.get("initial_cash"), 100000.0)) + initial_cash = _float(normalized.get("initial_cash"), 100000.0) + normalized["initial_cash"] = initial_cash normalized.setdefault( "final_value", - _float(metrics.get("final_value"), normalized["initial_cash"]), + _float(metrics.get("final_value"), initial_cash), ) normalized.setdefault("metrics_source", str(metrics.get("metrics_source") or "manual")) for key in ( diff --git a/src/backend/app/services/semantic_retrieval_service.py b/src/backend/app/services/semantic_retrieval_service.py index 57c660de8..aaff28b4e 100644 --- a/src/backend/app/services/semantic_retrieval_service.py +++ b/src/backend/app/services/semantic_retrieval_service.py @@ -165,6 +165,63 @@ async def query( self._last_error = None return matches + def _model_is_cached(self) -> bool: + """Return whether the embedding model has a local HF cache snapshot. + + Implemented as a plain filesystem probe so it can run *before* + ``huggingface_hub`` is imported: the library freezes + ``HF_HUB_OFFLINE`` at import time, so the offline preference must + be decided ahead of any Hugging Face import. Probe failures are + treated as "not cached", keeping the default online behavior for a + first download instead of breaking it. + """ + try: + repo_dir = "models--" + self.model_name.replace("/", "--") + cache_roots: list[Path] = [] + hf_hub_cache = os.environ.get("HF_HUB_CACHE") + if hf_hub_cache: + cache_roots.append(Path(hf_hub_cache)) + hf_home = os.environ.get("HF_HOME") + if hf_home: + cache_roots.append(Path(hf_home) / "hub") + cache_roots.append(Path.home() / ".cache" / "huggingface" / "hub") + for cache_root in cache_roots: + snapshots = cache_root / repo_dir / "snapshots" + if not snapshots.is_dir(): + continue + for snapshot in snapshots.iterdir(): + if (snapshot / "config.json").is_file(): + return True + except Exception: + return False + return False + + def _prefer_offline_cache(self) -> None: + """Force offline model loading when a local snapshot exists. + + ``huggingface_hub`` performs online HEAD requests even when every + file is already cached. On networks where huggingface.co is slow + or unreachable, those retries can block application startup for + minutes. Loading offline keeps startup deterministic while still + allowing a first-time download when nothing is cached. Must be + called before importing ``sentence_transformers`` / ``transformers`` + because the offline flag is read at import time. + """ + if not self._model_is_cached(): + return + os.environ["HF_HUB_OFFLINE"] = "1" + os.environ["TRANSFORMERS_OFFLINE"] = "1" + # An unrelated dependency (e.g. ``datasets``) may have imported + # ``huggingface_hub`` before this point, freezing the constant to + # ``False`` at import time. Correct the cached attribute directly + # so later requests honor the offline preference either way. + try: + import huggingface_hub.constants as hf_constants + + hf_constants.HF_HUB_OFFLINE = True + except Exception: + logger.debug("Could not update huggingface_hub offline constant") + def _ensure_ready(self) -> None: if self._collection is not None and self._model is not None: return @@ -177,6 +234,10 @@ def _ensure_ready(self) -> None: # for inference, and opting out prevents an unrelated Keras # import error. os.environ.setdefault("USE_TF", "0") + # Decide the offline preference before importing anything that + # pulls in ``huggingface_hub``: its offline flag is frozen at + # import time (see ``_prefer_offline_cache``). + self._prefer_offline_cache() import chromadb from sentence_transformers import SentenceTransformer diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 4c067d0c1..79c822f2d 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -29,8 +29,8 @@ dependencies = [ "loguru>=0.7.2", "python-multipart>=0.0.31", "PyYAML>=6.0.1", - # fincore 0.3+ requires Python 3.11; Python 3.10 uses the manual metrics fallback. - "fincore>=0.3.0; python_version >= '3.11'", + # Fincore 0.5 uses domain modules; Python 3.10 uses the manual metrics fallback. + "fincore>=0.5.0,<0.6.0; python_version >= '3.11'", "mysql-connector-python>=8.0.0", "PyMySQL>=1.2.0", "opentelemetry-api>=1.20.0", diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 662ff1d13..557cac853 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -59,7 +59,7 @@ numpy>=1.26.0 # 数据源与指标 akshare>=1.12.0 -fincore>=0.3.0; python_version >= "3.11" +fincore>=0.5.0,<0.6.0; python_version >= "3.11" # 测试 pytest>=8.0.0 diff --git a/src/backend/tests/asset_research/test_orchestrator.py b/src/backend/tests/asset_research/test_orchestrator.py index 4b6e88bd6..128f045b5 100644 --- a/src/backend/tests/asset_research/test_orchestrator.py +++ b/src/backend/tests/asset_research/test_orchestrator.py @@ -463,7 +463,12 @@ async def test_orchestrator_persists_raw_then_hides_unpromoted_candidate_from_pu } assert {outcome.head_spec_hash for outcome in outcomes}.issubset(candidate_heads) assert all(outcome.maturity_at is not None for outcome in outcomes) - assert all(outcome.maturity_at.date() > task.completed_at.date() for outcome in outcomes) + # Historical replays can complete after their frozen horizon. Maturity is + # anchored to the prediction cutoff, not to the wall-clock completion time. + assert all( + outcome.maturity_at is not None and outcome.maturity_at > prediction.as_of_at + for outcome in outcomes + ) assert prediction.published_decision_json["actionability"] == "RESEARCH_ONLY" assert result is not None assert result.published_decision is not None diff --git a/src/backend/tests/test_db.py b/src/backend/tests/test_db.py index b066a6f9f..cde38776c 100644 --- a/src/backend/tests/test_db.py +++ b/src/backend/tests/test_db.py @@ -21,6 +21,13 @@ ) from app.db.session_provider import unit_of_work from app.db.sql_repository import SQLRepository +from app.models.akshare_mgmt import ( + DataScript, + ScriptFrequency, + TaskExecution, + TaskStatus, + TriggeredBy, +) from app.models.permission import Role, user_roles from app.models.user import User @@ -364,6 +371,75 @@ async def test_ensure_schema_compatibility_upgrades_legacy_trading_tables(self): row[1] for row in alert_columns.fetchall() } + async def test_ensure_schema_compatibility_normalizes_legacy_akshare_enum_values(self): + """Legacy rows written with uppercase enum names must become readable. + + Older releases persisted ``Enum`` members by *name* (``MANUAL``), while the + current ORM persists/reads them by *value* (``manual``). Loading such legacy + rows raises ``LookupError`` (HTTP 500 on ``GET /api/v1/data/scripts``), so the + startup schema sync must normalise the stored labels to lowercase values. + """ + async with engine.begin() as conn: + await conn.execute( + text( + """ + INSERT INTO ak_data_scripts ( + script_id, script_name, category, frequency, source, + estimated_duration, timeout, is_active, is_custom, + created_at, updated_at + ) VALUES ( + 'legacy_script', 'Legacy Script', 'macro', 'MANUAL', 'akshare', + 60, 300, 1, 0, + '2026-09-01 00:00:00', '2026-09-01 00:00:00' + ) + """ + ) + ) + await conn.execute( + text( + """ + INSERT INTO ak_task_executions ( + execution_id, script_id, status, triggered_by, retry_count, + created_at, updated_at + ) VALUES ( + 'legacy_exec', 'legacy_script', 'COMPLETED', 'MANUAL', 0, + '2026-09-01 00:00:00', '2026-09-01 00:00:00' + ) + """ + ) + ) + + # Before the sync, the ORM cannot map the legacy uppercase labels. + async with async_session_maker() as session: + with pytest.raises(LookupError): + (await session.execute(select(DataScript))).scalars().all() + + await ensure_schema_compatibility() + + async with async_session_maker() as session: + script = ( + await session.execute( + select(DataScript).where(DataScript.script_id == "legacy_script") + ) + ).scalar_one() + execution = ( + await session.execute( + select(TaskExecution).where(TaskExecution.execution_id == "legacy_exec") + ) + ).scalar_one() + + assert script.frequency is ScriptFrequency.MANUAL + assert execution.status is TaskStatus.COMPLETED + assert execution.triggered_by is TriggeredBy.MANUAL + + async with engine.begin() as conn: + stored_frequency = ( + await conn.execute( + text("SELECT frequency FROM ak_data_scripts WHERE script_id='legacy_script'") + ) + ).scalar_one() + assert stored_frequency == "manual" + @pytest.mark.asyncio class TestGetDb: diff --git a/src/backend/tests/test_fincore_adapter.py b/src/backend/tests/test_fincore_adapter.py index 347a23af1..ce9b83acb 100644 --- a/src/backend/tests/test_fincore_adapter.py +++ b/src/backend/tests/test_fincore_adapter.py @@ -5,6 +5,9 @@ for financial metric calculations with fallback to manual calculations. """ +import sys + +import numpy as np import pytest from app.services.backtest.analyzers import FincoreAdapter @@ -38,10 +41,9 @@ def test_calculate_sharpe_ratio_manual_positive(self): returns = [0.01, 0.02, 0.015, -0.005, 0.03, 0.01] result = adapter.calculate_sharpe_ratio(returns, 0.02) - # Expected: mean=0.0117, std=0.012, excess=0.0117-0.02=-0.0083, sharpe=-0.69 - assert isinstance(result, float) - # Just verify it computes a reasonable value - assert -5 < result < 5 # Sanity check + excess_returns = np.asarray(returns) - 0.02 + expected = np.mean(excess_returns) / np.std(excess_returns, ddof=1) * np.sqrt(252) + assert result == pytest.approx(float(expected), rel=1e-12) def test_calculate_sharpe_ratio_manual_zero_std(self): """Test Sharpe ratio with zero standard deviation.""" @@ -68,6 +70,35 @@ def test_calculate_sharpe_ratio_with_risk_free_rate(self): assert isinstance(result, float) +@pytest.mark.skipif(sys.version_info < (3, 11), reason="fincore 0.5 requires Python 3.11+") +class TestCurrentFincoreDomainApi: + """Contract tests for the fincore 0.5 domain-oriented metrics API.""" + + def test_fincore_mode_matches_domain_metric_results_for_an_equity_curve(self): + """The adapter converts equity values before calling fincore leaf metrics.""" + from fincore.metrics.drawdown import max_drawdown + from fincore.metrics.ratios import sharpe_ratio + from fincore.metrics.returns import cum_returns_final, simple_returns + from fincore.metrics.yearly import annual_return + + equity = [100000.0, 101000.0, 98000.0, 103000.0, 102000.0, 106000.0] + returns = simple_returns(np.asarray(equity, dtype=float)) + adapter = FincoreAdapter(use_fincore=True) + + assert adapter.calculate_total_returns(equity) == pytest.approx( + float(cum_returns_final(returns)), rel=1e-12 + ) + assert adapter.calculate_max_drawdown(equity) == pytest.approx( + float(max_drawdown(returns)), rel=1e-12 + ) + assert adapter.calculate_annual_returns(equity, periods_per_year=252) == pytest.approx( + float(annual_return(returns, annualization=252)), rel=1e-12 + ) + assert adapter.calculate_sharpe_ratio( + returns.tolist(), periods_per_year=252 + ) == pytest.approx(float(sharpe_ratio(returns, annualization=252)), rel=1e-12) + + class TestCalculateMaxDrawdown: """Tests for maximum drawdown calculation.""" diff --git a/src/backend/tests/test_fincore_integration.py b/src/backend/tests/test_fincore_integration.py index a5b138552..5fa6e4bb7 100644 --- a/src/backend/tests/test_fincore_integration.py +++ b/src/backend/tests/test_fincore_integration.py @@ -120,7 +120,7 @@ def test_calculate_metrics_empty_data(self): assert result_manual["total_return"] == 0.0 assert result_fincore["total_return"] == 0.0 assert result_manual["metrics_source"] == MetricsSource.MANUAL - assert result_fincore["metrics_source"] == EXPECTED_FINCORE_SOURCE + assert result_fincore["metrics_source"] == MetricsSource.MANUAL class TestFincoreVsManualConsistency: diff --git a/src/backend/tests/test_fincore_metrics_helper.py b/src/backend/tests/test_fincore_metrics_helper.py index 8768cab34..53cf2ae81 100644 --- a/src/backend/tests/test_fincore_metrics_helper.py +++ b/src/backend/tests/test_fincore_metrics_helper.py @@ -1,8 +1,11 @@ """Tests for fincore_metrics_helper - financial metric calculations.""" import builtins +import sys from datetime import datetime, timedelta +import pytest + from app.services.backtest.analyzers import FincoreAdapter from app.services.fincore_metrics_helper import ( MetricsSource, @@ -248,12 +251,32 @@ def test_average_holding_falls_back_to_open_close_dates(self): assert result["total_trades"] == 2 assert result["avg_holding_bars"] == 3.0 + @pytest.mark.skipif(sys.version_info < (3, 11), reason="fincore 0.5 requires Python 3.11+") + def test_fincore_mode_reports_metric_level_provenance(self): + """Return metrics use fincore while trade metrics retain their manual contract.""" + result = calculate_metrics_from_log_data( + { + "equity_curve": [100000.0, 101000.0, 98000.0, 103000.0, 106000.0], + "trades": [{"pnlcomm": 100.0}, {"pnlcomm": -50.0}], + }, + use_fincore=True, + ) + + assert result["metrics_source"] == MetricsSource.FINCORE + assert result["metric_sources"] == { + "total_return": MetricsSource.FINCORE, + "annual_return": MetricsSource.FINCORE, + "sharpe_ratio": MetricsSource.FINCORE, + "max_drawdown": MetricsSource.FINCORE, + "win_rate": MetricsSource.MANUAL, + } + def test_use_fincore_flag_without_fincore(self, monkeypatch): """When fincore is not installed, falls back to manual.""" original_import = builtins.__import__ def import_without_fincore(name, *args, **kwargs): - if name == "fincore": + if name == "fincore" or name.startswith("fincore."): raise ImportError("simulated unsupported Python runtime") return original_import(name, *args, **kwargs) diff --git a/src/backend/tests/test_metrics_service.py b/src/backend/tests/test_metrics_service.py index f4ad9d799..1c5cff6e9 100644 --- a/src/backend/tests/test_metrics_service.py +++ b/src/backend/tests/test_metrics_service.py @@ -24,3 +24,23 @@ def test_metrics_normalize_is_safe_for_empty_trades_and_non_finite_values(): assert result["max_drawdown"] == 0 assert result["total_trades"] == 0 assert result["profit_loss_ratio"] == 0 + + +def test_metrics_normalize_preserves_per_metric_fincore_provenance(): + result = MetricsService().normalize( + { + "metrics_source": "fincore", + "metric_sources": { + "total_return": "fincore", + "annual_return": "fincore", + "sharpe_ratio": "fincore", + "max_drawdown": "fincore", + "win_rate": "manual", + }, + }, + trades=[], + ) + + assert result["metrics_source"] == "fincore" + assert result["metric_sources"]["sharpe_ratio"] == "fincore" + assert result["metric_sources"]["win_rate"] == "manual" diff --git a/src/backend/tests/test_semantic_retrieval_service.py b/src/backend/tests/test_semantic_retrieval_service.py new file mode 100644 index 000000000..cc202ad9f --- /dev/null +++ b/src/backend/tests/test_semantic_retrieval_service.py @@ -0,0 +1,128 @@ +"""Unit tests for SemanticRetrievalService offline-cache handling. + +Regression context: even when the embedding model is fully cached locally, +``huggingface_hub`` performs online HEAD requests against huggingface.co. +On networks where that host is slow or unreachable, the retry/backoff chain +blocks application startup for minutes and the ops health check times out. +The service must therefore prefer offline loading whenever a cached +snapshot exists. + +The cache probe is a deliberate filesystem check (not a ``huggingface_hub`` +call): ``huggingface_hub.constants.HF_HUB_OFFLINE`` is frozen at import +time, so the offline preference must be decided before any Hugging Face +import happens. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from app.services.semantic_retrieval_service import SemanticRetrievalService + +MODEL_NAME = "BAAI/bge-small-zh-v1.5" +REPO_DIR = "models--BAAI--bge-small-zh-v1.5" + + +def _make_service(tmp_path) -> SemanticRetrievalService: + return SemanticRetrievalService( + enabled=True, + persistence_path=tmp_path, + model_name=MODEL_NAME, + ) + + +def _isolate_default_cache(tmp_path, monkeypatch) -> None: + """Point every cache-location source at empty directories. + + Without this, the ``~/.cache/huggingface/hub`` fallback resolves to the + developer's real cache, where the embedding model is usually present. + """ + fake_home = tmp_path / "fake-home" + fake_home.mkdir(exist_ok=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "empty-hub")) + monkeypatch.delenv("HF_HOME", raising=False) + + +def _build_cached_snapshot(cache_root: Path) -> Path: + snapshot = cache_root / REPO_DIR / "snapshots" / "abc123" + snapshot.mkdir(parents=True) + (snapshot / "config.json").write_text("{}") + return snapshot + + +class TestModelIsCached: + def test_true_when_snapshot_file_present(self, tmp_path, monkeypatch): + cache_root = tmp_path / "hub" + _build_cached_snapshot(cache_root) + monkeypatch.setenv("HF_HUB_CACHE", str(cache_root)) + + assert _make_service(tmp_path)._model_is_cached() is True + + def test_true_via_hf_home_when_hub_cache_unset(self, tmp_path, monkeypatch): + cache_root = tmp_path / "hub" + _build_cached_snapshot(cache_root) + monkeypatch.delenv("HF_HUB_CACHE", raising=False) + monkeypatch.setenv("HF_HOME", str(tmp_path)) + + assert _make_service(tmp_path)._model_is_cached() is True + + def test_false_when_repo_dir_missing(self, tmp_path, monkeypatch): + _isolate_default_cache(tmp_path, monkeypatch) + + assert _make_service(tmp_path)._model_is_cached() is False + + def test_false_when_snapshot_lacks_config(self, tmp_path, monkeypatch): + cache_root = tmp_path / "hub" + snapshot = cache_root / REPO_DIR / "snapshots" / "abc123" + snapshot.mkdir(parents=True) + (snapshot / "README.md").write_text("no model files") + monkeypatch.setenv("HF_HUB_CACHE", str(cache_root)) + fake_home = tmp_path / "fake-home" + fake_home.mkdir(exist_ok=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.delenv("HF_HOME", raising=False) + + assert _make_service(tmp_path)._model_is_cached() is False + + +class TestPreferOfflineCache: + def test_sets_offline_env_when_cached(self, tmp_path, monkeypatch): + cache_root = tmp_path / "hub" + _build_cached_snapshot(cache_root) + monkeypatch.setenv("HF_HUB_CACHE", str(cache_root)) + monkeypatch.delenv("HF_HUB_OFFLINE", raising=False) + monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising=False) + + _make_service(tmp_path)._prefer_offline_cache() + + assert os.environ.get("HF_HUB_OFFLINE") == "1" + assert os.environ.get("TRANSFORMERS_OFFLINE") == "1" + + def test_leaves_env_untouched_when_not_cached(self, tmp_path, monkeypatch): + _isolate_default_cache(tmp_path, monkeypatch) + monkeypatch.delenv("HF_HUB_OFFLINE", raising=False) + monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising=False) + + _make_service(tmp_path)._prefer_offline_cache() + + assert "HF_HUB_OFFLINE" not in os.environ + assert "TRANSFORMERS_OFFLINE" not in os.environ + + def test_fixes_frozen_hub_constant_from_earlier_import(self, tmp_path, monkeypatch): + """Regression: ``huggingface_hub.constants.HF_HUB_OFFLINE`` is frozen + at import time. When an unrelated dependency (e.g. ``datasets``) + imports ``huggingface_hub`` before warm-up runs, setting the env var + alone is not enough — the cached constant must be corrected too. + """ + import huggingface_hub.constants as hf_constants + + cache_root = tmp_path / "hub" + _build_cached_snapshot(cache_root) + monkeypatch.setenv("HF_HUB_CACHE", str(cache_root)) + monkeypatch.setattr(hf_constants, "HF_HUB_OFFLINE", False) + + _make_service(tmp_path)._prefer_offline_cache() + + assert hf_constants.HF_HUB_OFFLINE is True From bcca12ab7da3840f4717a53a81b6125449234e00 Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Wed, 2 Sep 2026 09:20:44 +0800 Subject: [PATCH 30/32] style(backend): format database compatibility logs --- src/backend/app/db/database.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/backend/app/db/database.py b/src/backend/app/db/database.py index 15cca16fd..92ca5c71c 100644 --- a/src/backend/app/db/database.py +++ b/src/backend/app/db/database.py @@ -686,8 +686,7 @@ def _ensure_akshare_mgmt_schema_compatibility_sync(bind) -> None: ) ) logger.warning( - "Normalized MySQL enum column %s.%s to lowercase values " - "during startup schema sync", + "Normalized MySQL enum column %s.%s to lowercase values during startup schema sync", table_name, column_name, ) @@ -703,8 +702,7 @@ def _ensure_akshare_mgmt_schema_compatibility_sync(bind) -> None: ).rowcount if updated: logger.warning( - "Normalized %d legacy uppercase enum value(s) in %s.%s " - "during startup schema sync", + "Normalized %d legacy uppercase enum value(s) in %s.%s during startup schema sync", updated, table_name, column_name, From de8c9242a01374ace03d2c5d66f1851246856fdd Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Wed, 2 Sep 2026 09:42:39 +0800 Subject: [PATCH 31/32] test(retrieval): isolate optional huggingface hub import --- src/backend/tests/test_semantic_retrieval_service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/backend/tests/test_semantic_retrieval_service.py b/src/backend/tests/test_semantic_retrieval_service.py index cc202ad9f..b9725c3d4 100644 --- a/src/backend/tests/test_semantic_retrieval_service.py +++ b/src/backend/tests/test_semantic_retrieval_service.py @@ -16,7 +16,9 @@ from __future__ import annotations import os +import sys from pathlib import Path +from types import ModuleType from app.services.semantic_retrieval_service import SemanticRetrievalService @@ -116,12 +118,16 @@ def test_fixes_frozen_hub_constant_from_earlier_import(self, tmp_path, monkeypat imports ``huggingface_hub`` before warm-up runs, setting the env var alone is not enough — the cached constant must be corrected too. """ - import huggingface_hub.constants as hf_constants + hf_package = ModuleType("huggingface_hub") + hf_constants = ModuleType("huggingface_hub.constants") + hf_constants.HF_HUB_OFFLINE = False + hf_package.constants = hf_constants + monkeypatch.setitem(sys.modules, "huggingface_hub", hf_package) + monkeypatch.setitem(sys.modules, "huggingface_hub.constants", hf_constants) cache_root = tmp_path / "hub" _build_cached_snapshot(cache_root) monkeypatch.setenv("HF_HUB_CACHE", str(cache_root)) - monkeypatch.setattr(hf_constants, "HF_HUB_OFFLINE", False) _make_service(tmp_path)._prefer_offline_cache() From b8afe9b5978e25f4c235ba31d8ac2788ab7146fe Mon Sep 17 00:00:00 2001 From: cloudQuant Date: Wed, 2 Sep 2026 11:06:24 +0800 Subject: [PATCH 32/32] fix(database): handle postgres legacy enum labels --- src/backend/app/db/database.py | 55 +++++++++++++++++++++++++++-- src/backend/tests/test_db.py | 64 +++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/backend/app/db/database.py b/src/backend/app/db/database.py index 92ca5c71c..8f04cb063 100644 --- a/src/backend/app/db/database.py +++ b/src/backend/app/db/database.py @@ -663,6 +663,7 @@ def _ensure_scanner_plan_schema_compatibility_sync(bind) -> None: def _ensure_akshare_mgmt_schema_compatibility_sync(bind) -> None: """Normalize legacy uppercase akshare enum labels to lowercase values.""" dialect_name = bind.dialect.name + normalized_postgres_enum_types: set[tuple[str | None, str]] = set() for table_name, column_name in _AKSHARE_LEGACY_ENUM_COLUMNS: if not _has_table(bind, table_name): continue @@ -692,8 +693,58 @@ def _ensure_akshare_mgmt_schema_compatibility_sync(bind) -> None: ) continue - # Text-backed dialects (SQLite, PostgreSQL): lowercase legacy rows so ORM - # reads resolve to the defined enum values. + if dialect_name == "postgresql": + enum_labels = list(getattr(column["type"], "enums", []) or []) + enum_type_name = getattr(column["type"], "name", None) + if enum_labels: + if not enum_type_name: + logger.warning( + "Skipped PostgreSQL enum normalization for %s.%s: enum type has no name", + table_name, + column_name, + ) + continue + + rename_pairs = [ + (label, label.lower()) for label in enum_labels if label != label.lower() + ] + if not rename_pairs: + continue + + enum_schema = getattr(column["type"], "schema", None) + enum_key = (enum_schema, enum_type_name) + if enum_key in normalized_postgres_enum_types: + continue + + existing_labels = set(enum_labels) + if any(new_label in existing_labels for _, new_label in rename_pairs): + logger.warning( + "Skipped PostgreSQL enum normalization for %s.%s: lower-case labels already exist", + table_name, + column_name, + ) + continue + + quote_identifier = bind.dialect.identifier_preparer.quote + enum_sql = quote_identifier(enum_type_name) + if enum_schema: + enum_sql = f"{quote_identifier(enum_schema)}.{enum_sql}" + for old_label, new_label in rename_pairs: + old_sql = old_label.replace("'", "''") + new_sql = new_label.replace("'", "''") + bind.execute( + text(f"ALTER TYPE {enum_sql} RENAME VALUE '{old_sql}' TO '{new_sql}'") + ) + normalized_postgres_enum_types.add(enum_key) + logger.warning( + "Normalized PostgreSQL enum type %s to lowercase values during startup schema sync", + enum_sql, + ) + continue + + # Text-backed dialects such as SQLite: lowercase legacy rows so ORM reads + # resolve to the defined enum values. PostgreSQL native enums are handled + # above through type-level label renames because LOWER(enum) is invalid. updated = bind.execute( text( f"UPDATE {table_name} SET {column_name} = LOWER({column_name}) " diff --git a/src/backend/tests/test_db.py b/src/backend/tests/test_db.py index cde38776c..b7b846be1 100644 --- a/src/backend/tests/test_db.py +++ b/src/backend/tests/test_db.py @@ -2,14 +2,17 @@ Database module tests. """ -from unittest.mock import AsyncMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import pytest from sqlalchemy import delete, select, text +from sqlalchemy.dialects import postgresql from app.config import get_settings from app.db.database import ( Base, + _ensure_akshare_mgmt_schema_compatibility_sync, async_session_maker, create_default_admin, create_tables, @@ -440,6 +443,65 @@ async def test_ensure_schema_compatibility_normalizes_legacy_akshare_enum_values ).scalar_one() assert stored_frequency == "manual" + async def test_akshare_schema_sync_skips_lowercase_postgres_enum(self, monkeypatch): + """Fresh PostgreSQL enums must not be passed to text ``LOWER`` calls.""" + bind = SimpleNamespace( + dialect=SimpleNamespace( + name="postgresql", + identifier_preparer=postgresql.dialect().identifier_preparer, + ), + execute=Mock(return_value=SimpleNamespace(rowcount=0)), + ) + enum_type = postgresql.ENUM( + "hourly", "daily", "weekly", "monthly", "once", "manual", name="scriptfrequency" + ) + inspector = SimpleNamespace( + get_columns=lambda _table_name: [ + {"name": "frequency", "type": enum_type, "nullable": True} + ] + ) + monkeypatch.setattr( + "app.db.database._has_table", lambda _bind, table_name: table_name == "ak_data_scripts" + ) + monkeypatch.setattr("app.db.database.sa.inspect", lambda _bind: inspector) + + _ensure_akshare_mgmt_schema_compatibility_sync(bind) + + bind.execute.assert_not_called() + + async def test_akshare_schema_sync_renames_legacy_postgres_enum_labels(self, monkeypatch): + """PostgreSQL legacy enum labels need type-level renames, not ``LOWER``.""" + bind = SimpleNamespace( + dialect=SimpleNamespace( + name="postgresql", + identifier_preparer=postgresql.dialect().identifier_preparer, + ), + execute=Mock(return_value=SimpleNamespace(rowcount=0)), + ) + enum_type = postgresql.ENUM( + "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "ONCE", "MANUAL", name="scriptfrequency" + ) + inspector = SimpleNamespace( + get_columns=lambda _table_name: [ + {"name": "frequency", "type": enum_type, "nullable": True} + ] + ) + monkeypatch.setattr( + "app.db.database._has_table", lambda _bind, table_name: table_name == "ak_data_scripts" + ) + monkeypatch.setattr("app.db.database.sa.inspect", lambda _bind: inspector) + + _ensure_akshare_mgmt_schema_compatibility_sync(bind) + + assert [str(call.args[0]) for call in bind.execute.call_args_list] == [ + "ALTER TYPE scriptfrequency RENAME VALUE 'HOURLY' TO 'hourly'", + "ALTER TYPE scriptfrequency RENAME VALUE 'DAILY' TO 'daily'", + "ALTER TYPE scriptfrequency RENAME VALUE 'WEEKLY' TO 'weekly'", + "ALTER TYPE scriptfrequency RENAME VALUE 'MONTHLY' TO 'monthly'", + "ALTER TYPE scriptfrequency RENAME VALUE 'ONCE' TO 'once'", + "ALTER TYPE scriptfrequency RENAME VALUE 'MANUAL' TO 'manual'", + ] + @pytest.mark.asyncio class TestGetDb: