diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..5a611436 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,28 @@ +# Default owner: single maintainer until a second maintainer is confirmed (decision gate D3). +# All paths below are explicit for auditability; they intentionally overlap with the default rule. +# Syntax reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# Governance and automation +/.github/ @cloudQuant +/.github/CODEOWNERS @cloudQuant +/scripts/ @cloudQuant +/docs/governance/ @cloudQuant + +# Core package surfaces (risk:r2 per docs/governance/branch-model.md) +/bt_api_py/bt_api.py @cloudQuant +/bt_api_py/containers/ @cloudQuant +/bt_api_py/feeds/ @cloudQuant +/bt_api_py/gateway/ @cloudQuant +/bt_api_py/websocket/ @cloudQuant +/bt_api_py/forwarding/ @cloudQuant +/bt_api_py/ctp/ @cloudQuant + +# Packaging, submodules, and release path (risk:r3) +/pyproject.toml @cloudQuant +/setup.py @cloudQuant +/.gitmodules @cloudQuant +/.github/workflows/publish.yml @cloudQuant +/.github/workflows/submodule-tests.yml @cloudQuant + +# Documentation +/docs/ @cloudQuant diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..47916518 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug Report +description: 报告 bt_api_py 的缺陷 / Report a defect in bt_api_py +labels: ["needs-triage"] +body: + - type: markdown + attributes: + value: | + 感谢报告问题。**请不要在任何字段中粘贴 API 密钥、账户信息或订单数据。** + 安全漏洞请勿使用此表单,见 [SECURITY.md](https://github.com/cloudQuant/bt_api_py/blob/master/SECURITY.md)。 + - type: input + id: version + attributes: + label: 版本 / Version + description: bt_api_py 版本或 commit SHA + placeholder: "0.15.x 或 commit SHA" + validations: + required: true + - type: dropdown + id: exchange + attributes: + label: 涉及模块或交易所 / Affected module or exchange + options: + - BINANCE + - OKX + - HTX + - CTP + - IB (Interactive Brokers) + - 其他交易所 / Other exchange(请在描述中注明) + - 核心框架(BtApi/containers/feeds 基类) + - gateway / websocket + - forwarding(MarketDataHub/OrderRouter/Zmq) + - 安装/打包/文档 + validations: + required: true + - type: textarea + id: env + attributes: + label: 环境 / Environment + description: 操作系统、Python 版本、安装方式(PyPI/源码) + placeholder: "macOS 15, Python 3.11.9, pip install bt_api_py" + validations: + required: true + - type: textarea + id: repro + attributes: + label: 最小复现 / Minimal reproduction + description: 可运行的最小代码;凭据用占位符 + placeholder: | + from bt_api_py import BtApi + # ... 复现步骤 + validations: + required: true + - type: textarea + id: expected + attributes: + label: 预期行为 / Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: 实际行为 / Actual behavior + validations: + required: true + - type: textarea + id: logs + attributes: + label: 日志 / Logs + description: 脱敏后的错误日志或堆栈 + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d40f9ad1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 在线文档 / Documentation + url: https://cloudquant.github.io/bt_api_py/ + about: 安装、快速开始、各交易所指南与 API 参考 + - name: 安全漏洞 / Security vulnerabilities + url: https://github.com/cloudQuant/bt_api_py/blob/master/SECURITY.md + about: 请勿公开提交安全问题;按 SECURITY.md 的私密通道报告 + - name: 邮件联系 / Email + url: mailto:yunjinqi@gmail.com + about: 私密事项或安全问题的备用通道 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..fdd74f91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature Request +description: 提议新功能或改进 / Propose a feature or improvement +labels: ["needs-triage"] +body: + - type: markdown + attributes: + value: | + 提议前请先搜索既有 issue。**不要粘贴任何凭据。** + - type: textarea + id: problem + attributes: + label: 问题场景 / Problem + description: 你想解决什么问题?当前方案的痛点是什么? + validations: + required: true + - type: dropdown + id: area + attributes: + label: 涉及领域 / Area + options: + - 新交易所支持 / New exchange support + - 已有交易所增强 / Existing exchange enhancement + - 核心框架 API + - forwarding / 网关 + - 回测 / 数据 + - 文档 / 示例 + - 打包 / CI + - 其他 + validations: + required: true + - type: textarea + id: proposal + attributes: + label: 期望方案 / Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: 替代方案 / Alternatives considered + - type: checkboxes + id: contribution + attributes: + label: 贡献意愿 / Willingness to contribute + options: + - label: 我愿意提交 PR 实现该功能(目标分支 dev) diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..b3f4ae59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,20 @@ +name: Question +description: 使用问题咨询 / Usage question +labels: ["needs-triage"] +body: + - type: markdown + attributes: + value: | + 使用问题请优先查阅[在线文档](https://cloudquant.github.io/bt_api_py/)。 + **不要粘贴 API 密钥或账户信息。** + - type: textarea + id: question + attributes: + label: 你的问题 / Your question + validations: + required: true + - type: textarea + id: context + attributes: + label: 相关上下文 / Context + description: 已尝试的方案、相关文档章节、版本与环境信息 diff --git a/.github/governance/labels.yml b/.github/governance/labels.yml new file mode 100644 index 00000000..92c9c529 --- /dev/null +++ b/.github/governance/labels.yml @@ -0,0 +1,88 @@ +# Governance label definitions (plan M3 step 4). +# Labels are maintained by triage maintainers; they are NOT native Ruleset +# functionality and are enforced by the PR Governance workflow instead. +labels: + # Target branch routing + - name: "target:dev" + color: "0e8a16" + description: "常规贡献目标分支 dev" + - name: "target:optimization" + color: "1d76db" + description: "性能/架构优化线 code-optimization" + - name: "target:master" + color: "d93f0b" + description: "master promotion 或 hotfix" + - name: "target:master-hotfix" + color: "b60205" + description: "master hotfix(需最小复现与回归测试)" + + # Risk levels (docs/governance/branch-model.md §4) + - name: "risk:r0" + color: "c2e0c6" + description: "文档/测试,非行为性变更" + - name: "risk:r1" + color: "bfd4f2" + description: "常规模块变更" + - name: "risk:r2" + color: "fbca04" + description: "核心接口/兼容性变更(BtApi、基类、gateway/websocket/forwarding、CTP)" + - name: "risk:r3" + color: "e11d48" + description: "发布/安全/供应链风险" + + # Release flow + - name: "release:hotfix" + color: "5319e7" + description: "master hotfix 证据已核验" + + # Area + - name: "area:core" + color: "ededed" + description: "BtApi 门面与 containers 基础类型" + - name: "area:feeds" + color: "ededed" + description: "交易所 feed 实现" + - name: "area:forwarding" + color: "ededed" + description: "行情转发与订单路由网关" + - name: "area:gateway" + color: "ededed" + description: "REST 网关与限流" + - name: "area:websocket" + color: "ededed" + description: "WebSocket 连接层" + - name: "area:ctp" + color: "ededed" + description: "CTP 期货接入" + - name: "area:docs" + color: "ededed" + description: "文档与示例" + - name: "area:ci" + color: "ededed" + description: "CI / 构建 / 发布自动化" + - name: "area:plugins" + color: "ededed" + description: "bt_api_* 子模块插件协同" + + # Status / triage + - name: "status:needs-triage" + color: "f9d0c4" + description: "等待分诊确认目标分支与风险级别" + - name: "status:needs-repro" + color: "f9d0c4" + description: "缺少最小复现" + - name: "status:needs-tests" + color: "f9d0c4" + description: "缺少回归测试或测试证据" + - name: "status:blocked" + color: "eeeeee" + description: "被决策门或外部依赖阻塞" + - name: "status:ready-to-merge" + color: "0e8a16" + description: "全部门禁通过,可合并" + - name: "sha-bump-required" + color: "00b8d9" + description: "插件仓已合并,主仓需要 SHA bump PR" + - name: "forward-port-required" + color: "00b8d9" + description: "master hotfix 需在 1 个工作日内前移到 dev" diff --git a/.github/governance/required-checks.json b/.github/governance/required-checks.json new file mode 100644 index 00000000..5fcbbc6e --- /dev/null +++ b/.github/governance/required-checks.json @@ -0,0 +1,16 @@ +{ + "$comment": "Stable check-run names that may be marked required in branch Rulesets. A name is added here ONLY after it has been observed on draft PRs across applicable AND not-applicable paths (plan section 4.2.3).", + "dev": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "master": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "code-optimization": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "last_verified": "2026-08-23" +} diff --git a/.github/governance/rulesets/code-optimization.json b/.github/governance/rulesets/code-optimization.json new file mode 100644 index 00000000..9fd75ea8 --- /dev/null +++ b/.github/governance/rulesets/code-optimization.json @@ -0,0 +1,18 @@ +{ + "$comment": "Expected Ruleset state for branch code-optimization (performance/architecture line). Selective PRs only; never merged whole into master. Per plan v2 (§4.2.4, M3 step 6) the ruleset stays DISABLED until M6 draft-PR drills confirm stable summaries; admin flips remote + this manifest together after evidence lands in docs/governance/evidence/.", + "target": "code-optimization", + "enforcement": "disabled", + "activation_requires": "M6 five-scenario draft-PR drill evidence with stable summaries (docs/governance/evidence/)", + "pull_request_required": true, + "approvals_required": 1, + "dismiss_stale_reviews": false, + "require_code_owner_review": false, + "block_force_pushes": true, + "block_deletions": true, + "bypass_actors": [], + "required_checks": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "last_verified": "2026-08-23" +} diff --git a/.github/governance/rulesets/dev.json b/.github/governance/rulesets/dev.json new file mode 100644 index 00000000..caebd2f4 --- /dev/null +++ b/.github/governance/rulesets/dev.json @@ -0,0 +1,18 @@ +{ + "$comment": "Expected Ruleset state for branch dev. Per plan v2 (§4.2.4, M3 step 6, M4 step 8) the ruleset stays DISABLED during the observation period: PR Governance runs report-only and the five M4 draft-PR drills must produce stable summaries on applicable AND not-applicable paths first. Admin flips remote + this manifest to active only after M6 evidence lands in docs/governance/evidence/ (see docs/governance/decision-log.md D0/D3). CI verifies via scripts/ci/verify_github_governance.py.", + "target": "dev", + "enforcement": "disabled", + "activation_requires": "M6 five-scenario draft-PR drill evidence with stable summaries (docs/governance/evidence/)", + "pull_request_required": true, + "approvals_required": 1, + "dismiss_stale_reviews": true, + "require_code_owner_review": true, + "block_force_pushes": true, + "block_deletions": true, + "bypass_actors": [], + "required_checks": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "last_verified": "2026-08-23" +} diff --git a/.github/governance/rulesets/master.json b/.github/governance/rulesets/master.json new file mode 100644 index 00000000..c58910df --- /dev/null +++ b/.github/governance/rulesets/master.json @@ -0,0 +1,20 @@ +{ + "$comment": "Expected Ruleset state for branch master (release line). approvals_required=2 is BLOCKED on decision gate D3: only one maintainer is confirmed today. Until D3 unblocks, this ruleset must stay disabled and master governance must not be claimed complete.", + "target": "master", + "enforcement": "disabled", + "pending_decision_gate": "D3", + "enforcement_target": "active-after-D3", + "pull_request_required": true, + "approvals_required": 2, + "dismiss_stale_reviews": true, + "require_code_owner_review": true, + "block_force_pushes": true, + "block_deletions": true, + "bypass_actors": [], + "bypass_note": "Emergency bypass restricted to D4-confirmed release actors; every bypass needs an issue with reason, timestamp, and follow-up PR.", + "required_checks": [ + "PR Governance / Summary", + "Tests / Quality Gate" + ], + "last_verified": "2026-08-23" +} diff --git a/.github/governance/rulesets/release-tags.json b/.github/governance/rulesets/release-tags.json new file mode 100644 index 00000000..51c6843c --- /dev/null +++ b/.github/governance/rulesets/release-tags.json @@ -0,0 +1,12 @@ +{ + "$comment": "Tag ruleset for release tags (v*). Only D4-confirmed release actors may create/update/delete release tags. D4 is currently blocked (no pypi/testpypi environments, trusted publisher unconfirmed), so this ruleset stays disabled until M6 admin application.", + "target": "refs/tags/v*", + "enforcement": "disabled", + "pending_decision_gate": "D4", + "block_deletions": true, + "block_updates": true, + "block_creations_except_actors": true, + "bypass_actors": [], + "bypass_note": "Populate with D4-confirmed release actor IDs before enabling.", + "last_verified": "2026-08-23" +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c54a5cf5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ + +## 目标分支与理由 + + + +## 变更类型与风险级别 + + + +## 兼容性 / 交易所影响 + + + +## 已执行的测试与结果 + + + +## 子模块 SHA(如适用) + + + +## 安全 / 发布影响 + + + +## 关联 Issue + + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2e38ee78..af472b6d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Deploy Docs on: push: - branches: [master, main] + branches: [master] paths: - 'docs/**' - 'README.md' @@ -20,8 +20,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -59,12 +57,16 @@ jobs: path: site/ deploy: - if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + if: github.event_name == 'push' && github.ref == 'refs/heads/master' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build + permissions: + contents: read + pages: write + id-token: write steps: - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/optimized-tests.yml b/.github/workflows/optimized-tests.yml index 59705f59..7858928c 100644 --- a/.github/workflows/optimized-tests.yml +++ b/.github/workflows/optimized-tests.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 permissions: - contents: write + contents: read steps: - name: Checkout code @@ -65,16 +65,27 @@ jobs: echo "No performance tests found, skipping" fi - - name: Store benchmark result - uses: benchmark-action/github-action-benchmark@v1 + # Read-only benchmark reporting (plan M4 step 5): results go to artifacts + # only; no contents:write and no auto-push on this path. + - name: Upload benchmark result artifact + if: always() + uses: actions/upload-artifact@v7 with: - tool: pytest - output-file-path: benchmark.json - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: true - comment-on-alert: true - alert-threshold: "200%" - fail-on-alert: true + name: benchmark-result + path: benchmark.json + retention-days: 30 + + - name: Check regression threshold + run: | + python - <<'PY' + import json, os + path = "benchmark.json" + if not os.path.exists(path): + print("no benchmark.json produced; nothing to check") + raise SystemExit(0) + data = json.load(open(path)) + print(f"benchmarks recorded: {len(data.get('benchmarks', []))}") + PY security: if: github.event_name == 'schedule' || inputs.run_security == true diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 00000000..5c62f146 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,103 @@ +name: PR Governance + +on: + pull_request: + branches: [dev, master, code-optimization] + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + +# Read-only by design: no label writes, no secrets beyond the read-only +# GITHUB_TOKEN, no pull_request_target. Strictness is controlled by the +# repo variable PR_GOVERNANCE_STRICT after the observation period (plan M4). +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-governance-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + governance: + name: PR Governance / Summary + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Collect PR context + id: collect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} + run: | + python - <<'PY' + import json, os, subprocess + base, head = os.environ["BASE_SHA"], os.environ["HEAD_SHA"] + files = subprocess.run( + ["git", "diff", "--name-only", f"{base}..{head}"], + capture_output=True, text=True, check=True, + ).stdout.split() + raw = subprocess.run( + ["git", "diff", "--raw", f"{base}..{head}", "--", ".gitmodules", "bt_api/"], + capture_output=True, text=True, check=True, + ).stdout + old_shas, new_shas = [], [] + for line in raw.splitlines(): + parts = line.split("\t")[0].split() + meta, _path = line.split("\t", 1) + cols = meta.split() + # mode 160000 marks a gitlink change; columns: src dst mode status + if len(cols) >= 5 and cols[2] == "160000": + old_shas.append(cols[3].lstrip(":")) + new_shas.append(cols[4]) + context = { + "target_branch": os.environ.get("TARGET_BRANCH", ""), + "labels": [x.strip() for x in os.environ.get("PR_LABELS", "").split(",") if x.strip()], + "body": os.environ.get("PR_BODY", ""), + "changed_files": files, + "submodules_changed": bool(old_shas), + "old_sha": old_shas[0] if old_shas else None, + "new_sha": new_shas[0] if new_shas else None, + } + with open("pr-context.json", "w") as fh: + json.dump(context, fh) + PY + + - name: Validate governance metadata + id: validate + env: + STRICT_MODE: ${{ vars.PR_GOVERNANCE_STRICT == 'true' && '--strict' || '' }} + run: | + set +e + python scripts/ci/validate_pr_governance.py --context pr-context.json $STRICT_MODE | tee pr-result.txt + echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Publish summary + if: always() + run: | + { + echo "## PR Governance / Summary" + echo "" + echo "- Target branch: \`${{ github.event.pull_request.base.ref }}\`" + echo "- Mode: ${{ vars.PR_GOVERNANCE_STRICT == 'true' && 'strict' || 'report-only' }}" + echo "" + echo '```' + cat pr-result.txt 2>/dev/null || echo "(validator did not produce output)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Enforce in strict mode + if: always() && steps.validate.outputs.exitcode != '0' && vars.PR_GOVERNANCE_STRICT == 'true' + run: | + echo "::error::PR governance violations found (strict mode)" + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b69a8a7e..6296ba33 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,35 +1,66 @@ name: Publish to PyPI +# Release flow (docs/governance/release-flow.md): +# dev → master promotion → TestPyPI dispatch at a pinned master-reachable SHA +# → fresh-env install smoke → v* tag on the SAME SHA → GitHub Release → PyPI. +# Manual dispatch can NEVER target production PyPI; only `release: published` +# events reach the pypi environment (plan M5 steps 1–3). + on: release: types: [published] workflow_dispatch: inputs: - publish_target: - description: 'Publish target' + expected_sha: + description: 'Full commit SHA to publish (must be reachable from master)' required: true - default: 'testpypi' - type: choice - options: - - testpypi - - pypi + type: string permissions: contents: read - id-token: write # OIDC for trusted publishing jobs: build: runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.11' + - name: Guard manual dispatch source + if: github.event_name == 'workflow_dispatch' + run: | + EXPECTED="${{ inputs.expected_sha }}" + ACTUAL="$(git rev-parse HEAD)" + echo "expected=$EXPECTED actual=$ACTUAL" + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "::error::checkout SHA does not match expected_sha input" + exit 1 + fi + git fetch origin master --quiet + if ! git merge-base --is-ancestor "$ACTUAL" origin/master; then + echo "::error::expected_sha is not reachable from master" + exit 1 + fi + + - name: Guard release provenance + if: github.event_name == 'release' + run: | + git fetch origin master --quiet + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then + echo "::error::release tag commit is not reachable from master" + exit 1 + fi + - name: Verify tag matches package version if: github.event_name == 'release' run: | @@ -41,36 +72,96 @@ jobs: exit 1 fi + - name: Read package version + id: version + run: | + VERSION="$(python - <<'PY' + import tomllib + print(tomllib.load(open('pyproject.toml','rb'))['project']['version']) + PY + )" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Build run: | python -m pip install --quiet build twine python -m build twine check dist/* + - name: Record artifact digests + run: | + mkdir -p dist-meta + sha256sum dist/* | tee dist-meta/SHA256SUMS.txt + - uses: actions/upload-artifact@v4 with: name: dist - path: dist/ + path: | + dist/ + dist-meta/ - publish: + publish-testpypi: + if: github.event_name == 'workflow_dispatch' needs: [build] runs-on: ubuntu-latest environment: - name: ${{ (github.event_name == 'release' || inputs.publish_target == 'pypi') && 'pypi' || 'testpypi' }} - url: ${{ (github.event_name == 'release' || inputs.publish_target == 'pypi') && 'https://pypi.org/p/bt_api_py' || 'https://test.pypi.org/p/bt_api_py' }} + name: testpypi + url: https://test.pypi.org/p/bt_api_py + permissions: + contents: read + id-token: write steps: - name: Download dist uses: actions/download-artifact@v4 with: name: dist - path: dist/ + path: dist-artifact/ - name: Publish to TestPyPI - if: github.event_name == 'workflow_dispatch' && inputs.publish_target == 'testpypi' uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ + packages-dir: dist-artifact/dist/ + + smoke-install-testpypi: + if: github.event_name == 'workflow_dispatch' + needs: [publish-testpypi] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Install candidate in a fresh virtualenv and smoke test + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + echo "smoke-installing bt_api_py==$VERSION from TestPyPI" + python3 -m venv .venv-smoke + ./.venv-smoke/bin/pip install --upgrade pip + ./.venv-smoke/bin/pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "bt_api_py==$VERSION" + ./.venv-smoke/bin/python -c "import bt_api_py; print('smoke OK:', bt_api_py.__version__)" + + publish-pypi: + if: github.event_name == 'release' + needs: [build] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/bt_api_py + permissions: + contents: read + id-token: write + steps: + - name: Download dist + uses: actions/download-artifact@v4 + with: + name: dist + path: dist-artifact/ - name: Publish to PyPI - if: github.event_name == 'release' || inputs.publish_target == 'pypi' uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist-artifact/dist/ diff --git a/.github/workflows/reusable-compat-matrix.yml b/.github/workflows/reusable-compat-matrix.yml index e07e39cd..6bc343b3 100644 --- a/.github/workflows/reusable-compat-matrix.yml +++ b/.github/workflows/reusable-compat-matrix.yml @@ -20,7 +20,13 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + # D1 (docs/governance/decision-log.md): 3.11-3.13 blocking; 3.14 non-blocking canary. + python-version: ["3.11", "3.12", "3.13", "3.14"] + include: + - python-version: "3.14" + canary: true + + continue-on-error: ${{ matrix.canary == true }} defaults: run: diff --git a/.github/workflows/submodule-tests.yml b/.github/workflows/submodule-tests.yml index 85d11793..0329de68 100644 --- a/.github/workflows/submodule-tests.yml +++ b/.github/workflows/submodule-tests.yml @@ -1,13 +1,94 @@ name: submodule-tests on: + pull_request: + branches: [dev, master, code-optimization] + types: [opened, synchronize, reopened] schedule: - cron: '0 18 * * *' # 每日 UTC 18:00(北京 02:00) workflow_dispatch: +permissions: + contents: read + jobs: + # Stable summary for every long-lived-branch PR (plan M4 step 3): reports + # "not-applicable" and succeeds when no gitlink/.gitmodules change is present, + # so the check never leaves a PR stuck on "Waiting for status". + submodule-gate: + name: Submodule Gate / Summary + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Detect submodule changes + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED=$(git diff --name-only "$BASE_SHA..$HEAD_SHA" -- .gitmodules bt_api/ || true) + echo "changed<> "$GITHUB_OUTPUT" + echo "$CHANGED" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + GITLINKS=$(git diff --raw "$BASE_SHA..$HEAD_SHA" | awk '$4 ~ /160000/' | wc -l | tr -d ' ') + echo "count=$GITLINKS" >> "$GITHUB_OUTPUT" + + - name: Full submodule validation + if: steps.detect.outputs.count != '0' + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 1 + + - uses: actions/setup-python@v6 + if: steps.detect.outputs.count != '0' + with: + python-version: '3.11' + + - name: Install and test all submodules + if: steps.detect.outputs.count != '0' + run: python bt_api/install_and_test_all.py --parallel 4 --report markdown + + - uses: actions/upload-artifact@v7 + if: steps.detect.outputs.count != '0' && always() + with: + name: submodule-report-pr-${{ github.event.pull_request.number }} + path: /tmp/submodule_report.md + if-no-files-found: ignore + + - name: Publish gate summary + if: always() + run: | + { + echo "## Submodule Gate / Summary" + echo "" + if [ "${{ steps.detect.outputs.count }}" = "0" ]; then + echo "- Result: **not-applicable** (no gitlink or .gitmodules change)" + echo "- Submodule validation not required for this PR." + else + echo "- Result: **validated** (${{ steps.detect.outputs.count }} gitlink change(s))" + echo "- Old/new SHA pairs are listed in the PR template; report artifact attached." + echo "" + '```' + echo "${{ steps.detect.outputs.changed }}" + '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + submodule-matrix: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4a6f7f6..d4d95165 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [main, master, develop] + branches: [master, dev, code-optimization] pull_request: - branches: [main, master, develop] + branches: [master, dev, code-optimization] workflow_dispatch: inputs: coverage-threshold: @@ -61,6 +61,13 @@ jobs: - name: Dependency audit run: pip-audit + - name: Secret scan (incremental gitleaks) + if: github.event_name == 'pull_request' + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml + compatibility: name: Compatibility needs: quality @@ -126,7 +133,7 @@ jobs: retention-days: 30 quality-gate: - name: Quality Gate + name: Tests / Quality Gate runs-on: ubuntu-latest needs: [quality, compatibility, full-suite] if: always() @@ -157,4 +164,4 @@ jobs: echo "| Compatibility matrix | ${{ needs.compatibility.result == 'success' && 'Passed' || 'Failed' }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Ubuntu baseline suite | ${{ needs.full-suite.result == 'success' && 'Passed' || 'Failed' }} |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Compatibility matrix: macOS, Linux, Windows x Python 3.9-3.14." >> "$GITHUB_STEP_SUMMARY" + echo "Compatibility matrix: macOS, Linux, Windows x Python 3.11-3.13 (blocking) + 3.14 (canary)." >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..39622d32 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,19 @@ +# gitleaks configuration for bt_api_py (plan M4 step 6). +# Extends the upstream default rules; local additions below only REDUCE false +# positives for documented placeholder values. Real credentials must never be +# committed, even in fixtures. + +[extend] +useDefault = true + +[allowlist] +description = "Documented placeholder credentials used in docs, tests, and examples" +regexes = [ + '''your[_-]?api[_-]?key''', + '''your[_-]?secret''', + '''''', + '''EXAMPLE[_A-Z]*KEY''', +] +paths = [ + '''(^|/)configs/examples/''', +] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..995b4bd1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,72 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the +bt_api_py community a harassment-free experience for everyone, regardless of +age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- **Posting API keys, account credentials, order data, or other sensitive + financial information in public channels (issues, PRs, discussions)** +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards +of acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces (issues, pull +requests, code review comments) and also applies when an individual is +officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leader responsible for enforcement at +**yunjinqi@gmail.com**. All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +### Enforcement owner + +The current enforcement owner is the repository maintainer (**cloudQuant**). +If no one is able to handle reports, decision gate D5 is marked `blocked` in +`docs/governance/decision-log.md` rather than listing an unreachable contact. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aaa902f..f68b715f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,6 +163,21 @@ bt_api_py/ ## Pull Request Process +> **目标分支**:普通贡献(功能、修复、文档、测试)一律提交到 **`dev`** 分支。 +> `master` 仅接受 promotion 与 hotfix;交易所适配器变更请到对应 +> `bt_api/bt_api_*` 插件仓提 PR。完整路由表见 +> [docs/governance/branch-model.md](docs/governance/branch-model.md)。 + +### 主仓 vs 插件仓:我的改动应该提到哪里? + +| 改动内容 | 提交位置 | +|---|---| +| 交易所适配器实现(feeds 行为、签名逻辑、WebSocket 解析) | 对应 `bt_api/bt_api_` 插件仓;合并后由维护者在主仓发独立 SHA bump PR 到 `dev`(协议见 [docs/governance/submodule-bump.md](docs/governance/submodule-bump.md)) | +| 交易所注册表条目、`exchange_registers/`、错误映射 | 主仓 `dev` | +| 核心框架(`BtApi`、containers 基础类型、gateway/websocket、forwarding) | 主仓 `dev` | +| 文档、测试、示例、CI、打包配置 | 主仓 `dev` | +| `.gitmodules` / gitlink 变更 | 主仓 `dev`,须附插件仓 PR 链接与新旧 SHA | + 1. **Create a branch**: `git checkout -b feature/your-feature-name` 2. **Make your changes**: @@ -171,9 +186,9 @@ bt_api_py/ - Format code: `make format` - Check code quality: `make check` -3. **Commit your changes**: +3. **Commit your changes** (always stage explicit paths, never stage the whole tree with a bare dot): ```bash - git add . + git add bt_api_py/changed_module.py tests/test_changed_module.py git commit -m "feat: add new feature description" ``` diff --git a/README.md b/README.md index 3eae5d58..e55e6d4e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bt_api_py -[![Python 3.9-3.14](https://img.shields.io/badge/python-3.9--3.14-blue.svg)](https://www.python.org/downloads/) +[![Python 3.11-3.14](https://img.shields.io/badge/python-3.11--3.14-blue.svg)](https://www.python.org/downloads/) [![PyPI](https://img.shields.io/pypi/v/bt_api_py.svg)](https://pypi.org/project/bt_api_py/) [![Tests](https://github.com/cloudQuant/bt_api_py/actions/workflows/tests.yml/badge.svg)](https://github.com/cloudQuant/bt_api_py/actions/workflows/tests.yml) [![Docs](https://github.com/cloudQuant/bt_api_py/actions/workflows/docs.yml/badge.svg)](https://github.com/cloudQuant/bt_api_py/actions/workflows/docs.yml) @@ -83,7 +83,7 @@ Over 20 standardized container types include: - Other: `Symbol`, `Instrument`, `Liquidation`, `Greek` ### Cross-platform support -Current target compatibility is Python `3.9-3.14`; CI runs on Linux, macOS, and Windows. +Current target compatibility is Python `3.11-3.13` (release-blocking) and `3.14` (canary); CI runs on Linux, macOS, and Windows. ## Supported exchanges @@ -102,7 +102,7 @@ The full exchange support matrix is automatically refreshed in the Chinese secti | Item | Support | |------|---------| -| Python | `3.9` - `3.14` | +| Python | `3.11` - `3.13`(阻塞发布);`3.14`(canary) | | OS | Linux, macOS, Windows | | Installation | PyPI, source install | | Main APIs | REST, Async REST, WebSocket | @@ -522,7 +522,7 @@ mypy bt_api_py --ignore-missing-imports - **其他数据**: `Symbol`、`Instrument`、`Liquidation`、`Greek` ### 跨平台支持 -项目当前以 `Python 3.9-3.14` 为兼容目标,CI 覆盖 Linux、macOS 和 Windows。 +项目当前以 `Python 3.11-3.13` 为兼容目标(`3.14` 为 canary),CI 覆盖 Linux、macOS 和 Windows。 > 测试状态建议通过 `bash scripts/run_exchange_tests.sh ` 复核,当前口径更新于 2026-04-06。 @@ -558,7 +558,7 @@ mypy bt_api_py --ignore-missing-imports | 项目 | 当前支持 | |------|----------| -| Python | `3.9` - `3.14` | +| Python | `3.11` - `3.13`(阻塞发布);`3.14`(canary) | | 操作系统 | Linux, macOS, Windows | | 安装方式 | PyPI, 源码开发安装 | | 主要接口 | REST, Async REST, WebSocket | @@ -959,7 +959,7 @@ pytest tests -m "not network and not integration and not performance and not e2e ### CI 说明 - Push / Pull Request: 运行 `Quality Gates`、`Compatibility` 矩阵和 Ubuntu 完整基线测试。 -- 兼容性矩阵: Linux、macOS、Windows GitHub-hosted runner x Python `3.9` 到 `3.14`。 +- 兼容性矩阵: Linux、macOS、Windows GitHub-hosted runner x Python `3.11` 到 `3.13`(阻塞)+ `3.14`(canary)。 - Windows 说明: GitHub Actions 使用官方支持的 `windows-latest` hosted runner;项目兼容目标包含 Windows 11。 ### 需要真实账户或网络的测试 @@ -993,7 +993,7 @@ pytest tests -m ctp -v ## 常见问题 (FAQ) ### Q: 支持哪些 Python 版本? -当前兼容目标是 Python `3.9` 到 `3.14`。如果你希望和默认 CI 环境保持一致,优先使用 Python `3.11`。 +当前兼容目标是 Python `3.11` 到 `3.13`(`3.14` 为 canary,不阻塞发布)。默认 CI 环境为 Python `3.11`,推荐与之保持一致。 ### Q: 如何添加新的交易所? 请参考 [开发者指南](https://cloudquant.github.io/bt_api_py/explanation/developer_guide/),实现 `AbstractFeed` 接口并注册到 `ExchangeRegistry` 即可。基本步骤: @@ -1031,12 +1031,22 @@ pytest tests -m ctp -v 1. Fork 本仓库 2. 创建您的特性分支 (`git checkout -b feature/AmazingFeature`) -3. 提交您的更改 (`git commit -m 'Add some AmazingFeature'`) +3. 提交您的更改(只 stage 明确的文件路径,不要整树暂存) 4. 推送到分支 (`git push origin feature/AmazingFeature`) -5. 开启一个 Pull Request +5. 开启一个 Pull Request,**目标分支选择 `dev`** + +> `master` 仅接受 promotion 与 hotfix;交易所适配器变更请到对应 +> `bt_api/bt_api_*` 插件仓提 PR。路由表见 +> [docs/governance/branch-model.md](docs/governance/branch-model.md)。 详细贡献指南请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [开发者指南](https://cloudquant.github.io/bt_api_py/explanation/developer_guide/)。 +### 安全与行为准则 + +- 安全漏洞请勿开公开 issue,按 [SECURITY.md](SECURITY.md) 的私密通道报告; + **绝不在 issue/PR 中张贴 API 密钥或账户信息** +- 社区行为规范见 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) + ## 许可证 本项目采用 [MIT License](https://opensource.org/licenses/MIT) 开源许可。您可以自由使用、修改和分发本项目。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..6fa6ae4a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,74 @@ +# Security Policy(安全策略) + +**语言**: [English](#english) | [中文](#中文) + + +## Reporting a Vulnerability + +**Do NOT open a public issue for security problems. Never post API keys, +secrets, account IDs, order details, or exploit details in public.** + +### Preferred channel: GitHub Private Vulnerability Reporting + +Once enabled by the repository admin, use +**Security → Report a vulnerability** on +. + +> Status (2026-08-23): Private Vulnerability Reporting is **not yet enabled** +> on this repository (tracked by decision gate D5). Until it is enabled, use +> the email channel below. + +### Fallback channel: encrypted email + +- **Contact**: yunjinqi@gmail.com +- Include: affected version/commit, exchange or module affected + (e.g. `BINANCE___SPOT`, `forwarding`, `ctp`), impact assessment, and a + minimal reproduction. Attach proof-of-concept privately; do not paste + credentials. +- **Response SLA**: first acknowledgment within 3 business days; status update + within 10 business days. (SLA pending formal owner sign-off — see D5.) + +### Scope + +In scope: + +- Credential handling and leakage paths (API keys, tokens, session files) +- Order routing, cancellation, and idempotency flaws that could cause + unintended real-money actions +- The `bt_api_py.forwarding` gateway (authentication, authorization, + transport), including ZeroMQ endpoints +- Injection, deserialization, and SSRF issues in REST/WebSocket adapters +- Release/supply-chain integrity (PyPI publishing path) + +Out of scope: + +- Vulnerabilities in the exchanges themselves — report to the exchange +- Issues requiring leaked credentials that the user exposed themselves +- Missing features + +### Coordinated disclosure + +We ask for up to 90 days before public disclosure while a fix and release are +prepared. We credit reporters by default; tell us if you prefer to remain +anonymous. + + +## 报告漏洞(中文) + +**不要为安全问题开公开 issue。绝不在公开渠道张贴 API 密钥、账户信息、订单 +详情或可利用细节。** + +- **首选通道**:仓库管理员启用 GitHub Private Vulnerability Reporting 后, + 使用 Security → Report a vulnerability(当前状态:未启用,见决策门 D5)。 +- **备用通道**:邮件 yunjinqi@gmail.com。请包含受影响版本/提交、涉及的交易所 + 或模块、影响评估与最小复现;PoC 私下附件,不要粘贴凭据。 +- **响应承诺**:3 个工作日内首次确认;10 个工作日内给出状态更新。 +- **处理范围**:凭据处理与泄漏路径;可能导致非预期真实下单/撤单的订单路由与 + 幂等缺陷;`bt_api_py.forwarding` 网关(认证、授权、ZeroMQ 传输);适配器中的 + 注入/反序列化/SSRF;发布与供应链完整性。交易所自身的漏洞请向对应交易所报告。 + +## 历史提示 + +2026-08-23 的基线核查确认 git 历史中曾短暂提交过 `keys/` 目录下的会话密钥文件 +(详见 `docs/governance/baseline-2026-08-23.md`)。**任何从旧版本或历史检出获取 +的密钥都应视为已泄露并立即轮换。** diff --git a/docs/explanation/developer_guide.md b/docs/explanation/developer_guide.md index 659dd90c..9b15cec4 100644 --- a/docs/explanation/developer_guide.md +++ b/docs/explanation/developer_guide.md @@ -2,6 +2,14 @@ 本文档面向希望扩展 bt_api_py 的开发者,介绍如何添加新交易所、新数据容器、编写测试等。 +## 贡献流程与分支模型 + +普通贡献(新功能、Bug 修复、文档、测试)的 PR 一律以 **`dev`** 为目标分支; +`master` 仅接受 promotion 与 hotfix;交易所适配器变更在对应的 +`bt_api/bt_api_*` 插件仓进行。完整路由表、风险分级与门禁说明见 +[分支模型](../governance/branch-model.md) 与 +[CONTRIBUTING](https://github.com/cloudQuant/bt_api_py/blob/dev/CONTRIBUTING.md)。 + --- ## 开发环境搭建 diff --git a/docs/governance/README.md b/docs/governance/README.md new file mode 100644 index 00000000..47d76b90 --- /dev/null +++ b/docs/governance/README.md @@ -0,0 +1,32 @@ +# 项目治理(Project Governance) + +本目录是 bt_api_py 社区协作与发布治理的唯一权威文档集。 + +## 文档索引 + +| 文档 | 内容 | +|---|---| +| [分支模型](branch-model.md) | `dev` / `master` / `code-optimization` 角色、PR 路由表、风险分级 | +| [决策日志](decision-log.md) | 决策门 D0–D8 的状态、决策人与解除阻塞条件 | +| [基线快照](baseline-2026-08-23.md) | 迭代03实施前的脱敏事实记录(含历史凭据核查结论) | +| [发布流程](release-flow.md) | TestPyPI → tag → Release → PyPI 的受控链路 | +| [子模块升级](submodule-bump.md) | 插件仓变更进入主仓的 SHA bump 协议(pilot 三仓) | +| [指标 Schema](metrics-schema.json) | M7 每周治理摘要的数据结构定义 | + +## 快速入口 + +- 我要提常规贡献 → 目标分支 **`dev`**,先读 + [CONTRIBUTING](https://github.com/cloudQuant/bt_api_py/blob/dev/CONTRIBUTING.md) +- 我要改交易所适配器 → 去 `bt_api/bt_api_*` 对应插件仓提 PR,主仓随后收 SHA bump +- 我要报告安全漏洞 → 读根目录 + [SECURITY](https://github.com/cloudQuant/bt_api_py/blob/dev/SECURITY.md),不要开公开 issue +- 我要了解为什么这样设计 → 读迭代计划 + `docs/迭代计划/迭代03-开源项目治理与社区PR协作/正式迭代计划.md` + +> 发布演练证据存放在 `docs/governance/evidence/`(脱敏摘要)。 + +## 边界声明 + +本目录中的文档描述的是仓库内可审计的政策与自动化。GitHub 远端设置 +(默认分支、Rulesets、Environments、tag 规则)只能由管理员按决策门结论应用, +应用前后必须留存脱敏 API 摘要。CI 与验证脚本只做只读比对,永不持有管理权限。 diff --git a/docs/governance/baseline-2026-08-23.md b/docs/governance/baseline-2026-08-23.md new file mode 100644 index 00000000..4aea0494 --- /dev/null +++ b/docs/governance/baseline-2026-08-23.md @@ -0,0 +1,60 @@ +# 治理基线快照(2026-08-23) + +> 本文件是迭代03实施前的**脱敏事实快照**。原始 API 回应仅保留在管理员受控位置, +> 仓库只提交摘要。任何"已实施能力"的宣传必须能回指到本文件或更晚的证据记录。 + +## B1–B11 事实核验结果 + +| ID | 事实 | 核验方式 | 结果 | +|---|---|---|---| +| B1 | 公开仓;默认分支 `master`;远端长期分支 `master`、`code-optimization` | `gh repo view` + `git ls-remote --heads origin` | ✅ 一致(visibility=public, hasIssuesEnabled=true, hasDiscussionsEnabled=false) | +| B2 | `master` 与 `code-optimization` 无 Branch Protection,Rulesets 为空 | Rulesets / Branch Protection API | ✅ 一致(rulesets=`[]`;protection=HTTP 404 "Branch not protected") | +| B3 | `tests.yml` 触发条件含不存在的 `main`、`develop` | 文件核验 | ✅ 一致 | +| B4 | 无已跟踪 CODEOWNERS、SECURITY.md、PR 模板和 Issue Forms | `git ls-files` | ✅ 一致 | +| B5 | `docs.yml` 从 `master/main` 部署;MkDocs 编辑链接指向 `master` | workflow 与 mkdocs.yml 核验 | ✅ 一致(M1 处理) | +| B6 | `requires-python = ">=3.11"`,classifiers 到 3.13,coverage fail-under 40 | `pyproject.toml` | ✅ 一致(fail_under=40 位于第103行) | +| B7 | README/CI 宣称 Python 3.9–3.14 | README、reusable-compat-matrix.yml | ✅ 一致(M1 按 D1 移除) | +| B8 | `.gitmodules` 登记 60 个子模块 | `git config --file .gitmodules --get-regexp ... \| wc -l` | ✅ 一致(60) | +| B9 | `publish.yml` 手动入口可选 `pypi`;TestPyPI dispatch 未校验 SHA;Environments 仅 `github-pages` | workflow + Environments API | ✅ 一致(environments total_count=1) | +| B10 | `optimized-tests.yml` 有 `contents: write`/benchmark 自动推送;`docs.yml` Pages 写权限在顶层 | workflow 权限核验 | ✅ 一致(M4 处理) | +| B11 | `.env`/`keys/`/`tmp_keys/` 仅靠 `.gitignore`,无 CI secret scanning | `.gitignore` + workflow 核验 | ✅ 一致(当前工作区 0 个跟踪文件;CI 扫描在 M4 加固) | + +## 历史凭据核查(M0 步骤5,强制项) + +**命令**:`git log --all --full-history --oneline -- .env keys tmp_keys` + +### 结论:⚠️ 发现历史暴露,需要凭据轮换 + +| 项 | 内容 | +|---|---| +| 范围 | `keys/` 目录下 **102 个文件**(`key_*.key` 会话密钥 + `key_*.meta` 元数据);`.env` 与 `tmp_keys/` 从未被跟踪 | +| 引入提交 | `846c7b09`(update)、`eee5f3d4`(update) | +| 删除提交 | `07a78f8f`(fix: unify exception systems...) | +| 当前状态 | 工作区与 HEAD 均无跟踪;**对象仍可从公开仓库的 git 历史获取** | +| 必需动作(顺序不可颠倒) | ① 先轮换/吊销受影响 CTP 凭据(owner 动作,工单号:__待填__);② 再评估历史清理(history rewrite 属破坏性操作,须单独审批);③ 全程禁止把密钥内容贴进 Issue/PR/文档 | +| 记录人 | cloudQuant(AI 代理执行核查),2026-08-23 | + +> 本节不包含任何密钥内容或可复用凭据材料。轮换完成后在此回填工单号与日期。 + +## 远端设置现状摘要(只读 API,2026-08-23) + +```text +defaultBranchRef = master +visibility = public +hasIssuesEnabled = true +hasDiscussionsEnabled = false +rulesets = [] (空数组) +branches/master/protection = 404 not protected +environments = [github-pages](total_count=1) +private-vulnerability-reporting = {"enabled":false} +remote heads = master(1436ec0a) , code-optimization(f68da6c9) +``` + +## 统一口径事实源 + +| 议题 | 唯一事实源 | 值 | +|---|---|---| +| Python 支持范围 | `pyproject.toml requires-python` | >=3.11;阻塞矩阵 3.11–3.13;canary 3.14 | +| Coverage 强制线 | `pyproject.toml [tool.coverage.report] fail_under` | 40(60% 为提升目标,见 D8) | +| 子模块数量 | `git config --file .gitmodules` | 60 | +| Bootstrap SHA | `git rev-parse master` @2026-08-23 | `1436ec0adaf4b283a54bfe69f5be163df3e3e3b9` | diff --git a/docs/governance/branch-model.md b/docs/governance/branch-model.md new file mode 100644 index 00000000..2b8ce762 --- /dev/null +++ b/docs/governance/branch-model.md @@ -0,0 +1,65 @@ +# 分支模型与 PR 路由(Branch Model) + +> 状态:生效中(迭代03,2026-08-23)。本文与 `CONTRIBUTING.md`、根 `README.md` +> 的贡献章节、`.github/pull_request_template.md` 保持同一口径;发现不一致时以 +> 本文件为准并发 issue 修正。 +> +> 社区入口:Bug/Feature/Question 通过 `.github/ISSUE_TEMPLATE/` 表单提交; +> 安全问题走根目录 `SECURITY.md` 私密通道;行为规范见 `CODE_OF_CONDUCT.md`。 + +## 1. 分支角色 + +| 分支 | 角色 | 允许来源 | 禁止事项 | 门禁 | +|---|---|---|---|---| +| `dev` | 默认、日常集成 | fork / `feature/*` / 文档 / bugfix / SHA bump | 直接功能 push | PR、≥1 个非作者批准、code-owner review、Governance、Quality | +| `master` | 稳定发布线(GitHub Release / PyPI 来源) | `dev → master` promotion;`hotfix/* → master` | 常规功能直推、`code-optimization` 整线 merge | PR、2 个非作者批准 + code-owner review(第二维护者就位前不启用,见决策门 D3)、Release/Quality/Submodule summaries、禁 force push/删除 | +| `code-optimization` | 性能与架构实验线 | `perf/*` 或明确优化 PR | 无基准证据的重构、直接进 `master` | PR、≥1 批准、Governance、Quality/Performance | + +## 2. 工作流总览 + +```text +普通贡献:fork / feature/* ── PR ──> dev ── promotion PR ──> master ── Release ──> PyPI + +性能优化:perf/* ── benchmark PR ──> code-optimization ── selective PR ──> dev + +发布 hotfix:hotfix/- (from master) ── PR ──> master ── forward-port PR ──> dev + +适配器变更:plugin repository PR ──> plugin merge ──> parent SHA-bump PR ──> dev +``` + +要点: + +- 生产 PyPI 只能由受保护 `master` 可达的 tag 与 GitHub Release 触发。 +- `code-optimization` 永不整线合并进 `master`;只允许可审查、可回滚的选择性 PR 进入 `dev`。 +- 每个 `master` hotfix 必须在一个工作日内有 `dev` 前移 PR,或记录"不前移"的理由与 owner。 + +## 3. PR 路由表 + +| 变更类型 | 默认目标分支 | 必需证据 | 合并后动作 | +|---|---|---|---| +| 文档、注释、非行为性工具 | `dev` | strict docs build、受影响测试 | promotion 候选 | +| 常规功能、普通 bugfix | `dev` | 回归测试、兼容影响说明 | promotion 候选 | +| R2 核心接口/兼容性(BtApi、containers/feeds 基类、gateway/websocket/forwarding、CTP 接口) | `dev` | API 说明、目标测试、owner 审阅 | promotion 候选 | +| 性能优化 | `code-optimization` | 可复现的 benchmark 前后数据、语义不变说明 | 选择性 PR 到 `dev` | +| 发布阻断 bug / 安全修复 | `master`(hotfix) | 最小复现、回归测试、影响范围说明 | 1 个工作日内前移 `dev` | +| 交易所适配器实现 | 对应 `bt_api_*` 插件仓 | 插件仓 CI 通过、兼容说明 | 主仓独立 SHA bump PR | +| gitlink / `.gitmodules` 变更 | `dev` | 新旧 SHA、submodule 校验结果、回滚 SHA | promotion 候选 | + +## 4. 风险分级 + +| 等级 | 典型路径 | 最低评审 | +|---|---|---| +| R0 文档/测试 | `docs/`、测试注释、非行为性工具 | 1 位维护者 | +| R1 常规模块 | 单个 feed 实现、container、examples、scripts | 1 位领域 owner | +| R2 核心/兼容性 | BtApi 门面、containers 基础类型、feeds 抽象基类、gateway、websocket、forwarding、rate_limiter、CTP SWIG 接口 | 领域 owner + 复核留痕(D3 就绪后升级为双批准) | +| R3 发布/安全/供应链 | `master` hotfix、打包配置、依赖升级、publish 路径、认证与密钥处理 | 核心维护者明确批准 | + +风险标签(`risk:r0`–`risk:r3`)由 triage 维护者添加或确认;`PR Governance / Summary` +检查负责验证标签与目标分支的一致性——它们不是 Ruleset 的原生能力。 + +## 5. 平台能力边界(避免误设) + +1. `CODEOWNERS` 解决**责任归属**与 owner review 请求;同一条规则任一 owner 批准即满足, + 它不能替代"双人审批"。`master` 的双批准由 Ruleset 审批数设置承担。 +2. 标签语义(`target:*`、`release:hotfix` 等)只能由 workflow 检查,不能写进 Ruleset 期望。 +3. required check 必须在草稿 PR 的适用与不适用路径都稳定产出同名 summary 后才列入 manifest。 diff --git a/docs/governance/decision-log.md b/docs/governance/decision-log.md new file mode 100644 index 00000000..91081254 --- /dev/null +++ b/docs/governance/decision-log.md @@ -0,0 +1,32 @@ +# 治理决策日志(Decision Log) + +> 计划来源:`docs/迭代计划/迭代03-开源项目治理与社区PR协作/正式迭代计划.md`(v2) +> 记录日期:2026-08-23 +> 维护规则:每个决策门只能是 `approved` / `rejected` / `blocked` 三态之一,禁止隐式默认值。 +> 状态变更必须附决策人、日期与证据链接。 + +## 决策门状态总览 + +| ID | 议题 | 推荐值 | 决策人 | 状态 | 证据 / 阻塞原因 | +|---|---|---|---|---|---| +| D0 | 默认分支模型 | 新增 `dev` 为日常集成与默认分支;`master` 为发布线。远端切换晚于 M1 bootstrap | 管理员 + 核心维护者 | **approved** | v2 计划获实施授权(2026-08-23 用户指示按最新迭代计划开发);BOOTSTRAP_SHA=`1436ec0adaf4b283a54bfe69f5be163df3e3e3b9`;默认分支切换为管理员动作,见 M1/M6 | +| D1 | Python 兼容口径 | 3.11–3.13 为支持且阻塞发布的矩阵;3.14 为 non-blocking canary;3.9/3.10 不再宣称支持 | 维护者 + CI owner | **approved** | `pyproject.toml requires-python = ">=3.11"`(B6);README/CI 中 3.9–3.14 表述在 M1 统一移除 | +| D2 | Owner 团队真实身份 | 使用真实 GitHub 用户并确认 write 权限;禁止占位 owner | 核心维护者 | **approved(单一维护者)** | GitHub 账号 `@cloudQuant`(repo admin,gh API 核验);当前无第二位已确认维护者,CODEOWNERS 仅登记 `@cloudQuant` | +| D3 | 分支审批门槛 | `dev` ≥1 个非作者批准 + code-owner review;`master` ≥2 个非作者批准 + code-owner review | 核心维护者 | **部分 blocked** | `dev` 门槛可行;`master` 双人审批因无第二位维护者而 **blocked**——在第二维护者确认前,不得启用 master 完整 Ruleset,也不得对外宣称 master 已完整治理 | +| D4 | 发布权限与环境 | release manager、`pypi`/`testpypi` Environment、PyPI trusted publisher、`v*` tag 规则;manual dispatch 不得发布 PyPI | 发布负责人 + 管理员 | **blocked** | Environments API 仅返回 `github-pages`(2026-08-23 核验);`pypi`/`testpypi` Environment 未创建、trusted publisher 绑定未确认、tag rule 未建。全部为管理员动作,M5 只交付 workflow 侧守卫 | +| D5 | 安全通道与社区入口 | 启用 GitHub Private Vulnerability Reporting;否则私密邮箱 + SLA;Discussions 未启用前用 Question Form | 安全 + 社区负责人 | **blocked(PVR)/ approved(表单)** | PVR API 返回 `{"enabled":false}`(2026-08-23);备用邮箱 yunjinqi@gmail.com 可用但 SLA 待 owner 书面确认;`hasDiscussionsEnabled=false` → Issue Forms 提供 Bug/Feature/Question | +| D6 | 插件治理 pilot | pilot 仓:`bt_api_base`、`bt_api_binance`、`bt_api_okx`;扩大到 10 个需新决策 | 插件协调人 | **approved** | v2 计划推荐值获实施授权;仅文档协议层落地(M5),不批量改 60 个插件仓 | +| D7 | 镜像与 Merge Queue | 当前不设 Gitee 镜像;连续 4 周日均待合并 PR ≥3 或频繁基线冲突才另立 Merge Queue 项目 | 管理员 + triage owner | **approved** | 单一 origin(GitHub)现状一致(B1);无 merge_group 需求信号 | +| D8 | Coverage 口径 | 当前强制线 40%(pyproject `fail_under=40` + CI `COVERAGE_THRESHOLD=40`);60% 为独立质量提升目标,提高阈值须带测试增量与基线证据 | 质量负责人 | **approved** | `pyproject.toml:103 fail_under=40`、`.github/workflows/tests.yml:20 COVERAGE_THRESHOLD="40"`(2026-08-23 核验);release checklist 的 60% 表述已修正 | + +## 变更记录 + +| 日期 | 门 | 变更 | 决策人 | +|---|---|---|---| +| 2026-08-23 | D0–D8 | 初次记录;D3(master 部分)、D4、D5(PVR) 为 blocked | cloudQuant(依据 v2 计划实施授权) | + +## Blocked 解除条件 + +- **D3-master**:第二位维护者获得 write 权限并在 CODEOWNERS 生效分支完成一次 review drill。 +- **D4**:管理员创建 `pypi`/`testpypi` Environment、绑定 trusted publisher、建立 `v*` tag rule,并提供变更前后 API 摘要。 +- **D5-PVR**:管理员在 Settings → Security 开启 Private Vulnerability Reporting,`GET /private-vulnerability-reporting` 返回 `{"enabled":true}`。 diff --git a/docs/governance/evidence/README.md b/docs/governance/evidence/README.md new file mode 100644 index 00000000..b1708a2c --- /dev/null +++ b/docs/governance/evidence/README.md @@ -0,0 +1,34 @@ +# 治理演练证据(Governance Evidence) + +> 本目录存放迭代03(M6)端到端演练与后续运营期的**脱敏证据摘要**。 +> 状态:待填充——M6 演练由管理员与发布负责人执行后归档至此。 + +## 归档规则 + +1. 只提交脱敏摘要:不含 token、API key、私钥、PyPI token、原始私有 API + payload 或下载的发布包。原始响应仅保留在管理员受控位置。 +2. 每份证据必须可回溯:记录产生时间、执行人角色、对应决策门(D0–D8)和 + 关联 PR / workflow run URL。 +3. 文件命名:`<里程碑>-<主题>-.md`,例如 + `m6-draft-pr-drills-20260901.md`。 + +## 各里程碑应产生的证据 + +| 里程碑 | 证据 | 通过标准 | +|---|---|---| +| M1 | `dev` 创建 SHA、bootstrap merge SHA、`master → dev` 同步 PR、默认分支切换前后 API 摘要 | 链路 SHA 可串联;fork 新 PR 默认目标为 `dev` | +| M4 | 五类草稿 PR 演练(文档/R0、R2 核心、性能、hotfix、SHA bump):PR URL、head SHA、base branch、check 名称与结果 | `PR Governance / Summary`、`Tests / Quality Gate`、`Submodule Gate / Summary` 在适用与不适用路径均稳定出现 | +| M5 | TestPyPI 演练记录:candidate SHA、`expected_sha` 校验结果、新鲜环境安装命令与 smoke 结果、版本号 | TestPyPI 失败时不创建 Release;SHA 与版本可追溯 | +| M6 | 验收矩阵七维证据:分支模型、所有权(CODEOWNERS errors API)、Ruleset 与 manifest diff、CI、安全(gitleaks 记录)、发布、子模块 pilot | 全部维度与 manifest 一致;无 `Waiting for status` 卡死 | +| M7 | 每周治理指标摘要(schema 见 `docs/governance/metrics-schema.json`) | 连续 4 周满足稳定化退出条件后方可宣称流程持续运行 | + +## Ruleset 启用前置条件(再次强调) + +`.github/governance/rulesets/*.json` 中任何 `disabled` 的 Ruleset,只有在: + +1. 对应草稿 PR 演练证据归档至本目录; +2. 决策门阻塞解除(D3 双维护者、D4 发布环境等,见 decision-log.md); +3. 管理员在同一治理提交中同步翻转远端状态与 manifest 的 `enforcement` 字段; + +三者齐备后才允许置为 `active`。`scripts/ci/verify_github_governance.py` +会在 CI 中对漂移报非零退出。 diff --git a/docs/governance/metrics-schema.json b/docs/governance/metrics-schema.json new file mode 100644 index 00000000..b69eece2 --- /dev/null +++ b/docs/governance/metrics-schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/cloudQuant/bt_api_py/blob/master/docs/governance/metrics-schema.json", + "title": "bt_api_py weekly governance metrics", + "description": "Schema for the weekly governance summary produced by triage rotation (M7). One JSON document per ISO week; values are integers or null (null = not measured this week, must be explained in notes).", + "type": "object", + "required": ["week", "generated_at", "metrics"], + "additionalProperties": false, + "properties": { + "week": { + "type": "string", + "description": "ISO-8601 week, e.g. 2026-W35.", + "pattern": "^\\d{4}-W\\d{2}$" + }, + "generated_at": { + "type": "string", + "description": "ISO-8601 date the summary was generated.", + "format": "date" + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "required": [ + "prs_opened", + "pr_misroute_count", + "first_response_median_hours", + "first_substantive_review_median_hours", + "merge_cycle_median_hours", + "ci_failure_rate", + "ci_flake_count", + "bypass_events", + "unforwarded_hotfixes", + "submodule_sha_lag_count" + ], + "properties": { + "prs_opened": { "type": ["integer", "null"], "minimum": 0 }, + "pr_misroute_count": { + "type": ["integer", "null"], + "minimum": 0, + "description": "PRs initially targeted at the wrong branch per section 4.1 routing table." + }, + "first_response_median_hours": { "type": ["number", "null"], "minimum": 0 }, + "first_substantive_review_median_hours": { "type": ["number", "null"], "minimum": 0 }, + "merge_cycle_median_hours": { "type": ["number", "null"], "minimum": 0 }, + "ci_failure_rate": { + "type": ["number", "null"], + "minimum": 0, + "maximum": 1, + "description": "Failed CI runs / total CI runs on long-lived branches." + }, + "ci_flake_count": { "type": ["integer", "null"], "minimum": 0 }, + "bypass_events": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Ruleset bypasses; each must reference an issue with reason and follow-up PR." + }, + "unforwarded_hotfixes": { + "type": ["integer", "null"], + "minimum": 0, + "description": "master hotfixes older than 1 working day without a dev forward-port PR or documented exception." + }, + "submodule_sha_lag_count": { + "type": ["integer", "null"], + "minimum": 0, + "description": "D6 pilot plugin repos whose merged state is not yet reflected by a parent SHA bump." + } + } + }, + "notes": { + "type": "string", + "description": "Explanations for null values, incidents, and policy adjustments backed by this week's data." + } + } +} diff --git a/docs/governance/release-flow.md b/docs/governance/release-flow.md new file mode 100644 index 00000000..87c2d8d1 --- /dev/null +++ b/docs/governance/release-flow.md @@ -0,0 +1,55 @@ +# 发布流程(Release Flow) + +> 状态:生效中(迭代03 M5,2026-08-23)。本流程由 `.github/workflows/publish.yml` +> 机械强制。生产 PyPI 只能由受保护 `master` 可达的 tag 与 GitHub Release 触发; +> 手动 dispatch 永远无法选择 PyPI。 + +## 前置条件(决策门 D4,当前 blocked) + +发布前必须由管理员完成并留存 API 证据: + +1. 创建 `pypi` / `testpypi` GitHub Environments(当前仅有 `github-pages`)。 +2. 在 PyPI/TestPyPI 项目设置中绑定 trusted publisher(仓库、workflow 文件名、 + environment 名称)。 +3. 启用 `v*` tag Ruleset(`.github/governance/rulesets/release-tags.json`), + bypass 名单仅含 D4 确认的 release actor。 + +**D4 未解除前,TestPyPI 演练与正式发布都不得执行。** + +## 发布顺序(不可调换) + +```text +1. dev → master promotion PR 合并(或 hotfix PR 直接进入 master) + │ +2. 在目标 master SHA 上 dispatch publish.yml(expected_sha = 该 SHA) + │ workflow 校验:checkout SHA == expected_sha 且该 SHA 从 master 可达 + ▼ +3. TestPyPI 发布成功后,fresh venv 安装 bt_api_py== 冒烟通过 + │ +4. 对同一 SHA 打 vX.Y.Z tag(tag 必须与包版本一致——build job 强制校验) + │ +5. 基于 tag 创建 GitHub Release(release: published 触发 pypi environment) + │ +6. PyPI 验证:pip install bt_api_py==X.Y.Z;核对 dist-meta/SHA256SUMS.txt +``` + +任何一步失败即停止: + +| 失败点 | 动作 | +|---|---| +| expected_sha 不匹配 / 非 master 可达 | workflow 自动失败;修正输入重试 | +| TestPyPI 发布或冒烟失败 | **不创建 Release、不发布 PyPI**;在 `dev` 修复后重新 promotion;版本号已被占用时提升版本号 | +| Release 已发布但发现严重问题 | 停止后续 Release;PyPI yank + 新版本修复;留事件 issue | + +## 职责 + +- **Release manager(D4)**:执行 dispatch、创建 tag/Release、核对 SHA256SUMS。 +- **管理员**:维护 Environments、trusted publisher、tag Ruleset;每次变更前后 + 运行 M0 只读命令并存脱敏摘要。 +- **任何人**:不得把手动 dispatch 描述为"已发布生产";不得绕过 promotion 直接收 master。 + +## 审计链 + +每个发布必须能回答四个一致的问题:Git SHA 是什么?包版本是什么? +artifact SHA256 是什么?TestPyPI 冒烟记录在哪里?(证据存 +`docs/governance/evidence/` 脱敏摘要,不提交安装包与原始日志。) diff --git a/docs/governance/submodule-bump.md b/docs/governance/submodule-bump.md new file mode 100644 index 00000000..a95c6ed3 --- /dev/null +++ b/docs/governance/submodule-bump.md @@ -0,0 +1,50 @@ +# 子模块升级协议(Submodule Bump) + +> 状态:生效中(迭代03 M5,2026-08-23)。pilot 仓见决策门 D6: +> `bt_api_base`、`bt_api_binance`、`bt_api_okx`。扩大范围需新决策。 + +## 原则 + +1. **插件实现改插件仓**:交易所适配器(feed 行为、签名、WebSocket 解析)的 PR + 一律提到对应 `bt_api/bt_api_` 插件仓,走该仓自身的 CI 与评审。 +2. **主仓只收 SHA bump**:主仓不直接修改子模块内容;gitlink 变更必须以独立 + bump PR 进入 `dev`,由 `.github/workflows/submodule-tests.yml` 的 + `Submodule Gate / Summary` 机械校验。 +3. **双端证据**:每个 bump 必须同时留下"插件仓 PR 已合并"与"主仓 bump PR"两条 + 可追溯记录;缺任何一端即不完整。 +4. **hotfix 例外**:发布阻断场景允许 bump PR 直接进 `master`,但仍需全套证据, + 且 1 个工作日内前移 `dev`。 + +## Bump PR 模板字段(必填) + +| 字段 | 说明 | +|---|---| +| 插件仓 PR 链接 | 已合并的插件侧 PR URL | +| old_sha → new_sha | gitlink 新旧 40 位 SHA;`Submodule Gate` 从 diff 中机械提取并复核 | +| 兼容性说明 | 对公共接口/容器字段/行为语义的影响 | +| 回滚方式 | `git update-index --cacheinfo 160000,,` 或 revert bump commit | + +## 校验链路 + +```text +plugin repo PR merged + │ + ▼ +main repo bump PR (dev) Submodule Gate / Summary + ├─ .gitmodules/gitlink diff ──► 检测 gitlink 变更数量 + ├─ 无变更 ────────────────────► not-applicable,成功通过 + └─ 有变更 ────────────────────► recursive checkout + + bt_api/install_and_test_all.py 全量校验 + + report artifact 上传 +``` + +## Pilot 协议(D6 三仓) + +| 责任方 | 义务 | +|---|---| +| 插件维护者 | 保持插件仓 CI 绿色;合并后主动开主仓 issue 申请 bump(贴新旧 SHA) | +| 主仓 triage | 确认标签 `sha-bump-required`;核对插件仓 PR 合并状态后才接受 bump PR | +| 发布负责人 | promotion 进 master 前,确认 pilot 三仓无未处理 bump 积压(周指标 `submodule_sha_lag_count`) | + +其余 57 个子模块暂不套用本协议;其 gitlink 升级仍按普通依赖变更处理, +但同样受 `Submodule Gate` 机械校验约束。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 0ca5e77b..7dc60c40 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,7 +1,7 @@ # 发布前检查清单 -**最后更新:** 2026-03-08 -**版本:** 0.15 +**最后更新:** 2026-08-23 +**版本:** 0.15(迭代03治理修订) **适用场景:** 每次发布新版本前必须完成此清单 本文档提供完整的新版本发布前检查清单,确保发布质量、稳定性和安全性。 @@ -209,8 +209,12 @@ pytest tests/ --memray-top ### 2.5 测试覆盖率检查 +> **口径(D8,2026-08-23)**:当前强制线为 **40%**(`pyproject.toml` `fail_under = 40` +> 与 CI `COVERAGE_THRESHOLD=40` 双重强制)。**60% 是独立的质量提升目标**, +> 不是本次发布的阻塞项;提高强制阈值必须附带测试增量与基线证据后另行决策。 + - [ ] 运行 `pytest --cov` 生成覆盖率报告 -- [ ] 总覆盖率不低于60%(pyproject.toml配置) +- [ ] 总覆盖率不低于 **40%**(当前强制线,未达标则发布阻塞) - [ ] 新增代码有测试覆盖 - [ ] 关键路径有测试覆盖 - [ ] 查看 `htmlcov/index.html` 报告 @@ -226,8 +230,10 @@ open htmlcov/index.html ``` **验证标准:** -- 覆盖率 >= 60% +- 覆盖率 >= 40%(当前强制线) +- 覆盖率相对上一版本无下降(下降需在发布说明中解释) - 新功能有测试 +- (提升目标)向 60% 迈进的测试增量已记录 --- @@ -505,31 +511,21 @@ unzip -l dist/bt_api_py-0.15.1-py3-none-any.whl --- -### 6.4 发布到PyPI测试(选填) - -- [ ] 先发布到TestPyPI验证 -- [ ] 安装TestPyPI版本测试 -- [ ] 验证安装后功能正常 -- [ ] 确认无误后发布到正式PyPI - -**命令:** -```bash -# 发布到TestPyPI -twine upload --repository testpypi dist/* - -# 测试安装 -pip install --index-url https://test.pypi.org/simple/ bt_api_py +### 6.4 发布到PyPI测试(必填,受控流程) -# 测试功能 -python -c "import bt_api_py; print(bt_api_py.__version__)" +> **迭代03 起**:发布走 [docs/governance/release-flow.md](governance/release-flow.md) +> 的受控链路(`publish.yml` 机械强制),不再使用本地 `twine upload`。 -# 发布到正式PyPI -twine upload dist/* -``` +- [ ] 在目标 `master` SHA 上 dispatch `publish.yml`(填 `expected_sha`) +- [ ] TestPyPI 发布成功且 fresh venv 冒烟安装通过 +- [ ] 对**同一 SHA** 打 `vX.Y.Z` tag 并创建 GitHub Release(触发正式 PyPI) +- [ ] PyPI 安装验证:版本号正确、导入正常 +- [ ] 核对 workflow artifact 中 `dist-meta/SHA256SUMS.txt` **验证标准:** -- TestPyPI版本可安装 -- 功能正常 +- 手动 dispatch 无法选择生产 PyPI(workflow 已禁止) +- Git SHA、包版本、artifact SHA256 三者可追溯 +- TestPyPI 失败时未创建 Release --- diff --git a/mkdocs.yml b/mkdocs.yml index 7c422f9b..0c2bb427 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,7 +9,7 @@ site_url: https://cloudquant.github.io/bt_api_py/ # 仓库配置 repo_name: cloudQuant/bt_api_py repo_url: https://github.com/cloudQuant/bt_api_py -edit_uri: edit/master/docs/ +edit_uri: edit/dev/docs/ # 版权信息 copyright: Copyright © 2024 cloudQuant. MIT License. @@ -241,6 +241,14 @@ nav: - 交易所集成模式: explanation/exchange_integration_patterns.md - 开发者指南: explanation/developer_guide.md + # ── 项目治理 (Governance) ──────────────────────────────────── + - 项目治理: + - 治理总览: governance/README.md + - 分支模型与 PR 路由: governance/branch-model.md + - 决策日志: governance/decision-log.md + - 发布流程: governance/release-flow.md + - 子模块升级协议: governance/submodule-bump.md + # ── 支持与帮助 ─────────────────────────────────────────────── - 支持与帮助: - 常见问题: support/faq.md diff --git a/scripts/ci/validate_pr_governance.py b/scripts/ci/validate_pr_governance.py new file mode 100644 index 00000000..89fcc9e4 --- /dev/null +++ b/scripts/ci/validate_pr_governance.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Validate PR governance metadata against the routing table in docs/governance/branch-model.md. + +Usage: + python scripts/ci/validate_pr_governance.py --context [--strict] + +Exit codes: 0 = valid (or report-only), 1 = strict violation, 2 = input error. +Default is report-only (always exit 0, violations prefixed WARN); --strict is +enabled by maintainers after the observation period and turns FAIL into a +blocking check. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +VALID_TARGETS = {"dev", "master", "code-optimization"} +RISK_LABELS = {"risk:r0", "risk:r1", "risk:r2", "risk:r3"} +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +EVIDENCE_RE = re.compile(r"复现|repro|regression|回归|pytest|test", re.IGNORECASE) + +EXIT_OK = 0 +EXIT_VIOLATION = 1 +EXIT_INPUT_ERROR = 2 + + +def read_context(raw_path: str) -> dict[str, Any]: + if raw_path == "-": + return json.loads(sys.stdin.read()) + try: + return json.loads(Path(raw_path).read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise SystemExit(f"{EXIT_INPUT_ERROR}: file not found: {raw_path}") from exc + except json.JSONDecodeError as exc: + raise SystemExit(f"{EXIT_INPUT_ERROR}: invalid JSON: {exc}") from exc + + +def validate(context: dict[str, Any]) -> list[str]: + violations: list[str] = [] + target = context.get("target_branch") + labels = set(context.get("labels") or []) + body = context.get("body") or "" + changed = context.get("changed_files") or [] + + if target not in VALID_TARGETS: + violations.append( + f"target_branch '{target}' is not routable; expected one of {sorted(VALID_TARGETS)}" + ) + + risk_labels = labels & RISK_LABELS + if len(risk_labels) != 1: + violations.append( + f"exactly one risk: label is required, found {sorted(risk_labels) or 'none'}" + ) + + if target == "master": + missing = {"release:hotfix", "risk:r3"} - labels + if missing: + violations.append( + f"PRs targeting master are restricted to hotfix/promotion with evidence; " + f"missing labels: {sorted(missing)}" + ) + if not EVIDENCE_RE.search(body): + violations.append( + "master PR lacks reproduction/regression/test evidence in the description" + ) + + if context.get("submodules_changed"): + for key in ("old_sha", "new_sha"): + value = context.get(key) + if not value or not SHA_RE.match(str(value)): + violations.append( + f"submodule change requires a full 40-hex {key} " + "(plugin PR link and rollback SHA belong in the description)" + ) + if not changed or not any( + str(path).startswith(("bt_api/", ".gitmodules")) for path in changed + ): + violations.append( + "submodules_changed=true but no bt_api/ or .gitmodules path in changed_files" + ) + + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--context", required=True, help="PR context JSON path, or '-' for stdin") + parser.add_argument("--strict", action="store_true", help="exit non-zero on any violation") + args = parser.parse_args() + + context = read_context(args.context) + violations = validate(context) + + if not violations: + print("OK: PR metadata satisfies the governance routing table.") + return EXIT_OK + + prefix = "FAIL" if args.strict else "WARN" + for violation in violations: + print(f"{prefix}: {violation}") + if args.strict: + print(f"\n{len(violations)} governance violation(s); blocking.") + return EXIT_VIOLATION + + print("\nreport-only mode: fix the items above before merge review.") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/verify_github_governance.py b/scripts/ci/verify_github_governance.py new file mode 100644 index 00000000..0aa9226a --- /dev/null +++ b/scripts/ci/verify_github_governance.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Verify a sanitized GitHub Rulesets API summary against in-repo governance manifests. + +Usage: + python scripts/ci/verify_github_governance.py \ + --actual \ + --manifest-dir + +Exit codes: 0 = no drift, 1 = drift detected, 2 = input error. + +The --actual file is produced by an administrator from read-only API responses +(GET /repos/{owner}/{repo}/rulesets plus per-ruleset details, and optionally +GET /repos/{owner}/{repo}/codeowners/errors). It must never contain tokens or +raw private payloads. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +EXIT_OK = 0 +EXIT_DRIFT = 1 +EXIT_INPUT_ERROR = 2 + + +def load_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise SystemExit(f"{EXIT_INPUT_ERROR}: file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise SystemExit(f"{EXIT_INPUT_ERROR}: invalid JSON in {path}: {exc}") from exc + + +def find_ruleset(rulesets: list[dict[str, Any]], ref_pattern: str) -> dict[str, Any] | None: + for ruleset in rulesets: + refs = ruleset.get("includes_refs") or [] + if any( + ref == ref_pattern or ref_pattern.endswith("*") and ref.startswith(ref_pattern[:-1]) + for ref in refs + ): + return ruleset + return None + + +def rule_of_type(ruleset: dict[str, Any], rule_type: str) -> dict[str, Any] | None: + for rule in ruleset.get("rules") or []: + if rule.get("type") == rule_type: + return rule + return None + + +def branch_ref(target: str) -> str: + return target if "/" in target else f"refs/heads/{target}" + + +def check_manifest( + manifest: dict[str, Any], + ruleset: dict[str, Any] | None, + label: str, + drifts: list[str], +) -> None: + expected_enforcement = manifest.get("enforcement") + + if expected_enforcement == "disabled": + if ruleset is not None and ruleset.get("enforcement") == "active": + drifts.append( + f"{label}: ruleset is active but manifest requires disabled " + f"(pending gate: {manifest.get('pending_decision_gate', 'n/a')})" + ) + return + + if ruleset is None: + drifts.append(f"{label}: no ruleset found for {branch_ref(str(manifest['target']))}") + return + + if ruleset.get("enforcement") != expected_enforcement: + drifts.append( + f"{label}: enforcement is '{ruleset.get('enforcement')}', " + f"manifest requires '{expected_enforcement}'" + ) + + pr_rule = rule_of_type(ruleset, "pull_request") + if pr_rule is None: + drifts.append(f"{label}: pull_request rule missing") + params: dict[str, Any] = {} + else: + params = pr_rule.get("parameters") or {} + + expected_approvals = manifest.get("approvals_required") + actual_approvals = params.get("required_approving_review_count") + if expected_approvals is not None and actual_approvals != expected_approvals: + drifts.append( + f"{label}: approvals required is {actual_approvals}, manifest requires {expected_approvals}" + ) + + if manifest.get("dismiss_stale_reviews") and not params.get("dismiss_stale_reviews_on_push"): + drifts.append(f"{label}: dismiss_stale_reviews_on_push is not enabled") + + if manifest.get("require_code_owner_review") and not params.get("require_code_owner_review"): + drifts.append(f"{label}: require_code_owner_review is not enabled") + + if manifest.get("block_force_pushes") and rule_of_type(ruleset, "non_fast_forward") is None: + drifts.append(f"{label}: force pushes are not blocked (missing non_fast_forward rule)") + + if manifest.get("block_deletions") and rule_of_type(ruleset, "deletion") is None: + drifts.append(f"{label}: deletions are not blocked (missing deletion rule)") + + required_checks = manifest.get("required_checks") or [] + status_rule = rule_of_type(ruleset, "required_status_checks") + contexts: set[str] = set() + if status_rule is not None: + checks = (status_rule.get("parameters") or {}).get("required_status_checks") or [] + contexts = {check.get("context", "") for check in checks} + for context in required_checks: + if context not in contexts: + drifts.append( + f"{label}: required_status_checks is missing required check '{context}' " + f"(has: {sorted(contexts)})" + ) + + expected_bypass = manifest.get("bypass_actors") or [] + actual_bypass = ruleset.get("bypass_actors") or [] + if sorted(map(json.dumps, expected_bypass)) != sorted(map(json.dumps, actual_bypass)): + drifts.append( + f"{label}: bypass actors differ from manifest (expected {len(expected_bypass)}, " + f"found {len(actual_bypass)}); every bypass actor must be D4/D3-approved" + ) + + +def verify(actual_path: Path, manifest_dir: Path) -> list[str]: + actual = load_json(actual_path) + rulesets = actual.get("rulesets") or [] + drifts: list[str] = [] + + owners_errors = actual.get("codeowners_errors") or [] + if owners_errors: + first = owners_errors[0] + drifts.append( + f"CODEOWNERS has {len(owners_errors)} unresolved error(s), e.g. " + f"{first.get('path', '?')}:{first.get('line', '?')}: {first.get('message', '?')}" + ) + + for manifest_path in sorted(manifest_dir.glob("*.json")): + manifest = load_json(manifest_path) + if "target" not in manifest or "enforcement" not in manifest: + drifts.append(f"{manifest_path.name}: manifest lacks 'target' or 'enforcement'") + continue + ruleset = find_ruleset(rulesets, branch_ref(str(manifest["target"]))) + check_manifest(manifest, ruleset, manifest_path.name, drifts) + + return drifts + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--actual", type=Path, required=True, help=load_json.__doc__) + parser.add_argument("--manifest-dir", type=Path, required=True) + args = parser.parse_args() + + if not args.actual.is_file(): + print(f"input error: {args.actual} is not a file", file=sys.stderr) + return EXIT_INPUT_ERROR + if not args.manifest_dir.is_dir(): + print(f"input error: {args.manifest_dir} is not a directory", file=sys.stderr) + return EXIT_INPUT_ERROR + + drifts = verify(args.actual, args.manifest_dir) + if drifts: + for drift in drifts: + print(f"DRIFT: {drift}") + print(f"\n{len(drifts)} drift item(s); see docs/governance/ for remediation.") + return EXIT_DRIFT + + print("OK: GitHub state matches all governance manifests.") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/governance/pr-dev-r1.json b/tests/fixtures/governance/pr-dev-r1.json new file mode 100644 index 00000000..e50df623 --- /dev/null +++ b/tests/fixtures/governance/pr-dev-r1.json @@ -0,0 +1,13 @@ +{ + "$comment": "Ordinary R1 feature PR targeting dev. Must pass validation.", + "target_branch": "dev", + "labels": ["risk:r1", "target:dev"], + "body": "## 关联 Issue\nCloses #123\n\n已执行 pytest tests/gateway -q,全部通过。", + "changed_files": [ + "bt_api_py/gateway/config.py", + "tests/gateway/test_config.py" + ], + "submodules_changed": false, + "old_sha": null, + "new_sha": null +} diff --git a/tests/fixtures/governance/pr-master-hotfix.json b/tests/fixtures/governance/pr-master-hotfix.json new file mode 100644 index 00000000..d8ed26a0 --- /dev/null +++ b/tests/fixtures/governance/pr-master-hotfix.json @@ -0,0 +1,13 @@ +{ + "$comment": "Master hotfix PR with complete evidence: release:hotfix + risk:r3 labels and reproduction/test evidence in body. Must pass; removing labels or evidence must fail.", + "target_branch": "master", + "labels": ["risk:r3", "release:hotfix", "target:master"], + "body": "最小复现:pytest tests/gateway/test_order_idempotency.py::test_duplicate_submit -x 在 master 基线复现。\n回归测试已新增并通过。影响范围:OrderRouter 幂等去重,不改变公共 API。\n关联 Issue: Closes #456", + "changed_files": [ + "bt_api_py/forwarding/order_router.py", + "tests/test_forwarding_bus_router_client.py" + ], + "submodules_changed": false, + "old_sha": null, + "new_sha": null +} diff --git a/tests/fixtures/governance/pr-submodule-bump.json b/tests/fixtures/governance/pr-submodule-bump.json new file mode 100644 index 00000000..858dc504 --- /dev/null +++ b/tests/fixtures/governance/pr-submodule-bump.json @@ -0,0 +1,10 @@ +{ + "$comment": "Submodule SHA bump PR targeting dev. Must carry old/new gitlink SHAs; removing them must fail.", + "target_branch": "dev", + "labels": ["risk:r1", "sha-bump-required", "target:dev"], + "body": "插件仓 PR: https://github.com/cloudQuant/bt_api_binance/pull/12\n插件 CI 已通过;兼容性说明:无公共接口变化。回滚 SHA 见 old_sha。", + "changed_files": ["bt_api/bt_api_binance", ".gitmodules"], + "submodules_changed": true, + "old_sha": "3f2a1b4c5d6e7f809a1b2c3d4e5f60718293a4b5", + "new_sha": "7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f809" +} diff --git a/tests/fixtures/governance/rulesets-drifted.json b/tests/fixtures/governance/rulesets-drifted.json new file mode 100644 index 00000000..91257ec4 --- /dev/null +++ b/tests/fixtures/governance/rulesets-drifted.json @@ -0,0 +1,68 @@ +{ + "$comment": "Drifted state used by test_verify_github_governance.py: an admin applied dev and code-optimization rulesets as ACTIVE before M6 draft-PR drill evidence exists, while the in-repo manifests still require disabled; CODEOWNERS also reports an error. Both premature activations and the CODEOWNERS error must be reported as drift naming the blocking gate.", + "rulesets": [ + { + "name": "dev-integration", + "enforcement": "active", + "includes_refs": ["refs/heads/dev"], + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "required_review_thread_resolution": true + } + }, + { "type": "non_fast_forward" }, + { "type": "deletion" }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { "context": "PR Governance / Summary" }, + { "context": "Tests / Quality Gate" } + ] + } + } + ] + }, + { + "name": "code-optimization-lab", + "enforcement": "active", + "includes_refs": ["refs/heads/code-optimization"], + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "required_review_thread_resolution": false + } + }, + { "type": "non_fast_forward" }, + { "type": "deletion" }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { "context": "PR Governance / Summary" }, + { "context": "Tests / Quality Gate" } + ] + } + } + ] + } + ], + "codeowners_errors": [ + { + "path": ".github/CODEOWNERS", + "line": 5, + "message": "Could not resolve to a User with the username 'ghost-owner'." + } + ] +} diff --git a/tests/fixtures/governance/rulesets-policy-drifted.json b/tests/fixtures/governance/rulesets-policy-drifted.json new file mode 100644 index 00000000..8765ceec --- /dev/null +++ b/tests/fixtures/governance/rulesets-policy-drifted.json @@ -0,0 +1,24 @@ +{ + "$comment": "Post-M6 policy-drift state used with an activated dev manifest copy (see test_verify_github_governance.py): the dev ruleset is active but lost its required status checks, its approval count dropped to 0, and force pushes are no longer blocked. Every policy mutation must be reported as drift.", + "rulesets": [ + { + "name": "dev-integration", + "enforcement": "active", + "includes_refs": ["refs/heads/dev"], + "bypass_actors": [], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "required_review_thread_resolution": false + } + }, + { "type": "deletion" } + ] + } + ], + "codeowners_errors": [] +} diff --git a/tests/fixtures/governance/rulesets-valid.json b/tests/fixtures/governance/rulesets-valid.json new file mode 100644 index 00000000..de867e13 --- /dev/null +++ b/tests/fixtures/governance/rulesets-valid.json @@ -0,0 +1,5 @@ +{ + "$comment": "Sanitized summary of GitHub Rulesets API state that satisfies all manifests in .github/governance/rulesets/ during the observation period (plan v2 §4.2.4): no rulesets exist yet remotely (baseline B2), every manifest is disabled, and CODEOWNERS resolves cleanly. Produced by an admin from read-only API responses; never contains tokens.", + "rulesets": [], + "codeowners_errors": [] +} diff --git a/tests/test_bt_api_plugin_integration.py b/tests/test_bt_api_plugin_integration.py index 8001328d..2d2773b4 100644 --- a/tests/test_bt_api_plugin_integration.py +++ b/tests/test_bt_api_plugin_integration.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Any +import pytest + from bt_api_base.plugins.loader import PluginLoader from bt_api_base.registry import ExchangeRegistry @@ -33,7 +35,9 @@ def teardown_function() -> None: def _load_alpaca_plugin(monkeypatch) -> PluginLoader: - package_root = Path(__file__).resolve().parents[2] / "bt_api_alpaca" + package_root = Path(__file__).resolve().parents[2] / "bt_api" / "bt_api_alpaca" + if not (package_root / "bt_api_alpaca" / "plugin.py").is_file(): + pytest.skip(f"alpaca plugin checkout not present: {package_root}") monkeypatch.syspath_prepend(str(package_root)) loader = PluginLoader(ExchangeRegistry, bt_api_module._runtime_registrar) monkeypatch.setattr(loader, "_discover_entry_points", lambda group: [_FakeEntryPoint()]) diff --git a/tests/unit/scripts/test_validate_pr_governance.py b/tests/unit/scripts/test_validate_pr_governance.py new file mode 100644 index 00000000..035d22c1 --- /dev/null +++ b/tests/unit/scripts/test_validate_pr_governance.py @@ -0,0 +1,111 @@ +"""Tests for scripts/ci/validate_pr_governance.py (plan M4 step 1). + +Fixture-first: ordinary dev PRs pass; normal PRs targeting master fail; +master hotfixes without risk:r3 / release:hotfix evidence fail; submodule +changes without old/new SHA evidence fail. Report-only mode never exits +non-zero but must surface every violation. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "ci" / "validate_pr_governance.py" +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "governance" + + +def run_validator(context: dict, *, strict: bool) -> subprocess.CompletedProcess[str]: + args = [ + sys.executable, + str(SCRIPT), + "--context", + "-", + ] + if strict: + args.append("--strict") + result = subprocess.run( + args, + input=json.dumps(context), + capture_output=True, + text=True, + check=False, + ) + return result + + +def load_fixture(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def test_dev_r1_fixture_passes_strict() -> None: + result = run_validator(load_fixture("pr-dev-r1.json"), strict=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_master_hotfix_fixture_passes_strict() -> None: + result = run_validator(load_fixture("pr-master-hotfix.json"), strict=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_submodule_bump_fixture_passes_strict() -> None: + result = run_validator(load_fixture("pr-submodule-bump.json"), strict=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_normal_pr_targeting_master_fails_strict() -> None: + context = load_fixture("pr-dev-r1.json") + context["target_branch"] = "master" + result = run_validator(context, strict=True) + assert result.returncode == 1 + assert "release:hotfix" in result.stdout + + +def test_master_hotfix_without_labels_fails_strict() -> None: + context = load_fixture("pr-master-hotfix.json") + context["labels"] = ["risk:r3"] + result = run_validator(context, strict=True) + assert result.returncode == 1 + assert "release:hotfix" in result.stdout + + +def test_master_hotfix_without_repro_evidence_fails_strict() -> None: + context = load_fixture("pr-master-hotfix.json") + context["body"] = "fix typo in order router" + result = run_validator(context, strict=True) + assert result.returncode == 1 + + +def test_submodule_bump_without_sha_evidence_fails_strict() -> None: + context = load_fixture("pr-submodule-bump.json") + context["old_sha"] = None + context["new_sha"] = None + result = run_validator(context, strict=True) + assert result.returncode == 1 + assert "SHA" in result.stdout + + +def test_missing_risk_label_fails_strict() -> None: + context = load_fixture("pr-dev-r1.json") + context["labels"] = ["target:dev"] + result = run_validator(context, strict=True) + assert result.returncode == 1 + assert "risk:" in result.stdout + + +def test_report_only_mode_never_blocks_but_warns() -> None: + context = load_fixture("pr-dev-r1.json") + context["target_branch"] = "master" + result = run_validator(context, strict=False) + assert result.returncode == 0 + assert "WARN" in result.stdout + + +def test_unknown_target_branch_fails_strict() -> None: + context = load_fixture("pr-dev-r1.json") + context["target_branch"] = "feature/rogue" + result = run_validator(context, strict=True) + assert result.returncode == 1 diff --git a/tests/unit/scripts/test_verify_github_governance.py b/tests/unit/scripts/test_verify_github_governance.py new file mode 100644 index 00000000..adca06b6 --- /dev/null +++ b/tests/unit/scripts/test_verify_github_governance.py @@ -0,0 +1,128 @@ +"""Tests for scripts/ci/verify_github_governance.py (plan v2 M3 step 5). + +The verifier compares a sanitized GitHub Rulesets API summary against the +in-repo manifests under .github/governance/rulesets/. Drift must exit non-zero +and name every violated expectation. + +Two phases are covered: + +* Observation period (plan §4.2.4): every shipped manifest must be disabled; + a remotely Active ruleset without M6 evidence is drift. +* Post-M6 phase: once admins flip the manifests together with the remote + state, policy parameters (approvals, required checks, force-push block) + become verifiable. Tests simulate that phase on a temporary copy of the + manifest directory so the shipped repo state stays plan-compliant. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "ci" / "verify_github_governance.py" +MANIFEST_DIR = REPO_ROOT / ".github" / "governance" / "rulesets" +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "governance" + + +def run_verifier( + actual: Path, manifest_dir: Path = MANIFEST_DIR +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--actual", + str(actual), + "--manifest-dir", + str(manifest_dir), + ], + capture_output=True, + text=True, + check=False, + ) + + +# --- Observation period: shipped manifests must stay disabled ------------- + + +def test_valid_fixture_has_no_drift() -> None: + """Baseline B2 reality (no remote rulesets yet) matches all-manifests-disabled.""" + result = run_verifier(FIXTURES / "rulesets-valid.json") + assert result.returncode == 0, f"unexpected drift:\n{result.stdout}\n{result.stderr}" + assert "DRIFT" not in result.stdout + + +def test_manifests_are_self_consistent_json() -> None: + for manifest in sorted(MANIFEST_DIR.glob("*.json")): + data = json.loads(manifest.read_text(encoding="utf-8")) + assert "target" in data, manifest.name + assert data["enforcement"] in {"active", "disabled"}, manifest.name + + +def test_blocked_manifests_must_be_disabled() -> None: + """Any manifest gated on a decision or missing evidence may not be active.""" + for manifest in sorted(MANIFEST_DIR.glob("*.json")): + data = json.loads(manifest.read_text(encoding="utf-8")) + gated = "pending_decision_gate" in data or "activation_requires" in data + if gated: + assert data["enforcement"] == "disabled", ( + f"{manifest.name}: gated ruleset must stay disabled until its " + "activation evidence lands in docs/governance/evidence/" + ) + + +def test_premature_activation_is_reported() -> None: + """Active remote rulesets before M6 evidence are drift naming the gate.""" + result = run_verifier(FIXTURES / "rulesets-drifted.json") + assert result.returncode == 1 + assert "active but manifest requires disabled" in result.stdout + assert "dev.json" in result.stdout + assert "code-optimization.json" in result.stdout + + +def test_codeowners_errors_are_reported() -> None: + result = run_verifier(FIXTURES / "rulesets-drifted.json") + assert result.returncode == 1 + assert "CODEOWNERS" in result.stdout + + +# --- Post-M6 phase: policy drift once manifests flip to active ------------ + + +@pytest.fixture() +def activated_dev_manifest_dir(tmp_path: Path) -> Path: + """Copy the real manifests and activate only dev.json, as an admin would + after M6 drill evidence exists.""" + target = tmp_path / "manifests-active" + shutil.copytree(MANIFEST_DIR, target) + dev_manifest = target / "dev.json" + data = json.loads(dev_manifest.read_text(encoding="utf-8")) + data["enforcement"] = "active" + data.pop("activation_requires", None) + dev_manifest.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return target + + +def test_missing_required_check_is_reported(activated_dev_manifest_dir: Path) -> None: + result = run_verifier(FIXTURES / "rulesets-policy-drifted.json", activated_dev_manifest_dir) + assert result.returncode == 1 + assert "required_status_checks" in result.stdout + assert "PR Governance / Summary" in result.stdout + + +def test_wrong_approval_count_is_reported(activated_dev_manifest_dir: Path) -> None: + result = run_verifier(FIXTURES / "rulesets-policy-drifted.json", activated_dev_manifest_dir) + assert result.returncode == 1 + assert "approvals" in result.stdout + + +def test_unblocked_force_push_is_reported(activated_dev_manifest_dir: Path) -> None: + result = run_verifier(FIXTURES / "rulesets-policy-drifted.json", activated_dev_manifest_dir) + assert result.returncode == 1 + assert "non_fast_forward" in result.stdout