diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ba6d9d43..04cda8bf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,7 @@ - 未执行及原因: - 最新提交的远程合并门禁: - [ ] 本地定向验证已记录;未将其表述为 CI 或发布验收完成 -- [ ] 合并前使用适用的 `ci:light` 或 `ci:milestone`;代码和开发规则改动不用 `ci:light` +- [ ] 核对自动选择的 PR 检查及当前提交的 `Required Gate`;需要完整验收时使用 `ci:milestone`,CI 治理变更走 Bootstrap ## 风险与后续 diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 4480825e..91a1f810 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,54 +1,14 @@ name: Backend CI on: - pull_request: - types: [labeled] - paths: - - "backend/**" - - "skills/**" - - "scripts/smoke_s3.py" - - "scripts/smoke_s4.py" - - "scripts/smoke_s5.py" - - "scripts/smoke_s6.py" - - "scripts/smoke_s7.py" - - "scripts/smoke_s8.py" - - "scripts/smoke_s9.py" - - "scripts/smoke_s10.py" - - "scripts/smoke_s11.py" - - "scripts/smoke_s18.py" - - "scripts/smoke_s19.py" - - "scripts/smoke_s21.py" - - "scripts/smoke_s22.py" - - "scripts/smoke_s23.py" - - "scripts/smoke_s24.py" - - "scripts/smoke_s25.py" - - "scripts/smoke_s26.py" - - "scripts/smoke_s27.py" - - "scripts/smoke_s28.py" - - "scripts/smoke_s29.py" - - "scripts/smoke_s30.py" - - "scripts/smoke_s47.py" - - "scripts/verify_s47_2_contract_migration.py" - - "scripts/verify_s47_3_contract_migration.py" - - "scripts/verify_kafka_compat.py" - - "scripts/storage_transfer.py" - - "scripts/capacity_s11.py" - - "scripts/capacity_workflow.py" - - "scripts/capacity_s19.py" - - "scripts/capacity_s29.py" - - ".github/workflows/backend-ci.yml" + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: test: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -73,12 +33,7 @@ jobs: - name: Test working-directory: backend run: uv run pytest - - name: Dependency audit - working-directory: backend - run: uv run pip-audit - integration: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.github/workflows/ci-bootstrap-validation.yml b/.github/workflows/ci-bootstrap-validation.yml new file mode 100644 index 00000000..e7a1b9eb --- /dev/null +++ b/.github/workflows/ci-bootstrap-validation.yml @@ -0,0 +1,45 @@ +name: CI Bootstrap Validation + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "AGENTS.md" + - "CONTRIBUTING.md" + - "Makefile" + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/workflows/**" + - ".github/actions/**" + - "docs/ci-milestone.md" + - "docs/development-efficiency-phase3.md" + - "backend/tests/test_ci_plan.py" + - "backend/tests/test_required_gate.py" + - "scripts/build_skill_evaluation.py" + - "scripts/ci_plan.py" + - "scripts/ci_summary.py" + - "scripts/required_gate.py" + - "scripts/run_targeted_tests.py" + +# Governance changes cannot pass the normal Required Gate. This separate, +# PR-triggered full suite supplies eligible checks for maintainer Bootstrap. +# It never publishes the Required Gate status or changes the branch ruleset. +permissions: + contents: read + +jobs: + bootstrap-quick: + uses: ./.github/workflows/quick-ci.yml + bootstrap-backend: + uses: ./.github/workflows/backend-ci.yml + bootstrap-frontend: + uses: ./.github/workflows/frontend-ci.yml + bootstrap-compose: + uses: ./.github/workflows/compose-ci.yml + bootstrap-security: + uses: ./.github/workflows/security-ci.yml + bootstrap-windows: + uses: ./.github/workflows/standalone-windows.yml + bootstrap-upgrade: + uses: ./.github/workflows/upgrade-ci.yml + bootstrap-skills: + uses: ./.github/workflows/skills-ci.yml diff --git a/.github/workflows/compose-ci.yml b/.github/workflows/compose-ci.yml index 5486f96e..ee466f05 100644 --- a/.github/workflows/compose-ci.yml +++ b/.github/workflows/compose-ci.yml @@ -1,49 +1,7 @@ name: Compose Smoke Test on: - pull_request: - types: [labeled] - paths: - - "backend/**" - - "frontend/**" - - "mock-target/**" - - "compose.yaml" - - "deploy/compact/**" - - "deploy/compatibility/**" - - "deploy/s47/**" - - "scripts/smoke_s3.py" - - "scripts/smoke_s4.py" - - "scripts/smoke_s5.py" - - "scripts/smoke_s6.py" - - "scripts/smoke_s7.py" - - "scripts/smoke_s8.py" - - "scripts/smoke_s9.py" - - "scripts/smoke_s10.py" - - "scripts/smoke_s11.py" - - "scripts/smoke_s18.py" - - "scripts/smoke_s19.py" - - "scripts/smoke_s21.py" - - "scripts/smoke_s22.py" - - "scripts/smoke_s23.py" - - "scripts/smoke_s24.py" - - "scripts/smoke_s25.py" - - "scripts/smoke_s26.py" - - "scripts/smoke_s27.py" - - "scripts/smoke_s28.py" - - "scripts/smoke_s29.py" - - "scripts/smoke_s30.py" - - "scripts/smoke_s31.py" - - "scripts/smoke_s32.py" - - "scripts/smoke_s47.py" - - "scripts/verify_kafka_compat.py" - - "scripts/storage_transfer.py" - - "scripts/capacity_s11.py" - - "scripts/capacity_workflow.py" - - "scripts/capacity_s19.py" - - "scripts/capacity_s29.py" - - "scripts/backup.sh" - - "scripts/verify_restore.sh" - - ".github/workflows/compose-ci.yml" + workflow_call: workflow_dispatch: inputs: run_rc_gates: @@ -52,10 +10,6 @@ on: default: false type: boolean -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read @@ -219,7 +173,6 @@ jobs: run: docker compose --env-file deploy/compact/.env -f deploy/compact/compose.yaml down --volumes smoke: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest timeout-minutes: 60 env: diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 547de386..ee2bf842 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,23 +1,14 @@ name: Frontend CI on: - pull_request: - types: [labeled] - paths: - - "frontend/**" - - ".github/workflows/frontend-ci.yml" + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: build: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -41,6 +32,3 @@ jobs: - name: Build working-directory: frontend run: pnpm build - - name: Dependency audit - working-directory: frontend - run: pnpm audit --audit-level high diff --git a/.github/workflows/full-regression.yml b/.github/workflows/full-regression.yml new file mode 100644 index 00000000..c3c8e0d7 --- /dev/null +++ b/.github/workflows/full-regression.yml @@ -0,0 +1,29 @@ +name: Full Regression + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1" + +permissions: + contents: read + +concurrency: + group: full-regression-${{ github.ref }} + cancel-in-progress: false + +jobs: + backend: + uses: ./.github/workflows/backend-ci.yml + frontend: + uses: ./.github/workflows/frontend-ci.yml + compose: + uses: ./.github/workflows/compose-ci.yml + security: + uses: ./.github/workflows/security-ci.yml + windows: + uses: ./.github/workflows/standalone-windows.yml + upgrade: + uses: ./.github/workflows/upgrade-ci.yml + skills: + uses: ./.github/workflows/skills-ci.yml diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 00000000..3e114fb4 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,159 @@ +name: PR Validation +run-name: ${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name != 'ci:light' && github.event.label.name != 'ci:milestone' && 'PR Validation ignored' || github.event.action == 'edited' && !github.event.changes.base && 'PR Validation ignored' || 'PR Validation active' }} + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled, edited] + +# Label runs have their own group: an unrelated label event must not cancel a valid run. +concurrency: + group: pr-validation-${{ github.event.pull_request.number }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled' || github.event.action == 'edited') && github.run_id || 'head' }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + plan: + name: CI Plan ${{ github.sha }} + if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'edited' || github.event.label.name == 'ci:light' || github.event.label.name == 'ci:milestone' || github.event.changes.base }} + runs-on: ubuntu-latest + outputs: + plan: ${{ steps.select.outputs.plan }} + fingerprint: ${{ steps.select.outputs.fingerprint }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - id: select + name: Select checks from complete PR metadata + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TESTED_SHA: ${{ github.sha }} + run: >- + python3 -I scripts/ci_plan.py --pr "$PR_NUMBER" + --expected-base "$BASE_SHA" --expected-head "$HEAD_SHA" + --tested-sha "$TESTED_SHA" --output "$GITHUB_OUTPUT" + + quick: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'quick') }} + uses: ./.github/workflows/quick-ci.yml + backend-standard: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'backend-standard') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.13.15" + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.32" + enable-cache: true + cache-dependency-glob: backend/uv.lock + - name: Install dependencies + working-directory: backend + run: uv sync --locked --extra dev + - name: Static checks + working-directory: backend + run: | + uv run ruff format --check . ../scripts/*.py + uv run ruff check . ../scripts/*.py + uv run mypy app ../scripts/*.py + uv run lint-imports + - name: Affected tests (full side when mapping is unknown) + working-directory: backend + env: + TARGETS: ${{ toJSON(fromJSON(needs.plan.outputs.plan).backend_targets) }} + run: | + if [[ "$TARGETS" == '[]' ]]; then + uv run pytest --no-cov + else + python3 ../scripts/run_targeted_tests.py backend + fi + + backend-full: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'backend-full') }} + uses: ./.github/workflows/backend-ci.yml + frontend-standard: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-standard') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + - uses: pnpm/action-setup@f520eceda224fe1a4aed5a2a27a194379a409996 # v6 + with: + version: 11.16.0 + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Static checks and build + working-directory: frontend + run: | + pnpm format:check + pnpm lint + pnpm build + - name: Affected tests (full side when mapping is unknown) + working-directory: frontend + env: + TARGETS: ${{ toJSON(fromJSON(needs.plan.outputs.plan).frontend_targets) }} + run: | + if [[ "$TARGETS" == '[]' ]]; then + pnpm test + else + python3 ../scripts/run_targeted_tests.py frontend + fi + + frontend-full: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'frontend-full') }} + uses: ./.github/workflows/frontend-ci.yml + compose: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'compose') }} + uses: ./.github/workflows/compose-ci.yml + security: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'security') }} + uses: ./.github/workflows/security-ci.yml + windows: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'windows') }} + uses: ./.github/workflows/standalone-windows.yml + upgrade: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'upgrade') }} + uses: ./.github/workflows/upgrade-ci.yml + skills: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.plan).required, 'skills') }} + uses: ./.github/workflows/skills-ci.yml + + shadow: + name: PR Validation Shadow ${{ github.sha }} ${{ needs.plan.outputs.fingerprint }} + if: ${{ always() && (github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'edited' || github.event.label.name == 'ci:light' || github.event.label.name == 'ci:milestone' || github.event.changes.base) }} + needs: [plan, quick, backend-standard, backend-full, frontend-standard, frontend-full, compose, security, windows, upgrade, skills] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Summarize this run without publishing the required context + env: + CI_PLAN: ${{ needs.plan.outputs.plan }} + CI_NEEDS: ${{ toJSON(needs) }} + run: python3 -I scripts/ci_summary.py diff --git a/.github/workflows/quick-ci.yml b/.github/workflows/quick-ci.yml index 597e8ca8..ef3f53d6 100644 --- a/.github/workflows/quick-ci.yml +++ b/.github/workflows/quick-ci.yml @@ -1,20 +1,14 @@ name: Quick CI on: - pull_request: - types: [labeled] + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: quick: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:light' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/required-gate.yml b/.github/workflows/required-gate.yml index 4e2296c3..9c6c1b61 100644 --- a/.github/workflows/required-gate.yml +++ b/.github/workflows/required-gate.yml @@ -1,13 +1,9 @@ -name: Required Gate Controller +name: Required Gate Publisher on: - pull_request_target: - types: [labeled] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + workflow_run: + workflows: [PR Validation] + types: [requested, in_progress, completed] permissions: actions: read @@ -16,118 +12,18 @@ permissions: statuses: write jobs: - controller: - if: ${{ github.event_name != 'pull_request_target' || github.event.label.name == 'ci:milestone' || github.event.label.name == 'ci:light' }} - name: Required Gate Controller + publisher: + name: Required Gate Publisher runs-on: ubuntu-latest - timeout-minutes: 100 + timeout-minutes: 5 steps: - - name: Create trusted Required Gate status - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: | - details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - gh api --method POST \ - "repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \ - -f state='pending' \ - -f context='Required Gate' \ - -f description='等待路径选择的子门禁' \ - -f target_url="${details_url}" + # workflow_run executes the trusted default branch. No PR checkout, + # artifacts, caches, build, or test run in this job. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - fetch-depth: 0 + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - ref: ${{ github.event.pull_request.base.sha || github.sha }} - - name: Resolve current CI mode - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - mode='full' - if [[ -n "${PR_NUMBER}" ]]; then - mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq 'if (([.labels[].name] | index("ci:milestone")) and ([.labels[].name] | index("ci:light"))) then "invalid" elif ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" - fi - if [[ "${mode}" == 'invalid' ]]; then - echo '当前 PR 缺少 CI 模式标签' >&2 - exit 1 - fi - echo "FLOWTEST_GATE_MODE=${mode}" >>"${GITHUB_ENV}" - - name: Resolve changed paths from trusted metadata + - name: Recompute the trusted plan and publish the one required status env: - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - changed_paths="${RUNNER_TEMP}/flowtest-required-gate-paths.txt" - if [[ -n "${PR_NUMBER}" ]]; then - gh api --paginate \ - "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ - --jq '.[] | (.filename, .previous_filename) | select(. != null)' \ - | sort -u >"${changed_paths}" - elif [[ -z "${BASE_SHA}" || "${BASE_SHA}" =~ ^0+$ ]]; then - git show --pretty=format: --name-only "${HEAD_SHA}" | sed '/^$/d' >"${changed_paths}" - else - git diff --no-ext-diff --no-textconv --name-only \ - "${BASE_SHA}" "${HEAD_SHA}" | sed '/^$/d' >"${changed_paths}" - fi - if [[ "$(wc -l <"${changed_paths}")" -ge 3000 ]]; then - echo '变更文件数达到 GitHub API 上限,Required Gate 拒绝继续' >&2 - exit 1 - fi - echo "FLOWTEST_GATE_PATHS_FILE=${changed_paths}" >>"${GITHUB_ENV}" - - id: gate - name: Wait for path-selected child gates - continue-on-error: true - env: - FLOWTEST_GATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} GITHUB_TOKEN: ${{ github.token }} - run: >- - python3 scripts/required_gate.py - --sha "${FLOWTEST_GATE_SHA}" - --paths-file "${FLOWTEST_GATE_PATHS_FILE}" - --event-name "${GITHUB_EVENT_NAME}" - --mode "${FLOWTEST_GATE_MODE}" - - name: Complete trusted Required Gate status - if: always() - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - GATE_OUTCOME: ${{ steps.gate.outcome }} - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - state='failure' - description='路径选择的子门禁失败' - metadata_current='true' - if [[ -n "${PR_NUMBER}" ]]; then - current_refs="$(gh api \ - "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq '[.base.sha, .head.sha] | join(" ")')" - if [[ "${current_refs}" != "${BASE_SHA} ${HEAD_SHA}" ]]; then - metadata_current='false' - description='PR 基线或 Head 已变更' - fi - current_mode="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq 'if (([.labels[].name] | index("ci:milestone")) and ([.labels[].name] | index("ci:light"))) then "invalid" elif ([.labels[].name] | index("ci:milestone")) then "full" elif ([.labels[].name] | index("ci:light")) then "light" else "invalid" end')" - if [[ "${current_mode}" != "${FLOWTEST_GATE_MODE}" ]]; then - metadata_current='false' - description='PR 的 CI 模式标签已变更' - fi - fi - if [[ "${GATE_OUTCOME}" == 'success' && "${metadata_current}" == 'true' ]]; then - state='success' - description='路径选择的子门禁全部成功' - fi - details_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - gh api --method POST \ - "repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \ - -f state="${state}" \ - -f context='Required Gate' \ - -f description="${description}" \ - -f target_url="${details_url}" - if [[ "${state}" != 'success' ]]; then - exit 1 - fi + run: python3 scripts/required_gate.py diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml index 23230363..8a999bcc 100644 --- a/.github/workflows/security-ci.yml +++ b/.github/workflows/security-ci.yml @@ -1,20 +1,14 @@ name: Security CI on: - pull_request: - types: [labeled] + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: source-and-images: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest timeout-minutes: 35 steps: diff --git a/.github/workflows/skills-ci.yml b/.github/workflows/skills-ci.yml new file mode 100644 index 00000000..9388dc2b --- /dev/null +++ b/.github/workflows/skills-ci.yml @@ -0,0 +1,27 @@ +name: Skills CI + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + evaluate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.13.15" + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.32" + - name: Skill contract and evaluation + run: | + uv sync --project backend --locked --extra dev + uv run --project backend pytest --no-cov backend/tests/test_s56_core_rc.py backend/tests/test_s60_skill_evaluation.py + uv run --project backend python scripts/build_skill_evaluation.py --check diff --git a/.github/workflows/standalone-windows.yml b/.github/workflows/standalone-windows.yml index d995f255..ec409fc5 100644 --- a/.github/workflows/standalone-windows.yml +++ b/.github/workflows/standalone-windows.yml @@ -1,26 +1,14 @@ name: Standalone Windows Bundle on: - pull_request: - types: [labeled] - paths: - - ".github/workflows/standalone-windows.yml" - - "backend/**" - - "frontend/**" - - "deploy/standalone/**" - - "scripts/storage_transfer.py" + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: bundle: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: windows-latest timeout-minutes: 30 steps: diff --git a/.github/workflows/upgrade-ci.yml b/.github/workflows/upgrade-ci.yml index b27b32ad..c2925599 100644 --- a/.github/workflows/upgrade-ci.yml +++ b/.github/workflows/upgrade-ci.yml @@ -1,29 +1,14 @@ name: V2 to V3 Upgrade CI on: - pull_request: - types: [labeled] - paths: - - ".github/workflows/upgrade-ci.yml" - - "backend/**" - - "deploy/upgrade/**" - - "mock-target/**" - - "scripts/smoke_s11.py" - - "scripts/storage_transfer.py" - - "scripts/verify_v2_v3_data.py" - - "scripts/verify_v2_v3_upgrade.sh" + workflow_call: workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: rehearse-v2-to-v3-upgrade-and-rollback: - if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }} runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/AGENTS.md b/AGENTS.md index 8119a708..59115faf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,4 +48,4 @@ - Distinguish local targeted validation, remote merge gates for the latest commit, and full release acceptance. - Report changed files, checks actually run, checks not run, and remaining risks. Never infer a green CI gate or release readiness from a local targeted pass. -- Use `ci:light` only for its allowed documentation scope. Code and development-rule changes require the applicable `ci:milestone` or controlled governance path; do not bypass branch protection or required checks. +- Ordinary PRs receive automatic docs, standard, or integration checks from the trusted path/risk plan. `ci:light` cannot lower that floor; `ci:milestone` requests full PR acceptance. Governance files require controlled Bootstrap review. The formal `Required Gate` remains a trusted Commit Status; manual and periodic runs do not grant PR merge success. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1916559..fb24051e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,8 @@ make test-frontend-targeted TARGETS='["src/lib/api.test.ts"]' 完整本地前后端检查仍使用 `make check`。Compose Playwright、跨部署形态、历史升级和发布验收按变更风险及合并/发布门禁执行;不要把每次编辑都当作发布验收。CI 治理调整由独立授权的变更处理,不使用 `ci:light` 规避代码门禁。 +普通 PR 在新门禁激活后自动获得按路径和风险选择的检查;追加提交会验证新版本。`ci:light` 不能降低最低档位,`ci:milestone` 请求完整验收,双标签按较重要求。合并依据当前提交的受信 `Required Gate`,手动工作流和周期结果仅供诊断/回归。当前激活状态与操作边界见 [第三阶段 CI 说明](docs/development-efficiency-phase3.md)。 + 涉及数据库模型的变更必须包含 Alembic 迁移;涉及 API 契约的变更必须更新测试和 OpenAPI 示例。 ## 完成定义 diff --git a/Makefile b/Makefile index 6b642da5..04375415 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev-backend dev-frontend test test-backend-targeted test-frontend-targeted test-integration lint format check up down logs +.PHONY: help install dev-backend dev-frontend test test-backend-targeted test-frontend-targeted test-ci-routing test-integration lint format check up down logs help: @echo "make install Install backend and frontend dependencies" @@ -6,6 +6,8 @@ help: @echo "make dev-frontend Run Vite development server" @echo "make test-backend-targeted TARGETS='[\"tests/test_imports_api.py\"]'" @echo "make test-frontend-targeted TARGETS='[\"src/lib/api.test.ts\"]'" + @echo "make test-ci-routing Run local CI plan and gate regression tests" + @echo "PR checks route automatically after trusted CI activation; ci:milestone requests full acceptance" @echo "make check Run backend tests/lint and frontend checks" install: @@ -27,6 +29,9 @@ test-backend-targeted: test-frontend-targeted: @python3 scripts/run_targeted_tests.py frontend +test-ci-routing: + cd backend && uv run pytest --no-cov tests/test_ci_plan.py tests/test_required_gate.py + test-integration: docker compose up -d postgres redis minio cd backend && uv run pytest -m integration diff --git a/backend/tests/test_ci_plan.py b/backend/tests/test_ci_plan.py new file mode 100644 index 00000000..a8e743dc --- /dev/null +++ b/backend/tests/test_ci_plan.py @@ -0,0 +1,303 @@ +"""Characterize the trusted, deterministic PR routing policy.""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest +import yaml + +MODULE_PATH = Path(__file__).parents[2] / "scripts/ci_plan.py" +SPEC = importlib.util.spec_from_file_location("ci_plan", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +ci_plan: ModuleType = importlib.util.module_from_spec(SPEC) + +sys.modules[SPEC.name] = ci_plan +SPEC.loader.exec_module(ci_plan) +SHA = "a" * 40 + + +def plan(paths: list[str], labels: set[str] | None = None) -> Any: + return ci_plan.build_plan(paths, labels or set(), base_sha=SHA, head_sha=SHA, tested_sha=SHA) + + +@pytest.mark.parametrize( + ("path", "tier", "required"), + [ + ("README.md", "docs", {"quick"}), + ("docs/release/s46-ga-gate.md", "standard", {"backend-standard"}), + ("frontend/src/features/navigation/ShellSidebar.tsx", "standard", {"frontend-standard"}), + ("backend/app/services/imports.py", "standard", {"backend-standard"}), + ("backend/app/domain/execution.py", "integration", {"backend-full", "compose"}), + ("backend/app/core/security.py", "integration", {"backend-full", "compose"}), + ("backend/alembic/versions/123.py", "integration", {"upgrade", "windows"}), + ("backend/uv.lock", "integration", {"security", "compose"}), + ("deploy/standalone/mcp.ps1", "integration", {"windows"}), + ("deploy/upgrade/verify.sh", "integration", {"upgrade", "compose"}), + ("deploy/compact/start.sh", "integration", {"compact", "compose"}), + ("skills/flowtest-generate-integration-flow/SKILL.md", "standard", {"skills"}), + (".github/workflows/required-gate.yml", "full", {"policy", "backend-full"}), + ("scripts/build_skill_evaluation.py", "full", {"policy", "skills"}), + ("new-area/输入 文档.md", "full", {"security", "windows"}), + ], +) +def test_routing(path: str, tier: str, required: set[str]) -> None: + result = plan([path]) + assert result.tier_floor == tier + assert required <= set(result.required) + + +@pytest.mark.parametrize( + "path", + [ + "backend/app/services/auth.py", + "backend/app/services/oidc.py", + "backend/app/http/oidc.py", + "backend/app/api/dependencies.py", + "backend/app/core/context.py", + "backend/app/services/organizations.py", + "backend/app/repositories/access.py", + ], +) +def test_identity_boundaries_require_integration_consumers(path: str) -> None: + selected = plan([path]) + assert selected.tier == "integration" + assert {"backend-full", "frontend-full", "compose", "security"} <= set(selected.required) + + +def test_policy_entrypoints_use_isolated_python() -> None: + workflow = (Path(__file__).parents[2] / ".github/workflows/pr-validation.yml").read_text() + assert "python3 -I scripts/ci_plan.py" in workflow + assert "python3 -I scripts/ci_summary.py" in workflow + + +def test_isolated_planner_ignores_adjacent_module(tmp_path: Path) -> None: + shutil.copyfile(MODULE_PATH, tmp_path / "ci_plan.py") + (tmp_path / "hashlib.py").write_text("raise RuntimeError('adjacent module loaded')\n") + result = subprocess.run( + [sys.executable, "-I", str(tmp_path / "ci_plan.py"), "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_labels_only_raise_scope() -> None: + code = ["backend/app/domain/execution.py"] + assert plan(code, {"ci:light"}).tier == "integration" + full = plan(["README.md"], {"ci:light", "ci:milestone"}) + assert full.tier == "full" + assert "security" in full.required + assert plan(["README.md"], {"unrelated"}).tier == "docs" + assert plan(["README.md"], {"ci:milestone"}).tier == "full" + assert plan(["README.md"]).tier == "docs" + assert len(plan(["README.md"]).policy_digest) == 64 + + +def test_targets_include_direct_consumers_and_fallback() -> None: + targeted = plan(["frontend/src/features/navigation/navigation-config.tsx"]) + assert "src/features/navigation/ShellSidebar.test.tsx" in targeted.frontend_targets + assert "src/App.test.tsx" in targeted.frontend_targets + assert ( + "src/App.test.tsx" + in plan(["frontend/src/features/navigation/ShellSidebar.tsx"]).frontend_targets + ) + assert plan(["backend/app/services/imports.py"]).backend_targets == ( + "tests/test_imports_api.py", + ) + assert plan(["docs/release/s46-ga-gate.md"]).backend_targets == ("tests/test_s46_ga_gate.py",) + assert plan(["backend/app/services/new_service.py"]).backend_targets == () + + +def test_rename_considers_old_and_new_paths() -> None: + result = plan(["docs/说明.md", "backend/app/domain/旧 引擎.py"]) + assert result.tier == "integration" + assert "backend-full" in result.required + + +def test_special_filenames_remain_data() -> None: + result = plan(["docs/中文 空格\n$(date).md"]) + assert result.tier == "docs" + assert json.loads(result.to_json())["tier"] == "docs" + + +@pytest.mark.parametrize("paths", [[], [""], ["../README.md"], ["/tmp/file"], ["nul\x00path"]]) +def test_invalid_paths_fail_closed(paths: list[str]) -> None: + with pytest.raises(ci_plan.PlanError): + plan(paths) + + +class StubGitHub(ci_plan.GitHub): + def __init__(self, pages: dict[str, Any]) -> None: + self.pages = pages + + def get(self, path: str) -> Any: + return self.pages[path] + + +def pr_payload(count: int) -> dict[str, Any]: + return { + "state": "open", + "changed_files": count, + "base": {"sha": SHA}, + "head": {"sha": SHA}, + "labels": [], + } + + +def test_api_pagination_and_rename() -> None: + first = [{"filename": f"docs/{index}.md", "status": "modified"} for index in range(100)] + client = StubGitHub( + { + "pulls/7": pr_payload(101), + "pulls/7/files?per_page=100&page=1": first, + "pulls/7/files?per_page=100&page=2": [ + { + "filename": "docs/新 名称.md", + "previous_filename": "backend/app/domain/旧.py", + "status": "renamed", + } + ], + } + ) + assert client.pr_plan(7, SHA).tier == "integration" + + +@pytest.mark.parametrize( + "changed_count,files", [(0, []), (2, [{"filename": "README.md"}]), (3000, [])] +) +def test_incomplete_api_list_fails(changed_count: int, files: list[dict[str, str]]) -> None: + client = StubGitHub( + {"pulls/7": pr_payload(changed_count), "pulls/7/files?per_page=100&page=1": files} + ) + with pytest.raises(ci_plan.PlanError): + client.pr_plan(7, SHA) + + +def test_rename_without_previous_filename_fails() -> None: + client = StubGitHub( + { + "pulls/7": pr_payload(1), + "pulls/7/files?per_page=100&page=1": [{"filename": "docs/new.md", "status": "renamed"}], + } + ) + with pytest.raises(ci_plan.PlanError): + client.pr_plan(7, SHA) + + +def test_pagination_failure_cannot_become_docs() -> None: + class FailingGitHub(StubGitHub): + def get(self, path: str) -> Any: + if path.endswith("page=2"): + raise ci_plan.PlanError("page 2 unavailable") + return super().get(path) + + first = [{"filename": f"docs/{index}.md"} for index in range(100)] + client = FailingGitHub({"pulls/7": pr_payload(101), "pulls/7/files?per_page=100&page=1": first}) + with pytest.raises(ci_plan.PlanError): + client.pr_plan(7, SHA) + + +def test_changed_pr_snapshot_cannot_mix_file_versions() -> None: + class MovingGitHub(StubGitHub): + calls = 0 + + def get(self, path: str) -> Any: + if path == "pulls/7": + self.calls += 1 + if self.calls == 2: + return {**pr_payload(1), "head": {"sha": "b" * 40}} + return super().get(path) + + client = MovingGitHub( + {"pulls/7": pr_payload(1), "pulls/7/files?per_page=100&page=1": [{"filename": "README.md"}]} + ) + with pytest.raises(ci_plan.PlanError): + client.pr_plan(7, SHA) + + +def test_irrelevant_pr_metadata_change_does_not_invalidate_plan() -> None: + original = { + **pr_payload(1), + "base": {"sha": SHA, "repo": {"full_name": "a3384379/FlowTest", "pushed_at": "old"}}, + "labels": [{"name": "ci:milestone"}, {"name": "reviewed"}], + } + + class MovingGitHub(StubGitHub): + calls = 0 + + def get(self, path: str) -> Any: + if path == "pulls/7": + self.calls += 1 + if self.calls == 2: + return { + **original, + "base": { + "sha": SHA, + "repo": {"full_name": "a3384379/FlowTest", "pushed_at": "new"}, + }, + "labels": [{"name": "ci:milestone"}, {"name": "unrelated"}], + } + return super().get(path) + + client = MovingGitHub( + {"pulls/7": original, "pulls/7/files?per_page=100&page=1": [{"filename": "README.md"}]} + ) + assert client.pr_plan(7, SHA).tier == "full" + + +def test_automatic_pr_entries_and_workflow_call_reuse() -> None: + root = Path(__file__).parents[2] / ".github/workflows" + workflows = { + path.name: yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + for path in root.glob("*.yml") + } + automatic = [name for name, body in workflows.items() if "pull_request" in body["on"]] + assert set(automatic) == {"pr-validation.yml", "ci-bootstrap-validation.yml"} + assert "paths" not in workflows["pr-validation.yml"]["on"]["pull_request"] + bootstrap = workflows["ci-bootstrap-validation.yml"] + assert set(bootstrap["on"]["pull_request"]["paths"]) == set(ci_plan.GOVERNANCE_FILES) | { + f"{prefix}**" for prefix in ci_plan.GOVERNANCE_PREFIXES + } + assert bootstrap["permissions"] == {"contents": "read"} + assert set(bootstrap["jobs"]) == { + "bootstrap-quick", + "bootstrap-backend", + "bootstrap-frontend", + "bootstrap-compose", + "bootstrap-security", + "bootstrap-windows", + "bootstrap-upgrade", + "bootstrap-skills", + } + assert "workflow_dispatch" not in workflows["required-gate.yml"]["on"] + assert "workflow_run" in workflows["required-gate.yml"]["on"] + assert "pull_request_target" not in workflows["required-gate.yml"]["on"] + assert workflows["required-gate.yml"]["on"]["workflow_run"]["types"] == [ + "requested", + "in_progress", + "completed", + ] + assert "github.run_id" in workflows["pr-validation.yml"]["concurrency"]["group"] + assert "opened" in workflows["pr-validation.yml"]["on"]["pull_request"]["types"] + assert "synchronize" in workflows["pr-validation.yml"]["on"]["pull_request"]["types"] + for name in ( + "quick-ci.yml", + "backend-ci.yml", + "frontend-ci.yml", + "compose-ci.yml", + "security-ci.yml", + "standalone-windows.yml", + "upgrade-ci.yml", + "skills-ci.yml", + ): + assert "workflow_call" in workflows[name]["on"] + assert "pull_request" not in workflows[name]["on"] diff --git a/backend/tests/test_required_gate.py b/backend/tests/test_required_gate.py index 215e2e00..df85928c 100644 --- a/backend/tests/test_required_gate.py +++ b/backend/tests/test_required_gate.py @@ -1,17 +1,23 @@ +"""Trusted status publisher must not accept stale or incomplete PR runs.""" + +from __future__ import annotations + +import hashlib import importlib.util +import json import sys from pathlib import Path from types import ModuleType +from typing import Any import pytest -import yaml -WORKSPACE_ROOT = Path(__file__).parents[2] +SCRIPT_ROOT = Path(__file__).parents[2] / "scripts" +sys.path.insert(0, str(SCRIPT_ROOT)) -def _required_gate_module() -> ModuleType: - script_path = WORKSPACE_ROOT / "scripts" / "required_gate.py" - spec = importlib.util.spec_from_file_location("flowtest_required_gate", script_path) +def load_script(name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, SCRIPT_ROOT / f"{name}.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -19,327 +25,268 @@ def _required_gate_module() -> ModuleType: return module -required_gate = _required_gate_module() +ci_plan = load_script("ci_plan") +ci_summary = load_script("ci_summary") +gate = load_script("required_gate") +SHA = "a" * 40 +BASE = "b" * 40 +MERGE = "c" * 40 +REPO = "a3384379/FlowTest" -def _keys(plan) -> set[str]: - return {spec.key for spec in plan.required} +def plan(paths: list[str] | None = None) -> Any: + return ci_plan.build_plan( + paths or ["README.md"], set(), base_sha=BASE, head_sha=SHA, tested_sha=MERGE + ) + +def run_for(selected: Any, *, conclusion: str = "success", run_id: int = 42) -> dict[str, Any]: + return { + "id": run_id, + "repository": {"full_name": REPO}, + "workflow_id": 99, + "path": ".github/workflows/pr-validation.yml@refs/pull/7/merge", + "event": "pull_request", + "display_title": "PR Validation active", + "head_sha": SHA, + "status": "completed", + "conclusion": conclusion, + } -class _CheckClient: - def __init__(self, check_runs: list[dict], workflow_runs: dict[int, dict]) -> None: - self._check_runs = check_runs - self._workflow_runs = workflow_runs - self.workflow_run_calls: list[int] = [] - def check_runs(self, _sha: str) -> list[dict]: - return self._check_runs +def jobs_for( + selected: Any, *, plan_result: str = "success", shadow_result: str = "success", run_id: int = 42 +) -> list[dict[str, Any]]: + return [ + { + "name": f"CI Plan {MERGE}", + "run_id": run_id, + "status": "completed", + "conclusion": plan_result, + }, + { + "name": f"PR Validation Shadow {MERGE} {gate.fingerprint(selected)}", + "run_id": run_id, + "status": "completed", + "conclusion": shadow_result, + }, + ] - def workflow_run(self, run_id: int) -> dict: - self.workflow_run_calls.append(run_id) - return self._workflow_runs[run_id] +@pytest.mark.parametrize("result", ["failure", "cancelled", "skipped", "timed_out", "missing"]) +def test_shadow_refuses_required_job_without_real_success(result: str) -> None: + selected = plan() + needs = {"plan": {"result": "success"}, "quick": {"result": result}} + valid, lines = ci_summary.evaluate(json.loads(selected.to_json()), needs) + assert not valid + assert any(f"quick: {result}" in line for line in lines) -def test_required_gate_marks_irrelevant_checks_as_no_op() -> None: - plan = required_gate.build_gate_plan(["docs/release/notes.md"]) - assert _keys(plan) == {"security"} - assert {spec.key for spec in plan.no_op} == { - "backend", - "frontend", - "compose", - "standalone", - "upgrade", - } +def test_shadow_records_not_applicable_separately() -> None: + selected = plan() + valid, lines = ci_summary.evaluate( + json.loads(selected.to_json()), + {"plan": {"result": "success"}, "quick": {"result": "success"}}, + ) + assert valid + assert "windows: not_applicable" in lines -def test_light_gate_requires_only_quick_ci() -> None: - plan = required_gate.build_gate_plan(["backend/app/services/projects.py"], mode="light") +def test_shadow_job_contract_matches_planner() -> None: + assert ci_summary.ALL_JOBS == ci_plan.ALL_JOBS - assert _keys(plan) == {"quick"} - assert {spec.key for spec in plan.no_op} == { - "backend", - "frontend", - "security", - "compose", - "standalone", - "upgrade", - } + +@pytest.mark.parametrize( + "payload", + [None, {"required": []}, {"required": ["quick", "quick"]}, {"required": ["nonexistent"]}], +) +def test_invalid_plan_fails(payload: dict[str, Any] | None) -> None: + assert not ci_summary.evaluate(payload, {})[0] -def test_required_gate_rejects_unknown_mode() -> None: - with pytest.raises(required_gate.RequiredGateError, match="未知 CI 模式"): - required_gate.build_gate_plan([], mode="skip") +def test_shadow_rejects_failed_plan_and_inconsistent_not_applicable() -> None: + selected = json.loads(plan().to_json()) + assert not ci_summary.evaluate(selected, {"plan": {"result": "failure"}})[0] + selected["not_applicable"] = [] + assert not ci_summary.evaluate(selected, {"plan": {"result": "success"}})[0] -def test_light_gate_accepts_documentation_only() -> None: - required_gate.enforce_light_scope( - ["README.md", "docs/operations/deployment.md", ".github/PULL_REQUEST_TEMPLATE.md"], - "light", +def test_current_run_must_match_plan_fingerprint_and_tested_sha() -> None: + selected = plan() + assert gate.evaluate_run(run_for(selected), jobs_for(selected), selected, REPO, 99)[0] + changed = ci_plan.build_plan( + ["README.md"], {"ci:milestone"}, base_sha=BASE, head_sha=SHA, tested_sha=MERGE + ) + assert not gate.evaluate_run(run_for(selected), jobs_for(selected), changed, REPO, 99)[0] + wrong_sha = ci_plan.build_plan( + ["README.md"], set(), base_sha=BASE, head_sha=SHA, tested_sha="d" * 40 ) + assert not gate.evaluate_run(run_for(selected), jobs_for(selected), wrong_sha, REPO, 99)[0] @pytest.mark.parametrize( - "paths", + "mutation", [ - ["backend/app/services/auth.py"], - ["docs/ci-milestone.md", "frontend/src/App.tsx"], - ["docs/assets/diagram.png"], - [], + lambda run: run.update({"workflow_id": 88}), + lambda run: run.update({"head_sha": BASE}), + lambda run: run.update({"event": "workflow_dispatch"}), + lambda run: run.update({"display_title": "PR Validation ignored"}), + lambda run: run.update({"repository": {"full_name": "other/repo"}}), + lambda run: run.update({"conclusion": "failure"}), ], ) -def test_light_gate_rejects_runtime_or_non_document_paths(paths: list[str]) -> None: - with pytest.raises(required_gate.RequiredGateError, match="ci:light 只适用于"): - required_gate.enforce_light_scope(paths, "light") - - -def test_required_gate_selects_backend_dependent_checks() -> None: - plan = required_gate.build_gate_plan(["backend/app/services/projects.py"]) +def test_wrong_run_identity_or_result_rejected(mutation: Any) -> None: + selected = plan() + run = run_for(selected) + mutation(run) + assert not gate.evaluate_run(run, jobs_for(selected), selected, REPO, 99)[0] - assert _keys(plan) == {"backend", "security", "compose", "standalone", "upgrade"} +def test_same_named_job_from_other_run_is_rejected() -> None: + selected = plan() + assert not gate.evaluate_run( + run_for(selected), jobs_for(selected, run_id=41), selected, REPO, 99 + )[0] -def test_required_gate_selects_backend_contract_for_skill_changes() -> None: - plan = required_gate.build_gate_plan(["skills/flowtest-generate-integration-flow/SKILL.md"]) - assert _keys(plan) == {"backend", "security"} +def test_missing_or_skipped_shadow_rejected() -> None: + selected = plan() + assert not gate.evaluate_run( + run_for(selected), jobs_for(selected, shadow_result="skipped"), selected, REPO, 99 + )[0] + assert not gate.evaluate_run(run_for(selected), jobs_for(selected)[:1], selected, REPO, 99)[0] -def test_required_gate_selects_frontend_dependent_checks() -> None: - plan = required_gate.build_gate_plan(["frontend/src/main.tsx"]) +def test_governance_change_cannot_self_approve() -> None: + selected = plan(["scripts/ci_plan.py"]) + assert not gate.evaluate_run(run_for(selected), jobs_for(selected), selected, REPO, 99)[0] - assert _keys(plan) == {"frontend", "security", "compose", "standalone"} +class FakeGitHub: + def __init__(self, pages: dict[str, Any]) -> None: + self.pages = pages -@pytest.mark.parametrize("path", sorted(required_gate.CI_GOVERNANCE_PATHS)) -def test_required_gate_blocks_pr_ci_governance_changes(path: str) -> None: - with pytest.raises(required_gate.RequiredGateError, match="Bootstrap"): - required_gate.enforce_trusted_governance([path], "pull_request_target") + def get(self, path: str) -> Any: + return self.pages[path] -def test_required_gate_blocks_new_workflow_that_could_spoof_required_context() -> None: - with pytest.raises(required_gate.RequiredGateError, match="Bootstrap"): - required_gate.enforce_trusted_governance( - [".github/workflows/spoof-required-gate.yml"], "pull_request_target" - ) - - -def test_required_gate_allows_normal_pr_and_trusted_push_paths() -> None: - required_gate.enforce_trusted_governance( - ["backend/app/services/projects.py"], "pull_request_target" - ) - required_gate.enforce_trusted_governance([".github/workflows/required-gate.yml"], "push") - - -def test_required_gate_skips_newer_same_name_check_from_untrusted_app() -> None: - sha = "trusted-head" - workflow_path = ".github/workflows/security-ci.yml" - client = _CheckClient( - check_runs=[ - { - "id": 200, - "name": "source-and-images", - "status": "completed", - "conclusion": "success", - "details_url": "https://github.com/a3384379/FlowTest/actions/runs/2001/job/20", - "app": {"id": 99999}, - }, - { - "id": 100, - "name": "source-and-images", - "status": "completed", - "conclusion": "success", - "details_url": "https://github.com/a3384379/FlowTest/actions/runs/1001/job/10", - "app": {"id": required_gate.GITHUB_ACTIONS_APP_ID}, - }, - ], - workflow_runs={ - 1001: { - "path": workflow_path, - "event": "pull_request", - "head_sha": sha, - }, - 2001: { - "path": workflow_path, - "event": "pull_request", - "head_sha": sha, - }, - }, - ) - security_spec = next(spec for spec in required_gate.GATE_SPECS if spec.key == "security") - - states = required_gate._check_states(client, (security_spec,), sha, {}) - - assert states == {"Security CI/source-and-images": "success"} - assert client.workflow_run_calls == [1001] - - -def test_required_gate_fails_closed_without_github_actions_check() -> None: - sha = "trusted-head" - client = _CheckClient( - check_runs=[ - { - "id": 200, - "name": "source-and-images", - "status": "completed", - "conclusion": "success", - "details_url": "https://github.com/a3384379/FlowTest/actions/runs/2001/job/20", - "app": {"id": 99999}, - }, - { - "id": 100, - "name": "source-and-images", - "status": "completed", - "conclusion": "success", - "details_url": "https://github.com/a3384379/FlowTest/actions/runs/1001/job/10", - }, - ], - workflow_runs={}, - ) - security_spec = next(spec for spec in required_gate.GATE_SPECS if spec.key == "security") - - states = required_gate._check_states(client, (security_spec,), sha, {}) - - assert states == {"Security CI/source-and-images": "pending"} - assert client.workflow_run_calls == [] - - -def test_required_gate_path_rules_match_child_workflow_triggers() -> None: - for gate_spec in required_gate.GATE_SPECS: - if gate_spec.always_required: - continue - workflow = yaml.load( - (WORKSPACE_ROOT / gate_spec.workflow_path).read_text(encoding="utf-8"), - Loader=yaml.BaseLoader, - ) - workflow_paths = set(workflow["on"]["pull_request"]["paths"]) - workflow_prefixes = { - pattern.removesuffix("**") for pattern in workflow_paths if pattern.endswith("/**") - } - workflow_exact_paths = { - pattern for pattern in workflow_paths if not pattern.endswith("/**") - } - - assert set(gate_spec.prefixes) == workflow_prefixes - assert set(gate_spec.exact_paths) == workflow_exact_paths +@pytest.mark.parametrize( + "run_status,latest_id,expected_posts", + [ + ("in_progress", 42, 1), + ("completed", 42, 0), + ("in_progress", 43, 0), + ], +) +def test_delayed_pending_event_cannot_overwrite_terminal_status( + run_status: str, latest_id: int, expected_posts: int +) -> None: + class FakePublisher(FakeGitHub): + repository = REPO + def __init__(self, pages: dict[str, Any]) -> None: + super().__init__(pages) + self.posts: list[tuple[str, str]] = [] -def test_compose_rc_gates_only_run_after_explicit_post_review_dispatch() -> None: - workflow = yaml.load( - (WORKSPACE_ROOT / ".github/workflows/compose-ci.yml").read_text(encoding="utf-8"), - Loader=yaml.BaseLoader, - ) + def post(self, sha: str, state: str, description: str, run_id: int) -> None: + self.posts.append((sha, state)) - run_rc_gates = workflow["on"]["workflow_dispatch"]["inputs"]["run_rc_gates"] - assert run_rc_gates["default"] == "false" - assert run_rc_gates["required"] == "true" - - expected_condition = "${{ github.event_name == 'workflow_dispatch' && inputs.run_rc_gates }}" - assert workflow["jobs"]["compact-smoke"]["if"] == expected_condition - smoke_steps = workflow["jobs"]["smoke"]["steps"] - capacity_steps = [step for step in smoke_steps if "capacity" in step.get("name", "").lower()] - assert {step["name"] for step in capacity_steps} == { - "Run API capacity gate", - "Run real Workflow capacity gate", - "Run durable 1000-task queue capacity gate", - "Enable S29 Runner Fabric capacity plane", - "Run S29 5000-queue and 500-workflow multi-Worker capacity gate", + pr = { + "state": "open", + "base": {"sha": BASE, "ref": "main", "repo": {"full_name": REPO}}, + "head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}, } - assert all(step["if"] == expected_condition for step in capacity_steps) - - compose_spec = next(spec for spec in required_gate.GATE_SPECS if spec.key == "compose") - assert compose_spec.checks == ("smoke",) - - -def test_required_gate_controller_runs_trusted_base_code() -> None: - workflow = yaml.load( - (WORKSPACE_ROOT / ".github/workflows/required-gate.yml").read_text(encoding="utf-8"), - Loader=yaml.BaseLoader, - ) - - assert "pull_request_target" in workflow["on"] - assert "pull_request" not in workflow["on"] - assert "push" not in workflow["on"] - assert workflow["on"]["pull_request_target"]["types"] == ["labeled"] - assert workflow["concurrency"]["cancel-in-progress"] == "true" - assert workflow["permissions"]["statuses"] == "write" - assert "checks" not in workflow["permissions"] - controller = workflow["jobs"]["controller"] - assert controller["if"] == ( - "${{ github.event_name != 'pull_request_target' " - "|| github.event.label.name == 'ci:milestone' " - "|| github.event.label.name == 'ci:light' }}" - ) - assert controller["name"] == "Required Gate Controller" - checkout = next(step for step in controller["steps"] if "uses" in step) - assert checkout["with"]["ref"] == "${{ github.event.pull_request.base.sha || github.sha }}" - assert checkout["with"]["persist-credentials"] == "false" - create_status = next( - step - for step in controller["steps"] - if step.get("name") == "Create trusted Required Gate status" - ) - assert '"repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}"' in create_status["run"] - assert "-f context='Required Gate'" in create_status["run"] - assert "-f state='pending'" in create_status["run"] - assert controller["steps"].index(create_status) < controller["steps"].index(checkout) - resolve_paths = next( - step - for step in controller["steps"] - if step.get("name") == "Resolve changed paths from trusted metadata" - ) - assert ".previous_filename" in resolve_paths["run"] - assert "-ge 3000" in resolve_paths["run"] - complete_status = next( - step - for step in controller["steps"] - if step.get("name") == "Complete trusted Required Gate status" + notified = { + "id": 42, + "run_attempt": 1, + "head_sha": SHA, + "display_title": "PR Validation active", + "repository": {"full_name": REPO}, + "pull_requests": [{"number": 7}], + } + latest = { + "id": latest_id, + "run_attempt": 1, + "display_title": "PR Validation active", + "head_branch": "feature", + "head_repository": {"full_name": REPO}, + } + run = {**run_for(plan()), "status": run_status, "run_attempt": 1} + run_path = ( + f"actions/workflows/pr-validation.yml/runs?event=pull_request&head_sha={SHA}&per_page=100" ) - assert complete_status["if"] == "always()" - assert ".base.sha, .head.sha" in complete_status["run"] - assert '"${BASE_SHA} ${HEAD_SHA}"' in complete_status["run"] - assert "${FLOWTEST_GATE_MODE}" in complete_status["run"] - - -def test_quick_ci_runs_only_for_light_label() -> None: - workflow = yaml.load( - (WORKSPACE_ROOT / ".github/workflows/quick-ci.yml").read_text(encoding="utf-8"), - Loader=yaml.BaseLoader, + gh = FakePublisher( + { + "pulls/7": pr, + run_path: {"total_count": 1, "workflow_runs": [latest]}, + "actions/workflows/pr-validation.yml": {"id": 99}, + "actions/runs/42": run, + } ) - - assert workflow["on"]["pull_request"]["types"] == ["labeled"] - assert "push" not in workflow["on"] - assert workflow["jobs"]["quick"]["if"] == ( - "${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:light' }}" + gate.publish_pending_run(gh, {"workflow_run": notified}, "main") + assert len(gh.posts) == expected_posts + if expected_posts: + assert gh.posts == [(SHA, "pending")] + + +def test_latest_run_rejects_old_completion_and_ignores_irrelevant_label_run() -> None: + pr = {"head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}} + runs = [ + { + "id": 11, + "conclusion": "success", + "display_title": "PR Validation active", + "head_branch": "feature", + "head_repository": {"full_name": REPO}, + }, + { + "id": 12, + "conclusion": None, + "display_title": "PR Validation ignored", + "head_branch": "feature", + "head_repository": {"full_name": REPO}, + }, + { + "id": 13, + "conclusion": "failure", + "display_title": "PR Validation active", + "head_branch": "feature", + "head_repository": {"full_name": REPO}, + }, + ] + path = ( + f"actions/workflows/pr-validation.yml/runs?event=pull_request&head_sha={SHA}&per_page=100" ) - quick_steps = workflow["jobs"]["quick"]["steps"] - assert any("git diff --check" in step.get("run", "") for step in quick_steps) + gh = FakeGitHub({path: {"total_count": 3, "workflow_runs": runs}}) + assert gate.latest_run(gh, pr)["id"] == 13 + + +def test_merge_commit_must_match_exact_base_and_head() -> None: + pr = {"merge_commit_sha": MERGE, "base": {"sha": BASE}, "head": {"sha": SHA}} + gh = FakeGitHub({f"commits/{MERGE}": {"parents": [{"sha": BASE}, {"sha": SHA}]}}) + assert gate.validate_merge(gh, pr) == MERGE + gh.pages[f"commits/{MERGE}"]["parents"].reverse() + with pytest.raises(gate.GateError): + gate.validate_merge(gh, pr) + + +def test_pr_validation_identity_ignores_irrelevant_metadata() -> None: + original = { + "state": "open", + "base": {"sha": BASE, "ref": "main", "repo": {"full_name": REPO, "pushed_at": "old"}}, + "head": {"sha": SHA, "ref": "feature", "repo": {"full_name": REPO}}, + "merge_commit_sha": MERGE, + "labels": [{"name": "ci:milestone"}, {"name": "reviewed"}], + } + refreshed = { + **original, + "base": {**original["base"], "repo": {"full_name": REPO, "pushed_at": "new"}}, + "labels": [{"name": "ci:milestone"}, {"name": "unrelated"}], + } + assert gate.pr_validation_identity(refreshed) == gate.pr_validation_identity(original) + refreshed["labels"] = [{"name": "ci:light"}] + assert gate.pr_validation_identity(refreshed) != gate.pr_validation_identity(original) -@pytest.mark.parametrize( - ("workflow_name", "job_names"), - [ - ("backend-ci.yml", ("test", "integration")), - ("frontend-ci.yml", ("build",)), - ("security-ci.yml", ("source-and-images",)), - ("compose-ci.yml", ("smoke",)), - ("standalone-windows.yml", ("bundle",)), - ("upgrade-ci.yml", ("rehearse-v2-to-v3-upgrade-and-rollback",)), - ], -) -def test_pull_request_ci_runs_only_for_milestone_label( - workflow_name: str, - job_names: tuple[str, ...], -) -> None: - workflow = yaml.load( - (WORKSPACE_ROOT / ".github/workflows" / workflow_name).read_text(encoding="utf-8"), - Loader=yaml.BaseLoader, - ) - - assert workflow["on"]["pull_request"]["types"] == ["labeled"] - assert "push" not in workflow["on"] - assert workflow["concurrency"]["cancel-in-progress"] == "true" - expected_condition = ( - "${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:milestone' }}" - ) - assert all(workflow["jobs"][name]["if"] == expected_condition for name in job_names) +def test_plan_fingerprint_is_content_bound() -> None: + assert hashlib.sha256(plan().to_json().encode()).hexdigest() == gate.fingerprint(plan()) diff --git a/docs/ci-milestone.md b/docs/ci-milestone.md index f0b170d8..20f54bdd 100644 --- a/docs/ci-milestone.md +++ b/docs/ci-milestone.md @@ -1,21 +1,5 @@ -# PR CI 开关 +# PR CI 操作说明 -FlowTest 的 PR 通过 GitHub 标签选择一次远程检查。普通提交不会自动启动远程重任务;先完成适用的本地验证,再给稳定的候选提交加标签。 +第三阶段的自动 PR 验证矩阵、标签语义、可信门禁与启用状态见 [开发效率第三阶段](development-efficiency-phase3.md)。 -| 标签 | 用途 | PR 合并门禁 | -| --- | --- | --- | -| `ci:light` | 仅 `README.md`、PR 模板和 `docs/` 下的 Markdown 文档 | Quick CI:检查差异空白错误。不会运行功能测试、构建、容器或安全扫描;其他路径的 PR 会被 Required Gate 拒绝。 | -| `ci:milestone` | 功能、依赖、部署或需要完整回归的改动 | 按路径运行 Backend、Frontend、Security、Compose、Windows、Upgrade,并由 Required Gate 汇总。 | - -两个标签同时存在时门禁失败;先移除旧标签再添加新标签。CI 工作流自身的修改仍须经受控 Bootstrap 合并,不能用轻量标签绕过。`main` 合并后不再重复运行同一组完整 CI;需要补跑时使用各工作流的 `workflow_dispatch`。 - -## 使用流程 - -1. 在同一功能阶段内集中开发、修复和本地验证。 -2. 根据改动风险,为 PR 添加 `ci:light` 或 `ci:milestone`。 -3. 等待当前 PR Head 的 `Required Gate` 成功,再合并。 -4. 如果门禁后又修改了代码,先移除再重新添加所选标签,使最新 Head 获得新的检查结果。改变模式时先移除旧标签,再添加新标签。 - -在 GitHub PR 右侧的 **Labels** 中选择标签即可开启对应检查;命令行也可使用 `gh pr edit --add-label ci:light`。切换到完整模式时先移除 `ci:light`,再添加 `ci:milestone`。 - -所有工作流按 PR 设置并发组。新的门禁启动时,会取消同一 PR 上仍在运行的旧门禁,避免重复占用 Runner。GitHub 对 `labeled` 事件会创建其他工作流的跳过记录,但只有所选模式的检查会实际执行。 +代码进入受信默认分支并完成 Bootstrap/真实 PR 验证前,仓库仍按默认分支上的旧标签控制器运行;不要把本分支文档当作已经生效的远程设置。 diff --git a/docs/development-efficiency-phase3.md b/docs/development-efficiency-phase3.md new file mode 100644 index 00000000..4bd840c7 --- /dev/null +++ b/docs/development-efficiency-phase3.md @@ -0,0 +1,67 @@ +# 开发效率第三阶段:PR 验证路由与合并门禁 + +## 交付状态(2026-09-27) + +本分支提供确定性计划器、自动 PR 验证链、影子汇总与受信状态发布器代码。本轮尚未把它们部署到默认分支,也没有真实 PR 运行证据。`main` 的有效 ruleset `main-required-gate`(id `21653796`)要求严格更新,并要求 GitHub Actions App(integration id `15368`)发布名为 `Required Gate` 的结果;没有 merge queue。当前默认分支仍运行旧的加标签控制器。治理文件变更受现有 Bootstrap 规则限制,本分支不能通过修改自身的门禁来给自己放行。 + +本分支以 `codex/dev-efficiency-phase2` 的 `31a11e4` 为起点;第一阶段定向测试入口和第二阶段 Bake/缓存代码在此 checkout 中可用,但尚未进入当时的 `origin/main`。因此第三阶段的线上启用依赖前两阶段的受控交付和本阶段的 Bootstrap 审核。 + +## 架构与可信边界 + +```text +PR opened / synchronize / reopened / ready / 相关标签 / base 编辑 + → PR Validation 的 plan job:PR API 完整分页、确定性计划 + → 按计划运行 quick、标准检查或现有 workflow_call 全量检查 + → PR Validation Shadow:原生 needs,失败/跳过/缺失即失败 + → Required Gate Publisher:受信默认分支重新读取 PR 与计划, + 核对本轮 workflow run、被测合并提交、计划指纹和影子结果, + 发布唯一 Required Gate Commit Status +``` + +执行链使用只读 `pull_request` token。发布器只使用 `workflow_run` 的 requested/in_progress/completed 事件,只有读取元数据与写 Commit Status 的权限,不 checkout、不执行 PR 提供的脚本或产物,不恢复 PR 缓存。pending 绑定当前运行;延迟到达的 requested/in_progress 事件若发现运行已完成,就不再覆盖终态。正式 `Required Gate` 仍是 Commit Status,不能由同名 Check Run 替代。手动 `workflow_dispatch`、周期运行和影子结果都不发布该正式状态。 + +`workflow_run` 必须来自本仓库的 `PR Validation` 工作流和指定运行实例;发布器核对当前 PR 的 base/head、合并提交双亲、运行 ID/attempt、工作流 ID、Head、计划指纹和本轮 plan/shadow job 的实际成功结果。旧运行晚完成、标签切换或基线变化时,指纹或实例不匹配就不发布 success。所需 job 由影子汇总在同一 run 的 `needs` 中逐项判断;`skipped`、`cancelled`、`failure`、`missing` 不算通过。计划读取失败保持阻断。GitHub strict up-to-date 仍负责默认分支自行前进后的合并前更新要求。 + +治理保护包含 `.github/workflows/`、`.github/actions/`、AGENTS/CONTRIBUTING/Makefile/PR 模板、CI 说明、计划器、聚合器、发布器和定向测试包装脚本。改动这些文件时计划增加 `policy` 必需项,影子汇总及受信发布器均拒绝自行放行,必须走受控 Bootstrap。 + +## 检查矩阵 + +| 最低档位 | 典型输入 | 本轮任务 | +| --- | --- | --- | +| docs | 独立 README 与 `docs/*.md` 说明,排除治理说明 | Quick CI 的 diff 检查;其他 job 标记 `not_applicable` | +| standard | 局部前后端或 Skill 包 | 相关侧格式、lint、类型与构建;命中的源码测试加直接消费者,未知映射回退该侧全部测试且不启用全局覆盖率;Skill 契约和已有 evaluation | +| integration | 共享前端、引擎/权限/模型、依赖、部署形态 | 相关侧完整覆盖率与集成;按领域增加 Compose、镜像审计、Windows、历史升级 | +| full | `ci:milestone`、未知根路径或完整回归 | 全量后端、前端、Compose、镜像扫描、Windows、升级与 Skill 检查 | + +计划分开记录 `tier_floor`、最终 `tier`、领域、必需与不适用 job、理由、规则文件摘要以及 base/head/tested SHA。普通 Markdown 只有受限路径进入 docs;`docs/release/` 和 `docs/operations/` 中由测试读取或用于执行的文档进入后端检查,Skill 的 Markdown、fixture、生成输入和 CI 文档也按真实用途处理。新目录或未映射根路径升至 full。PR files API 使用每页 100 项,核对 `changed_files`,重命名同时读取新旧文件名,达到 3000 项上限或响应不完整即失败。文件名只作为 JSON 数据,不插入 shell 命令。 + +标准档后端定向入口禁用覆盖率,全侧回退也用 `--no-cov`;完整 Backend CI 的 90% 与 Frontend CI 的 80% 覆盖率阈值维持原样。依赖审计由 Security CI 执行,避免 Backend/Frontend full 作业重复执行同一审计。 + +`ci:light` 保留为兼容请求,不能降低代码变更的最低档位;`ci:milestone` 请求 full;双标签按较重请求执行并在原因中说明。相关标签增删会重新验证。无关标签和普通标题/正文修改不会执行 plan 或取消在途检查。目标分支编辑触发重新计划;默认分支自身前进由 strict up-to-date 阻止旧基线合并,不声称普通 `edited` 会覆盖它。 + +PR 验证运行将无关标签/元数据编辑标记为 `PR Validation ignored`,发布器只选 `PR Validation active` 运行。这样即使无关事件的全跳过 run 晚完成,也不会让有效运行失去最终汇总机会。 + +## 手动与周期入口 + +- 手动诊断:各 Backend/Frontend/Compose/Security/Windows/Upgrade 工作流保留 `workflow_dispatch`,被测提交以该运行的 `github.sha` 为准。它们不写 PR `Required Gate`。 +- PR 完整验收:在开放 PR 上添加 `ci:milestone`,或在同一 PR 事件重跑本轮验证。移除标签重新回到路径风险最低档位;旧结果不能直接补绿。 +- 周期回归:`Full Regression` 每周一 03:00 UTC 在受信默认分支运行一次,也允许手动启动。Compose 的 Compact/容量 RC 检查仍使用现有 `Compose Smoke Test` 的 `run_rc_gates` 手动开关;不把它伪装成普通 PR 的结果。 +- `main` 上没有每次 push 无条件重复跑整套发布矩阵。周期结果不替代候选提交需要的当前验证,漏洞库扫描也不按源码 SHA 跳过。 + +`deploy/compact/` 的自动路由明确加入 `compact` 必需项。现有 Compact RC 作业写明须在 P0/P1 复审清除后才手动执行;本阶段没有获得解除该限制的证据,因此自动链路不把普通 Compose smoke 冒充 Compact 通过,`compact` 当前保持缺失并阻断此类 PR。维护者完成复审并设计可关联当前 PR 的受信 RC 入口前,该路径不能由新门禁放行。 + +## 验证、性能与激活 + +本地已核对规则集和原有工作流,计划/发布器单元测试覆盖 docs、局部前后端、引擎、权限、迁移、依赖、部署、Skill、治理路径、重命名与分页、标签最低档、聚合异常、旧 run、手动运行边界。实际命令与结果以交付记录为准;尚无新工作流在 GitHub 上的运行、消耗分钟数或缓存命中数据,不宣称固定提速。预期省去的工作是文档 PR 的应用构建、局部前端 PR 的无关后端升级与 Windows 打包,以及旧控制器最长 90 分钟的轮询 runner 占用;标准档回退完整侧测试时仍需执行该侧测试。不能把并行 job 时间相加作为等待时间。 + +本地条件为 macOS、已有 uv 与 pnpm 依赖缓存,不代表 GitHub runner 的冷启动性能。集成期间一次完整后端 `pytest -q` 通过,墙钟 181.33 秒、覆盖率 90.67%(阈值 90%);之后针对最终规则改动复跑 `pytest --no-cov tests/test_ci_plan.py tests/test_required_gate.py -q`,59 项通过。后端 `ruff format --check`、`ruff check`、`mypy app ../scripts/*.py`、`lint-imports` 通过。Skills 契约测试和 `build_skill_evaluation.py --check` 通过。前端 `pnpm format:check`、`pnpm lint`、`pnpm test:coverage`、`pnpm build` 通过;覆盖率测试为 88 个文件、425 项,Vitest 报告历时 172.65 秒,语句 85.71%、分支 80.72%。11 个工作流经 PyYAML 解析;本机无 actionlint,GitHub 平台语法及实际 job 命名尚未验证。Compose、Windows、历史升级及真实 PR E2E 未在本轮本地执行。 + +受控启用步骤: + +1. 先确认前两阶段依赖已经在受信基线,核对本分支相对最新 `main` 的差异及 Bootstrap 规则。 +2. 维护者按现有 Bootstrap 治理流程审查并合入本阶段工作流、计划器与发布器。临时要求精确 Head 的 Bootstrap 子检查时,维持 GitHub Actions App 来源、strict 和无 bypass;普通合并后立即恢复唯一的 `Required Gate`,全程不关闭保护。 + `.github/workflows/ci-bootstrap-validation.yml` 对治理文件变更在 PR 事件中执行完整检查,供维护者核对精确 Head 后实施受控 Bootstrap;它不发布正式 `Required Gate`,普通功能 PR 不触发这套重复检查。 +3. 在代表性文档、局部功能、关键修改 PR 上观察 `PR Validation` 与 `PR Validation Shadow` 的实际 job 名、运行 ID、被测 SHA、跳过状态及发布器 Commit Status;再做一次追加提交、标签变化、失败后重跑,并确认旧运行不能覆盖新结果。 +4. 只有平台实测证明 `Required Gate` 的来源和状态被现有 ruleset 正确识别时才宣布激活。如工作流未触发或 publisher 无法可靠关联运行,保留保护并回滚到旧控制器/标签入口;不要以手动诊断成功补写 PR success。 + +当前未完成:Bootstrap 合并、上述真实 PR 事件验证、正式门禁显示与约束验证、Compact RC 的自动可信关联、跨平台/Compose 全量执行、实测耗时比较。代码提交与本地通过不等于门禁激活或发布验收完成。 diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py new file mode 100644 index 00000000..94960deb --- /dev/null +++ b/scripts/ci_plan.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +# User-facing CI copy intentionally uses Chinese punctuation. +# ruff: noqa: RUF001 +"""Deterministic PR check selection. The trusted publisher imports this from base.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, cast +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +TIERS = ("docs", "standard", "integration", "full") +ALL_JOBS = ( + "quick", + "backend-standard", + "backend-full", + "frontend-standard", + "frontend-full", + "compose", + "security", + "windows", + "upgrade", + "compact", + "skills", + "policy", +) +GOVERNANCE_FILES = frozenset( + { + "AGENTS.md", + "CONTRIBUTING.md", + "Makefile", + ".github/PULL_REQUEST_TEMPLATE.md", + "docs/ci-milestone.md", + "docs/development-efficiency-phase3.md", + "backend/tests/test_ci_plan.py", + "backend/tests/test_required_gate.py", + "scripts/build_skill_evaluation.py", + "scripts/ci_plan.py", + "scripts/ci_summary.py", + "scripts/required_gate.py", + "scripts/run_targeted_tests.py", + } +) +GOVERNANCE_PREFIXES = (".github/workflows/", ".github/actions/") +FRONTEND_SHARED = ( + "frontend/src/lib/", + "frontend/src/app/", + "frontend/src/features/workflows/", + "frontend/src/features/projects/", + "frontend/src/features/auth/", +) +BACKEND_IDENTITY_BOUNDARY = ( + "backend/app/api/dependencies.py", + "backend/app/api/v1/endpoints/auth", + "backend/app/api/v1/endpoints/organizations.py", + "backend/app/api/v1/endpoints/users.py", + "backend/app/core/context.py", + "backend/app/core/security.py", + "backend/app/domain/tenant.py", + "backend/app/http/oidc.py", + "backend/app/repositories/access.py", + "backend/app/repositories/organizations.py", + "backend/app/services/auth.py", + "backend/app/services/oidc.py", + "backend/app/services/organizations.py", + "backend/app/services/organization_governance.py", +) +BACKEND_CRITICAL = ( + "backend/app/domain/", + "backend/app/engine/", + "backend/app/execution/", + "backend/app/core/security", + "backend/app/schemas/", + "backend/app/services/auth.py", + "backend/app/services/durable_execution.py", + "backend/app/services/execution_events.py", + "backend/app/services/execution", + "backend/app/services/workflows", + "backend/app/services/workflow_snapshots.py", + "backend/app/api/v1/endpoints/auth", + "backend/app/api/v1/endpoints/executions.py", + "backend/app/models/", + "backend/app/repositories/", + "backend/alembic/", +) +BACKEND_TEST_MAP: dict[str, tuple[str, ...]] = { + "backend/app/services/imports.py": ("tests/test_imports_api.py",), + "docs/release/s46-ga-gate.md": ("tests/test_s46_ga_gate.py",), + "docs/release/s46-compatibility-matrix.md": ("tests/test_s46_ga_gate.py",), + "docs/operations/s46-failure-injection.md": ("tests/test_s46_ga_gate.py",), +} +FRONTEND_TEST_MAP: dict[str, tuple[str, ...]] = { + "frontend/src/features/navigation/navigation-config.tsx": ( + "src/features/navigation/navigation-config.test.tsx", + "src/features/navigation/ShellSidebar.test.tsx", + "src/features/navigation/navigation-preferences.test.ts", + "src/App.test.tsx", + ), + "frontend/src/features/navigation/ShellSidebar.tsx": ( + "src/features/navigation/ShellSidebar.test.tsx", + "src/App.test.tsx", + ), + "frontend/src/features/navigation/navigation-preferences.ts": ( + "src/features/navigation/navigation-preferences.test.ts", + "src/features/navigation/ShellSidebar.test.tsx", + ), +} + + +class PlanError(RuntimeError): + """Metadata is incomplete or unfit for a merge decision.""" + + +@dataclass(frozen=True) +class Plan: + base_sha: str + head_sha: str + tested_sha: str + policy_digest: str + tier_floor: str + tier: str + domains: tuple[str, ...] + required: tuple[str, ...] + not_applicable: tuple[str, ...] + reasons: tuple[str, ...] + backend_targets: tuple[str, ...] + frontend_targets: tuple[str, ...] + governance_change: bool + + def to_json(self) -> str: + return json.dumps(asdict(self), ensure_ascii=False, sort_keys=True) + + +def _tier_max(left: str, right: str) -> str: + return TIERS[max(TIERS.index(left), TIERS.index(right))] + + +def _docs_only(path: str) -> bool: + return (path == "README.md" or path.startswith("docs/")) and path.endswith(".md") + + +def _is_governance(path: str) -> bool: + return path in GOVERNANCE_FILES or path.startswith(GOVERNANCE_PREFIXES) + + +@dataclass +class Routing: + tier: str + domains: set[str] + reasons: set[str] + backend_sources: list[str] + frontend_sources: list[str] + governance: bool = False + + def classify(self, path: str) -> None: + if path.startswith("/") or ".." in Path(path).parts or "\x00" in path: + raise PlanError("变更路径无效") + if _is_governance(path): + self.tier = "full" + self.governance = True + self.domains.add("policy") + self.reasons.add("CI/开发规则变更需要 Bootstrap 审核") + elif path.startswith(("docs/release/", "docs/operations/")): + self.tier = _tier_max(self.tier, "standard") + self.domains.add("backend") + self.backend_sources.append(path) + self.reasons.add("发布/运维文档被测试读取或用于执行") + elif _docs_only(path): + self.reasons.add("独立说明文档") + elif path.startswith("skills/"): + self.tier = _tier_max(self.tier, "standard") + self.domains.add("skills") + self.reasons.add("Skill 包或 evaluation 输入") + elif path.startswith("frontend/"): + self.frontend(path) + elif path.startswith("backend/"): + self.backend(path) + elif path.startswith(("deploy/", "mock-target/")) or path == "compose.yaml": + self.deploy(path) + else: + self.tier = "full" + self.reasons.add(f"未知路径保守执行完整验收: {path}") + + def deploy(self, path: str) -> None: + if path.startswith("deploy/standalone/"): + self.tier = _tier_max(self.tier, "integration") + self.domains.update(("windows", "backend", "frontend")) + self.reasons.add("Standalone 打包或启动") + elif path.startswith("deploy/upgrade/"): + self.tier = _tier_max(self.tier, "integration") + self.domains.update(("compose", "upgrade")) + self.reasons.add("历史升级与回退") + elif path.startswith("deploy/compact/"): + self.tier = _tier_max(self.tier, "integration") + self.domains.update(("compose", "compact")) + self.reasons.add("Compact RC 仍需现有人工复审入口") + else: + self.tier = _tier_max(self.tier, "integration") + self.domains.add("compose") + self.reasons.add("部署与目标服务") + + def frontend(self, path: str) -> None: + self.domains.add("frontend") + self.tier = _tier_max(self.tier, "standard") + self.frontend_sources.append(path) + if path.startswith(FRONTEND_SHARED): + self.tier = _tier_max(self.tier, "integration") + self.reasons.add("共享前端状态/契约需完整消费者测试") + if path.startswith(("frontend/src/lib/api", "frontend/src/features/workflows/")): + self.domains.add("compose") + self.reasons.add("前端 API/流程契约需端到端冒烟") + if path in {"frontend/package.json", "frontend/pnpm-lock.yaml"} or "Dockerfile" in path: + self.tier = _tier_max(self.tier, "integration") + self.domains.add("security") + self.reasons.add("前端依赖或镜像输入") + + def backend(self, path: str) -> None: + self.domains.add("backend") + self.tier = _tier_max(self.tier, "standard") + self.backend_sources.append(path) + if path.startswith(BACKEND_IDENTITY_BOUNDARY): + self.tier = _tier_max(self.tier, "integration") + self.domains.update(("compose", "frontend", "security")) + self.reasons.add("认证、授权与租户边界需集成消费者和安全检查") + if path.startswith(BACKEND_CRITICAL): + self.tier = _tier_max(self.tier, "integration") + self.domains.add("compose") + self.reasons.add("关键后端运行或数据路径") + if path.startswith(("backend/app/domain/execution", "backend/app/engine/")): + self.domains.update(("frontend", "windows")) + self.reasons.add("引擎结果需前端契约与 Standalone 验证") + if path.startswith(("backend/app/schemas/", "backend/app/api/v1/endpoints/auth")): + self.domains.add("frontend") + self.reasons.add("共享 API 契约需前端消费者验证") + if path.startswith( + ("backend/alembic/", "backend/app/models/", "backend/app/repositories/") + ): + self.domains.update(("upgrade", "windows")) + self.reasons.add("数据模型与历史升级/Standalone 兼容") + if path in {"backend/pyproject.toml", "backend/uv.lock"} or "Dockerfile" in path: + self.tier = _tier_max(self.tier, "integration") + self.domains.update(("security", "compose")) + self.reasons.add("后端依赖或镜像输入") + + +def _mapped_targets(sources: list[str], mapping: dict[str, tuple[str, ...]]) -> tuple[str, ...]: + selected = [mapping.get(path) for path in sources] + if not selected or not all(selected): + return () + return tuple(sorted({target for group in selected if group for target in group})) + + +def _required_jobs( + routing: Routing, tier: str +) -> tuple[set[str], tuple[str, ...], tuple[str, ...]]: + required = {"quick"} + backend_targets: tuple[str, ...] = () + frontend_targets: tuple[str, ...] = () + if tier == "full": + required.update( + ("backend-full", "frontend-full", "compose", "security", "windows", "upgrade", "skills") + ) + else: + if "backend" in routing.domains: + required.add("backend-full" if tier == "integration" else "backend-standard") + backend_targets = _mapped_targets(routing.backend_sources, BACKEND_TEST_MAP) + if "frontend" in routing.domains: + required.add("frontend-full" if tier == "integration" else "frontend-standard") + frontend_targets = _mapped_targets(routing.frontend_sources, FRONTEND_TEST_MAP) + required.update( + routing.domains & {"compose", "security", "windows", "upgrade", "compact", "skills"} + ) + if "compact" in routing.domains: + required.add("compact") + if routing.governance: + required.add("policy") + return required, backend_targets, frontend_targets + + +def _paths_from_files(files: list[dict[str, Any]]) -> list[str]: + paths: list[str] = [] + for item in files: + filename = item.get("filename") + if not isinstance(filename, str) or not filename: + raise PlanError("PR 文件路径无效") + paths.append(filename) + if item.get("status") == "renamed": + old = item.get("previous_filename") + if not isinstance(old, str) or not old: + raise PlanError("重命名缺少旧路径") + paths.append(old) + return paths + + +def build_plan( + paths: list[str], labels: set[str], *, base_sha: str, head_sha: str, tested_sha: str +) -> Plan: + if not paths or any(not isinstance(path, str) or not path for path in paths): + raise PlanError("变更路径为空或无效,不能推断为文档变更") + if any( + len(sha) != 40 or any(c not in "0123456789abcdef" for c in sha) + for sha in (base_sha, head_sha, tested_sha) + ): + raise PlanError("base/head/tested SHA 无效") + routing = Routing("docs", set(), set(), [], []) + for path in paths: + routing.classify(path) + floor = routing.tier + tier = floor + if "ci:milestone" in labels: + tier = "full" + routing.reasons.add("ci:milestone 请求完整验收") + if "ci:light" in labels: + routing.reasons.add("ci:light 保留自动最低档位") + if {"ci:milestone", "ci:light"} <= labels: + routing.reasons.add("两个 CI 标签同时存在,采用较重请求") + required, backend_targets, frontend_targets = _required_jobs(routing, tier) + return Plan( + base_sha, + head_sha, + tested_sha, + hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + floor, + tier, + tuple(sorted(routing.domains)), + tuple(job for job in ALL_JOBS if job in required), + tuple(job for job in ALL_JOBS if job not in required), + tuple(sorted(routing.reasons)), + backend_targets, + frontend_targets, + routing.governance, + ) + + +def plan_source_identity(pr: dict[str, Any]) -> tuple[object, ...]: + """Keep only PR fields that can change the selected plan or tested revision.""" + base = pr.get("base") + head = pr.get("head") + if not isinstance(base, dict) or not isinstance(head, dict): + raise PlanError("PR base/head 元数据无效") + base_repo = base.get("repo") or {} + head_repo = head.get("repo") or {} + labels = frozenset( + item.get("name") + for item in pr.get("labels", []) + if isinstance(item, dict) and item.get("name") in {"ci:light", "ci:milestone"} + ) + return ( + pr.get("state"), + pr.get("changed_files"), + base.get("sha"), + base.get("ref"), + base_repo.get("full_name"), + head.get("sha"), + head.get("ref"), + head_repo.get("full_name"), + labels, + ) + + +class GitHub: + def __init__(self, repository: str, token: str) -> None: + self.repository = repository + self.token = token + + def get(self, path: str) -> dict[str, Any] | list[Any]: + request = Request( + f"https://api.github.com/repos/{self.repository}/{path}", + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urlopen(request, timeout=20) as response: # noqa: S310 - fixed HTTPS host + return cast(dict[str, Any] | list[Any], json.load(response)) + except (HTTPError, URLError, ValueError) as exc: + raise PlanError("GitHub 元数据读取失败") from exc + + def changed_paths(self, number: int, changed_count: object) -> list[str]: + if not isinstance(changed_count, int) or changed_count <= 0 or changed_count >= 3000: + raise PlanError("PR 文件清单为空或达到 GitHub API 上限") + files: list[dict[str, Any]] = [] + for page in range(1, 31): + batch = self.get(f"pulls/{number}/files?per_page=100&page={page}") + if not isinstance(batch, list) or any(not isinstance(item, dict) for item in batch): + raise PlanError("PR 文件分页响应无效") + files.extend(batch) + if len(batch) < 100: + break + else: + raise PlanError("PR 文件分页达到上限") + if len(files) != changed_count: + raise PlanError("PR 文件清单与 changed_files 不一致") + if len({item.get("filename") for item in files}) != changed_count: + raise PlanError("PR 文件分页重复或不完整") + return _paths_from_files(files) + + def pr_plan(self, number: int, tested_sha: str) -> Plan: + pr = self.get(f"pulls/{number}") + if not isinstance(pr, dict) or pr.get("state") != "open": + raise PlanError("PR 元数据无效或 PR 未开放") + paths = self.changed_paths(number, pr.get("changed_files")) + current = self.get(f"pulls/{number}") + if not isinstance(current, dict) or plan_source_identity(current) != plan_source_identity( + pr + ): + raise PlanError("读取文件期间 PR 元数据已改变") + labels = { + item["name"] + for item in pr.get("labels", []) + if isinstance(item, dict) and isinstance(item.get("name"), str) + } + return build_plan( + paths, + labels, + base_sha=pr["base"]["sha"], + head_sha=pr["head"]["sha"], + tested_sha=tested_sha, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--pr", type=int, required=True) + parser.add_argument("--tested-sha", required=True) + parser.add_argument("--expected-base", required=True) + parser.add_argument("--expected-head", required=True) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + plan = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_TOKEN"]).pr_plan( + args.pr, args.tested_sha + ) + if (plan.base_sha, plan.head_sha) != (args.expected_base, args.expected_head): + raise PlanError("PR base/head 已变化,本轮计划过期") + except (KeyError, PlanError) as exc: + parser.exit(1, f"CI 计划失败: {exc}\n") + result = plan.to_json() + print(result) + if args.output: + with args.output.open("a", encoding="utf-8") as output: + output.write(f"plan={result}\n") + output.write(f"fingerprint={hashlib.sha256(result.encode()).hexdigest()}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_summary.py b/scripts/ci_summary.py new file mode 100644 index 00000000..a20e255d --- /dev/null +++ b/scripts/ci_summary.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# User-facing CI copy intentionally uses Chinese punctuation. +# ruff: noqa: RUF001 +"""Summarize one PR Validation run using native needs results.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +# This script runs with Python's isolated path. Keep the job contract local so +# a PR-added module beside the planner cannot intercept the shadow summary. +ALL_JOBS = ( + "quick", + "backend-standard", + "backend-full", + "frontend-standard", + "frontend-full", + "compose", + "security", + "windows", + "upgrade", + "compact", + "skills", + "policy", +) + + +def evaluate(plan: dict[str, Any] | None, needs: dict[str, Any]) -> tuple[bool, list[str]]: + if plan is None or not isinstance(plan.get("required"), list): + return False, ["计划缺失或生成失败"] + if needs.get("plan", {}).get("result") != "success": + return False, ["计划 job 未成功"] + required = plan["required"] + if ( + not required + or "quick" not in required + or len(required) != len(set(required)) + or not set(required) <= set(ALL_JOBS) + ): + return False, ["计划任务无效"] + if set(plan.get("not_applicable", [])) != set(ALL_JOBS) - set(required): + return False, ["不适用清单与必需任务不一致"] + lines = [ + f"base={plan.get('base_sha')} head={plan.get('head_sha')} tested={plan.get('tested_sha')}", + f"policy=scripts/ci_plan.py@{plan.get('policy_digest')}", + f"档位: {plan.get('tier')};原因: {', '.join(plan.get('reasons', []))}", + ] + valid = not plan.get("governance_change", False) + for key in ALL_JOBS: + if key in required: + result = needs.get(key, {}).get("result", "missing") + lines.append(f"{key}: {result}") + valid &= result == "success" + else: + lines.append(f"{key}: not_applicable") + if plan.get("governance_change"): + lines.append("治理文件需 Bootstrap 受信审核") + return valid, lines + + +def main() -> int: + raw_plan = os.environ.get("CI_PLAN", "") + try: + plan = json.loads(raw_plan) if raw_plan else None + needs = json.loads(os.environ["CI_NEEDS"]) + except (json.JSONDecodeError, KeyError): + plan, needs = None, {} + valid, lines = evaluate(plan, needs) + text = "\n".join(lines) + "\n" + print(text) + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(summary).open("a", encoding="utf-8") as output: + output.write("### PR Validation Shadow\n\n```text\n" + text + "```\n") + return 0 if valid else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/required_gate.py b/scripts/required_gate.py index 47cb7851..4942df28 100644 --- a/scripts/required_gate.py +++ b/scripts/required_gate.py @@ -1,459 +1,324 @@ #!/usr/bin/env python3 # User-facing CI copy intentionally uses Chinese punctuation. # ruff: noqa: RUF001 -"""Aggregate path-selected GitHub Actions checks into one stable required gate.""" +"""Publish the one protected status from trusted PR metadata and one Actions run. + +This script runs only from the default branch in a privileged workflow. It never +checks out or executes PR content, and never waits for another workflow. +""" from __future__ import annotations -import argparse +import hashlib import json import os -import re -import time -from collections.abc import Iterable, Mapping -from dataclasses import dataclass +import sys from pathlib import Path -from typing import TypedDict, cast +from typing import Any, cast from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen -GITHUB_API_URL = "https://api.github.com" -GITHUB_ACTIONS_APP_ID = 15368 -SUCCESS = "success" -TERMINAL_FAILURES = frozenset( - {"action_required", "cancelled", "failure", "stale", "startup_failure", "timed_out"} -) -RUN_ID_PATTERN = re.compile(r"/actions/runs/(?P\d+)/") - -BACKEND_SCRIPTS = frozenset( - { - "scripts/capacity_s11.py", - "scripts/capacity_s19.py", - "scripts/capacity_s29.py", - "scripts/capacity_workflow.py", - "scripts/smoke_s3.py", - "scripts/smoke_s4.py", - "scripts/smoke_s5.py", - "scripts/smoke_s6.py", - "scripts/smoke_s7.py", - "scripts/smoke_s8.py", - "scripts/smoke_s9.py", - "scripts/smoke_s10.py", - "scripts/smoke_s11.py", - "scripts/smoke_s18.py", - "scripts/smoke_s19.py", - "scripts/smoke_s21.py", - "scripts/smoke_s22.py", - "scripts/smoke_s23.py", - "scripts/smoke_s24.py", - "scripts/smoke_s25.py", - "scripts/smoke_s26.py", - "scripts/smoke_s27.py", - "scripts/smoke_s28.py", - "scripts/smoke_s29.py", - "scripts/smoke_s30.py", - "scripts/smoke_s47.py", - "scripts/storage_transfer.py", - "scripts/verify_kafka_compat.py", - "scripts/verify_s47_2_contract_migration.py", - "scripts/verify_s47_3_contract_migration.py", - } -) -COMPOSE_SCRIPTS = ( - BACKEND_SCRIPTS - - { - "scripts/verify_s47_2_contract_migration.py", - "scripts/verify_s47_3_contract_migration.py", - } -) | { - "scripts/backup.sh", - "scripts/smoke_s31.py", - "scripts/smoke_s32.py", - "scripts/verify_restore.sh", -} -UPGRADE_SCRIPTS = frozenset( - { - "scripts/smoke_s11.py", - "scripts/storage_transfer.py", - "scripts/verify_v2_v3_data.py", - "scripts/verify_v2_v3_upgrade.sh", - } -) - - -@dataclass(frozen=True, slots=True) -class GateSpec: - key: str - label: str - workflow_path: str - checks: tuple[str, ...] - prefixes: tuple[str, ...] = () - exact_paths: frozenset[str] = frozenset() - always_required: bool = False - - def required_for(self, paths: frozenset[str]) -> bool: - return self.always_required or any( - path in self.exact_paths or path.startswith(self.prefixes) for path in paths - ) +from ci_plan import GitHub, Plan, PlanError + +CONTEXT = "Required Gate" +WORKFLOW = ".github/workflows/pr-validation.yml" +PLAN_PREFIX = "CI Plan " +SHADOW_PREFIX = "PR Validation Shadow " +SHA_CHARS = frozenset("0123456789abcdef") + + +class GateError(RuntimeError): + """The metadata cannot justify a protected success status.""" + + +def valid_sha(value: Any) -> bool: + return isinstance(value, str) and len(value) == 40 and set(value) <= SHA_CHARS + + +def require_pr(pr: dict[str, Any], repository: str, default_branch: str) -> None: + if pr.get("state") != "open" or pr.get("base", {}).get("ref") != default_branch: + raise GateError("PR 未开放或目标不是受信默认分支") + if pr.get("base", {}).get("repo", {}).get("full_name") != repository: + raise GateError("PR 基线仓库不匹配") + if not valid_sha(pr.get("base", {}).get("sha")) or not valid_sha(pr.get("head", {}).get("sha")): + raise GateError("PR base/head SHA 无效") + + +def validate_merge(gh: GitHub, pr: dict[str, Any]) -> str: + merge_sha = pr.get("merge_commit_sha") + if not valid_sha(merge_sha): + raise GateError("PR 测试合并 SHA 缺失") + commit = gh.get(f"commits/{merge_sha}") + if not isinstance(commit, dict): + raise GateError("测试合并提交响应无效") + parents = commit.get("parents", []) + if [item.get("sha") for item in parents] != [pr["base"]["sha"], pr["head"]["sha"]]: + raise GateError("测试合并提交与当前 base/head 不匹配") + return cast(str, merge_sha) + + +def pr_validation_identity(pr: dict[str, Any]) -> tuple[object, ...]: + """Identify the PR revision and policy inputs without mutable repository metadata.""" + base = pr.get("base") + head = pr.get("head") + if not isinstance(base, dict) or not isinstance(head, dict): + raise GateError("PR base/head 元数据无效") + base_repo = base.get("repo") or {} + head_repo = head.get("repo") or {} + labels = frozenset( + item.get("name") + for item in pr.get("labels", []) + if isinstance(item, dict) and item.get("name") in {"ci:light", "ci:milestone"} + ) + return ( + pr.get("state"), + base.get("sha"), + base.get("ref"), + base_repo.get("full_name"), + head.get("sha"), + head.get("ref"), + head_repo.get("full_name"), + pr.get("merge_commit_sha"), + labels, + ) -GATE_SPECS = ( - GateSpec( - key="backend", - label="Backend CI", - workflow_path=".github/workflows/backend-ci.yml", - checks=("test", "integration"), - prefixes=("backend/", "skills/"), - exact_paths=BACKEND_SCRIPTS | {".github/workflows/backend-ci.yml"}, - ), - GateSpec( - key="frontend", - label="Frontend CI", - workflow_path=".github/workflows/frontend-ci.yml", - checks=("build",), - prefixes=("frontend/",), - exact_paths=frozenset({".github/workflows/frontend-ci.yml"}), - ), - GateSpec( - key="security", - label="Security CI", - workflow_path=".github/workflows/security-ci.yml", - checks=("source-and-images",), - always_required=True, - ), - GateSpec( - key="compose", - label="Compose Smoke Test", - workflow_path=".github/workflows/compose-ci.yml", - checks=("smoke",), - prefixes=( - "backend/", - "frontend/", - "mock-target/", - "deploy/compact/", - "deploy/compatibility/", - "deploy/s47/", - ), - exact_paths=COMPOSE_SCRIPTS | {"compose.yaml", ".github/workflows/compose-ci.yml"}, - ), - GateSpec( - key="standalone", - label="Standalone Windows Bundle", - workflow_path=".github/workflows/standalone-windows.yml", - checks=("bundle",), - prefixes=("backend/", "frontend/", "deploy/standalone/"), - exact_paths=frozenset( +def fingerprint(plan: Plan) -> str: + return hashlib.sha256(plan.to_json().encode()).hexdigest() + + +def evaluate_jobs(run_id: int, jobs: list[dict[str, Any]], plan: Plan) -> tuple[bool, str]: + names: dict[str, dict[str, Any]] = {} + for job in jobs: + name = job.get("name") + if not isinstance(name, str) or name in names or job.get("run_id") != run_id: + return False, "运行任务清单有重复或来源不匹配" + names[name] = job + expected = ( + PLAN_PREFIX + plan.tested_sha, + SHADOW_PREFIX + plan.tested_sha + " " + fingerprint(plan), + ) + for name in expected: + matched = names.get(name) + if ( + not matched + or matched.get("status") != "completed" + or matched.get("conclusion") != "success" + ): + return False, "计划或影子汇总未成功,或计划与当前 PR 不一致" + return True, "当前 PR 验证及受信计划均通过" + + +def evaluate_run( + run: dict[str, Any], + jobs: list[dict[str, Any]], + plan: Plan, + repository: str, + workflow_id: int, +) -> tuple[bool, str]: + if run.get("repository", {}).get("full_name") != repository: + return False, "工作流仓库不匹配" + if run.get("workflow_id") != workflow_id or run.get("path", "").split("@")[0] != WORKFLOW: + return False, "工作流身份不匹配" + if run.get("event") != "pull_request" or run.get("head_sha") != plan.head_sha: + return False, "运行事件或 Head 不匹配" + if run.get("display_title") != "PR Validation active": + return False, "运行入口不匹配" + if run.get("status") != "completed" or run.get("conclusion") != "success": + return False, "PR 验证运行未成功" + if plan.governance_change: + return False, "CI 治理变更需要 Bootstrap 审核" + return evaluate_jobs(run["id"], jobs, plan) + + +class Publisher(GitHub): + def post(self, sha: str, state: str, description: str, run_id: int) -> None: + if not valid_sha(sha) or state not in {"pending", "failure", "success"}: + raise GateError("状态参数无效") + payload = json.dumps( { - ".github/workflows/standalone-windows.yml", - "scripts/storage_transfer.py", + "state": state, + "context": CONTEXT, + "description": description[:140], + "target_url": f"https://github.com/{self.repository}/actions/runs/{run_id}", } - ), - ), - GateSpec( - key="upgrade", - label="V2 to V3 Upgrade CI", - workflow_path=".github/workflows/upgrade-ci.yml", - checks=("rehearse-v2-to-v3-upgrade-and-rollback",), - prefixes=("backend/", "deploy/upgrade/", "mock-target/"), - exact_paths=UPGRADE_SCRIPTS | {".github/workflows/upgrade-ci.yml"}, - ), -) - -LIGHT_GATE_SPEC = GateSpec( - key="quick", - label="Quick CI", - workflow_path=".github/workflows/quick-ci.yml", - checks=("quick",), - always_required=True, -) - -CI_GOVERNANCE_PATHS = frozenset( - {spec.workflow_path for spec in GATE_SPECS} - | { - ".github/workflows/required-gate.yml", - "scripts/required_gate.py", - } -) -CI_GOVERNANCE_PREFIXES = (".github/workflows/",) -LIGHT_ALLOWED_EXACT_PATHS = frozenset({"README.md", ".github/PULL_REQUEST_TEMPLATE.md"}) - - -@dataclass(frozen=True, slots=True) -class GatePlan: - required: tuple[GateSpec, ...] - no_op: tuple[GateSpec, ...] - - -class CheckApp(TypedDict, total=False): - id: int - - -class CheckRun(TypedDict, total=False): - id: int - name: str - status: str - conclusion: str | None - details_url: str - app: CheckApp | None - - -class WorkflowRun(TypedDict, total=False): - id: int - path: str - event: str - head_sha: str - - -class RequiredGateError(RuntimeError): - """Raised when a required child check cannot satisfy the gate.""" - - -class GitHubClient: - def __init__(self, *, repository: str, token: str) -> None: - self._repository = repository - self._token = token - - def check_runs(self, sha: str) -> list[CheckRun]: - encoded_sha = quote(sha, safe="") - payload = self._get( - f"/repos/{self._repository}/commits/{encoded_sha}/check-runs?filter=latest&per_page=100" - ) - if not isinstance(payload, dict) or not isinstance(payload.get("check_runs"), list): - raise RequiredGateError("GitHub Checks API 返回了无效响应") - return cast(list[CheckRun], payload["check_runs"]) - - def workflow_run(self, run_id: int) -> WorkflowRun: - payload = self._get(f"/repos/{self._repository}/actions/runs/{run_id}") - if not isinstance(payload, dict): - raise RequiredGateError("GitHub Actions API 返回了无效响应") - return cast(WorkflowRun, payload) - - def _get(self, path: str) -> object: - request = Request( # noqa: S310 - URL always starts with the fixed GitHub API origin - f"{GITHUB_API_URL}{path}", + ).encode() + request = Request( + f"https://api.github.com/repos/{self.repository}/statuses/{sha}", + data=payload, + method="POST", headers={ + "Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "flowtest-required-gate", }, ) - with urlopen(request, timeout=30) as response: # noqa: S310 - fixed GitHub API origin - return json.load(response) - - -def build_gate_plan(paths: Iterable[str], *, mode: str = "full") -> GatePlan: - if mode == "light": - return GatePlan(required=(LIGHT_GATE_SPEC,), no_op=GATE_SPECS) - if mode != "full": - raise RequiredGateError(f"未知 CI 模式:{mode}") - normalized = _normalized_paths(paths) - required = tuple(spec for spec in GATE_SPECS if spec.required_for(normalized)) - no_op = tuple(spec for spec in GATE_SPECS if spec not in required) - return GatePlan(required=required, no_op=no_op) - - -def enforce_trusted_governance(paths: Iterable[str], event_name: str) -> None: - if event_name != "pull_request_target": - return - modified = sorted( - path - for path in _normalized_paths(paths) - if path in CI_GOVERNANCE_PATHS or path.startswith(CI_GOVERNANCE_PREFIXES) + try: + with urlopen(request, timeout=20) as response: # noqa: S310 - fixed HTTPS host + if response.status != 201: + raise GateError("GitHub 未确认状态发布") + except (HTTPError, URLError) as exc: + raise GateError("Required Gate 状态发布失败") from exc + + def jobs(self, run_id: int) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for page in range(1, 31): + payload = self.get(f"actions/runs/{run_id}/jobs?filter=latest&per_page=100&page={page}") + if not isinstance(payload, dict) or not isinstance(payload.get("jobs"), list): + raise GateError("Actions 任务清单无效") + batch = payload["jobs"] + result.extend(batch) + if len(batch) < 100: + if len(result) != payload.get("total_count"): + raise GateError("Actions 任务清单不完整") + return result + raise GateError("Actions 任务清单达到分页上限") + + +def associated_pr_number(gh: Publisher, run: dict[str, Any]) -> int: + references = run.get("pull_requests") or [] + numbers = {item.get("number") for item in references if isinstance(item, dict)} + if not numbers: + head_sha = run.get("head_sha") + if not valid_sha(head_sha): + raise GateError("运行 Head 无效") + pulls = gh.get(f"commits/{quote(cast(str, head_sha), safe='')}/pulls") + if not isinstance(pulls, list): + raise GateError("提交关联 PR 响应无效") + numbers = {item.get("number") for item in pulls if item.get("state") == "open"} + if len(numbers) != 1 or not all(isinstance(number, int) for number in numbers): + raise GateError("运行未唯一关联开放 PR") + return cast(int, numbers.pop()) + + +def latest_run(gh: Publisher, pr: dict[str, Any]) -> dict[str, Any]: + sha = pr["head"]["sha"] + payload = gh.get( + f"actions/workflows/pr-validation.yml/runs?event=pull_request&head_sha={sha}&per_page=100" ) - if modified: - raise RequiredGateError( - "CI 治理文件只能通过受控 Bootstrap 流程更新: " + ", ".join(modified) - ) - - -def enforce_light_scope(paths: Iterable[str], mode: str) -> None: - if mode != "light": + if not isinstance(payload, dict) or not isinstance(payload.get("workflow_runs"), list): + raise GateError("PR 验证运行列表无效") + if payload.get("total_count", 0) > 100: + raise GateError("PR 验证运行列表超过单页上限") + candidates = [ + run + for run in payload["workflow_runs"] + if run.get("display_title") == "PR Validation active" + and run.get("head_branch") == pr["head"]["ref"] + and run.get("head_repository", {}).get("full_name") == pr["head"]["repo"]["full_name"] + ] + if not candidates: + raise GateError("当前 PR 没有有效验证运行") + return max(candidates, key=lambda run: run["id"]) + + +def current_event_run( + gh: Publisher, event: dict[str, Any], default_branch: str +) -> tuple[dict[str, Any], dict[str, Any], int, dict[str, Any]] | None: + notified = event.get("workflow_run", {}) + if notified.get("display_title") != "PR Validation active": + return None + if notified.get("repository", {}).get("full_name") != gh.repository: + raise GateError("完成事件仓库不匹配") + number = associated_pr_number(gh, notified) + pr = gh.get(f"pulls/{number}") + if not isinstance(pr, dict): + raise GateError("PR 元数据无效") + require_pr(pr, gh.repository, default_branch) + if notified.get("head_sha") != pr["head"]["sha"]: + return None + current = latest_run(gh, pr) + if notified.get("id") != current.get("id") or notified.get("run_attempt") != current.get( + "run_attempt" + ): + return None # A stale notification cannot overwrite the current attempt. + return notified, pr, number, current + + +def publish_pending_run(gh: Publisher, event: dict[str, Any], default_branch: str) -> None: + resolved = current_event_run(gh, event, default_branch) + if resolved is None: return - normalized = _normalized_paths(paths) - disallowed = sorted( - path - for path in normalized - if path not in LIGHT_ALLOWED_EXACT_PATHS - and not (path.startswith("docs/") and path.endswith(".md")) - ) - if not normalized or disallowed: - raise RequiredGateError( - "ci:light 只适用于 README、PR 模板和 docs/ 下的 Markdown 文档;" - "其他文件请使用 ci:milestone:" + ", ".join(disallowed) - ) - - -def _normalized_paths(paths: Iterable[str]) -> frozenset[str]: - return frozenset(path.strip().removeprefix("./") for path in paths if path.strip()) + notified, pr, _, current = resolved + workflow = gh.get("actions/workflows/pr-validation.yml") + run = gh.get(f"actions/runs/{current['id']}") + if not isinstance(workflow, dict) or not isinstance(run, dict): + raise GateError("运行身份不可用") + if run.get("status") == "completed": + return # A delayed requested/in_progress event cannot replace a final result. + if ( + run.get("workflow_id") != workflow.get("id") + or run.get("id") != notified.get("id") + or run.get("run_attempt") != notified.get("run_attempt") + or run.get("path", "").split("@")[0] != WORKFLOW + or run.get("event") != "pull_request" + ): + raise GateError("pending 运行身份不匹配") + gh.post(pr["head"]["sha"], "pending", "等待当前 PR 验证与受信汇总", current["id"]) + + +def publish_complete(gh: Publisher, event: dict[str, Any], default_branch: str) -> None: + resolved = current_event_run(gh, event, default_branch) + if resolved is None: + return + notified, pr, number, current = resolved + publish_checked_run(gh, notified, pr, number, current) -def wait_for_required_checks( - *, - client: GitHubClient, - plan: GatePlan, - sha: str, - timeout_seconds: int, - poll_seconds: int, +def publish_checked_run( + gh: Publisher, + notified: dict[str, Any], + pr: dict[str, Any], + number: int, + current: dict[str, Any], ) -> None: - deadline = time.monotonic() + timeout_seconds - workflow_cache: dict[int, WorkflowRun] = {} - previous_summary = "" - while time.monotonic() < deadline: - states = _check_states(client, plan.required, sha, workflow_cache) - summary = _state_summary(states) - if summary != previous_summary: - print(summary, flush=True) - previous_summary = summary - failures = [name for name, state in states.items() if state.startswith("failed:")] - if failures: - raise RequiredGateError("子门禁失败: " + ", ".join(failures)) - if states and all(state == SUCCESS for state in states.values()): - return - time.sleep(max(1, poll_seconds)) - raise RequiredGateError(f"等待子门禁超时({timeout_seconds} 秒)") - - -def _check_states( - client: GitHubClient, - specs: tuple[GateSpec, ...], - sha: str, - workflow_cache: dict[int, WorkflowRun], -) -> dict[str, str]: - check_runs = sorted( - client.check_runs(sha), key=lambda item: int(item.get("id", 0)), reverse=True - ) - states: dict[str, str] = {} - for spec in specs: - for check_name in spec.checks: - key = f"{spec.label}/{check_name}" - run = _matching_check_run( - client, - check_runs, - check_name, - spec.workflow_path, - sha, - workflow_cache, - ) - states[key] = _check_state(run) - return states - - -def _matching_check_run( - client: GitHubClient, - check_runs: list[CheckRun], - check_name: str, - workflow_path: str, - sha: str, - workflow_cache: dict[int, WorkflowRun], -) -> CheckRun | None: - for check_run in check_runs: - if check_run.get("name") != check_name: - continue - if not _is_github_actions_check(check_run): - continue - run_id = _workflow_run_id(check_run.get("details_url", "")) - if run_id is None: - continue - workflow = workflow_cache.get(run_id) - if workflow is None: - workflow = client.workflow_run(run_id) - workflow_cache[run_id] = workflow - if _workflow_matches(workflow, workflow_path, sha): - return check_run - return None - - -def _is_github_actions_check(check_run: CheckRun) -> bool: - app = check_run.get("app") - return app is not None and app.get("id") == GITHUB_ACTIONS_APP_ID - - -def _workflow_run_id(details_url: str) -> int | None: - match = RUN_ID_PATTERN.search(details_url) - return int(match.group("run_id")) if match else None - - -def _workflow_matches(workflow: WorkflowRun, expected_path: str, sha: str) -> bool: - workflow_path = str(workflow.get("path", "")).split("@", 1)[0] - return ( - workflow_path == expected_path - and workflow.get("head_sha") == sha - and workflow.get("event") in {"pull_request", "push"} + merge_sha = validate_merge(gh, pr) + try: + plan = gh.pr_plan(number, merge_sha) + except PlanError as exc: + gh.post(pr["head"]["sha"], "failure", "受信 CI 计划不可用", current["id"]) + raise GateError("受信 CI 计划不可用") from exc + workflow = gh.get("actions/workflows/pr-validation.yml") + if not isinstance(workflow, dict) or not isinstance(workflow.get("id"), int): + raise GateError("PR 工作流身份不可用") + run = gh.get(f"actions/runs/{current['id']}") + if not isinstance(run, dict) or run.get("run_attempt") != notified.get("run_attempt"): + raise GateError("运行实例已改变") + valid, description = evaluate_run( + run, gh.jobs(current["id"]), plan, gh.repository, workflow["id"] ) - - -def _check_state(check_run: CheckRun | None) -> str: - if check_run is None or check_run.get("status") != "completed": - return "pending" - conclusion = check_run.get("conclusion") - if conclusion == SUCCESS: - return SUCCESS - if conclusion in TERMINAL_FAILURES: - return f"failed:{conclusion}" - return f"failed:{conclusion or 'unknown'}" - - -def _state_summary(states: Mapping[str, str]) -> str: - return ";".join(f"{name}={state}" for name, state in states.items()) - - -def _write_step_summary(plan: GatePlan, *, status: str) -> None: - summary_path = os.getenv("GITHUB_STEP_SUMMARY") - if not summary_path: + refreshed = gh.get(f"pulls/{number}") + if not isinstance(refreshed, dict) or pr_validation_identity( + refreshed + ) != pr_validation_identity(pr): + return # A new PR state must get its own run and status. + if latest_run(gh, pr).get("id") != current["id"]: return - required = ", ".join(spec.label for spec in plan.required) or "无" - no_op = ", ".join(spec.label for spec in plan.no_op) or "无" - with Path(summary_path).open("a", encoding="utf-8") as summary: - summary.write( - "## Required Gate\n\n" - f"- 状态:{status}\n" - f"- 必需子门禁:{required}\n" - f"- No-op Success:{no_op}\n" - ) - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repository", default=os.getenv("GITHUB_REPOSITORY", "")) - parser.add_argument("--sha", required=True) - parser.add_argument("--paths-file", type=Path, required=True) - parser.add_argument("--event-name", default=os.getenv("GITHUB_EVENT_NAME", "")) - parser.add_argument("--mode", choices=("full", "light"), default="full") - parser.add_argument("--timeout-seconds", type=int, default=5400) - parser.add_argument("--poll-seconds", type=int, default=30) - return parser.parse_args() + gh.post(plan.head_sha, "success" if valid else "failure", description, current["id"]) + if not valid: + raise GateError(description) def main() -> int: - args = _arguments() - token = os.getenv("GITHUB_TOKEN", "") - if not args.repository or not token: - print("Required Gate 缺少 GITHUB_REPOSITORY 或 GITHUB_TOKEN", flush=True) - return 2 - paths = args.paths_file.read_text(encoding="utf-8").splitlines() - plan = build_gate_plan(paths, mode=args.mode) - _write_step_summary(plan, status="等待子门禁") - required = ", ".join(spec.label for spec in plan.required) - no_op = ", ".join(spec.label for spec in plan.no_op) or "无" - print(f"必需子门禁:{required};No-op Success:{no_op}", flush=True) try: - enforce_trusted_governance(paths, args.event_name) - enforce_light_scope(paths, args.mode) - wait_for_required_checks( - client=GitHubClient(repository=args.repository, token=token), - plan=plan, - sha=args.sha, - timeout_seconds=args.timeout_seconds, - poll_seconds=args.poll_seconds, - ) - except (RequiredGateError, HTTPError, URLError) as error: - _write_step_summary(plan, status=f"失败:{error}") - print(f"Required Gate 失败:{error}", flush=True) + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + repository = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GITHUB_TOKEN"] + default_branch = event["repository"]["default_branch"] + gh = Publisher(repository, token) + if os.environ["GITHUB_EVENT_NAME"] != "workflow_run": + raise GateError("不支持的门禁事件") + if event.get("action") == "completed": + publish_complete(gh, event, default_branch) + elif event.get("action") in {"requested", "in_progress"}: + publish_pending_run(gh, event, default_branch) + else: + raise GateError("不支持的 workflow_run 活动类型") + except (GateError, KeyError, ValueError, PlanError) as exc: + print(f"Required Gate: {exc}", file=sys.stderr) return 1 - _write_step_summary(plan, status="成功") - print("Required Gate 成功", flush=True) return 0