From 967c782feb0a54895cbdb3b13fcf71d4491682d3 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Wed, 28 Oct 2026 11:52:33 +0800 Subject: [PATCH 1/8] ci: scope expensive validation to affected changes --- .github/ci-paths.toml | 94 +++++++++- .github/workflows/candidate-schema-replay.yml | 18 +- .github/workflows/capture-release-schema.yml | 16 +- .github/workflows/ci-changes.yml | 10 +- .github/workflows/desktop.yml | 24 ++- .github/workflows/e2e-harness.yml | 7 +- .github/workflows/kimi-code-acceptance.yml | 3 + .github/workflows/master.yml | 29 +++- .github/workflows/native-service.yml | 3 + .github/workflows/seekdb-lock-probe.yml | 12 +- .github/workflows/skill-guidance.yml | 23 +-- .github/workflows/zcode-acceptance.yml | 3 + docs/en/development/ci-scope.md | 31 ++++ docs/en/development/meta.json | 1 + docs/zh/development/ci-scope.md | 31 ++++ docs/zh/development/meta.json | 1 + scripts/ci_changes.py | 72 ++++++-- tests/test_ci_changes.py | 162 +++++++++++++++++- 18 files changed, 472 insertions(+), 68 deletions(-) create mode 100644 docs/en/development/ci-scope.md create mode 100644 docs/zh/development/ci-scope.md diff --git a/.github/ci-paths.toml b/.github/ci-paths.toml index 1127e2d91e..4ab3c0e73e 100644 --- a/.github/ci-paths.toml +++ b/.github/ci-paths.toml @@ -1,8 +1,9 @@ -# Shared runtime, dependency, and CI changes can affect every consumer. +# Shared runtime and dependency changes can affect every consumer. # Patterns use Python fnmatch semantics: * also matches path separators. full = [ - "src/**", "openapi/**", "pyproject.toml", "uv.lock", "Makefile", "tox.ini", - ".mise.toml", ".pre-commit-config.yaml", ".github/actions/**", ".github/workflows/**", + "Makefile", "tox.ini", + ".mise.toml", ".pre-commit-config.yaml", ".github/actions/setup-python-env/**", + ".github/workflows/ci-changes.yml", ".github/ci-paths.toml", "scripts/ci_changes.py", "tests/test_ci_changes.py", "tests/conftest.py", "tests/**/conftest.py", "tests/fixtures/**", "integrations/capabilities.toml", "integrations/langchain/pyproject.toml", "integrations/langgraph/pyproject.toml", @@ -13,6 +14,7 @@ full = [ ignore = [ "AGENTS.md", "REVIEW.md", "CONTRIBUTING.md", "GOVERNANCE.md", "LICENSE", "LICENSES/**", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md", ".github/CODEOWNERS", + ".github/dependabot.yml", ".github/workflows/license-check.yml", ".licenserc.yaml", ] [suites] @@ -32,6 +34,9 @@ database = [ "tests/e2e/*native_code*", "tests/e2e/test_recall_sufficiency_gate.py", "tests/e2e/test_context_text_assembly.py", "tests/e2e/test_scope_search_migration.py", "scripts/run_atomic_memory_clean_migration.py", + ".github/workflows/e2e-harness.yml", ".github/actions/setup-upgrade-oceanbase/**", + "tests/native/test_oceanbase_engine_recovery.py", + "tests/*migration*.py", ] dify = ["integrations/dify/**", "tests/*dify*"] hermes = ["integrations/hermes/**", "tests/*hermes*"] @@ -57,12 +62,91 @@ zcode = [ "integrations/zcode/**", "evaluation/zcode_guidance/**", "tests/*zcode*", "integrations/pi/**", "tests/e2e/test_cross_host_work_continuity.py", "tests/e2e/cross_host_work_continuity/**", ] -kimi = ["integrations/kimi-code/**", "tests/*kimi*"] +kimi = ["integrations/kimi-code/**", "tests/*kimi*", ".github/workflows/kimi-code-acceptance.yml"] +upgrade = [ + "release/**", "scripts/upgrade_*.py", "scripts/ci_legacy_upgrade_rejection.py", + "scripts/ci_release_smoke.py", "scripts/ci_ops_bundle_smoke.py", "scripts/capture_release_schema.py", + "tests/release_upgrades/**", "tests/test_upgrade*.py", "tests/test_registered_source_maintenance_cli.py", + "tests/test_managed_*maintenance.py", "tests/e2e/*migration*", "tests/e2e/*managed_startup*", + "tests/test_managed_startup*.py", "tests/builtin/persistence/**", "tests/*migration*.py", + ".github/workflows/upgrade-release.yml", ".github/workflows/release.yml", + ".github/actions/setup-upgrade-oceanbase/**", +] +observability = [ + "tests/*observability*", "tests/*logging*", "tests/*tracing*", "tests/e2e/*worker*", + "tests/e2e/*observability*", "tests/builtin/runtime/*worker*", "tests/builtin/runtime/*model_usage*", +] +packaging = [ + "tests/test_installation.py", "tests/test_upgrade_publication.py", "scripts/ci_role_smoke.py", "release/**", + ".github/workflows/release.yml", ".github/workflows/build-artifacts.yml", + ".github/workflows/build-docker.yml", "docker/**", +] +desktop = [ + "desktop/**", "tests/test_desktop_fixture_qualification.py", "website/assets/**", + ".github/workflows/desktop.yml", +] +candidate_replay = [ + "src/powercontext/builtin/persistence/**", "src/powercontext/builtin/artifacts/**", "pyproject.toml", "uv.lock", + "tests/builtin/persistence/**", "scripts/replay_candidate_migration.py", + ".github/workflows/candidate-schema-replay.yml", ".github/actions/setup-upgrade-oceanbase/**", +] +release_schema = [ + "scripts/capture_release_schema.py", "scripts/upgrade_database_probe.py", "scripts/upgrade_process.py", + "tests/release_upgrades/test_full_server.py", ".github/workflows/capture-release-schema.yml", + ".github/actions/setup-upgrade-oceanbase/**", +] +seekdb_lock = ["scripts/probe_seekdb_migration_lock.py", ".github/workflows/seekdb-lock-probe.yml"] +skill_guidance = [ + "evaluation/skills/skill-up/**", + "integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/**", + ".github/workflows/skill-guidance.yml", +] # Packaged guidance and its generators are inputs to the native host tests. -[shared] +[shared.guidance] patterns = [ "scripts/integration_guidance*", "scripts/evaluate_integration_guidance.py", "scripts/guidance_templates/**", "tests/test_integration_guidance*", "tests/test_guidance*", "tests/test_setup_rollback.py", ] suites = ["tests", "dify", "hermes", "dsh", "pi", "opencode", "openclaw", "zcode", "kimi"] + +# Shared Python changes still exercise all runtime consumers. Diagnostic source +# capture and lock probes have their own inputs rather than joining every build. +[shared.runtime] +patterns = ["src/**", "openapi/**", "pyproject.toml", "uv.lock"] +suites = [ + "tests", "evaluation", "database", "dify", "hermes", "dsh", "pi", "opencode", "openclaw", "opendal", + "website", "native", "harness", "zcode", "kimi", "upgrade", "observability", "packaging", "desktop", +] + +[shared.openapi_guidance] +patterns = ["openapi/powercontext.yaml"] +suites = ["skill_guidance"] + +# Editing one workflow must exercise its consumers without launching unrelated hosts. +[shared.main] +patterns = [".github/workflows/master.yml"] +suites = [ + "tests", "evaluation", "database", "dify", "hermes", "dsh", "pi", "opencode", "openclaw", "opendal", + "website", "docs_tests", "upgrade", "observability", "packaging", +] + +[shared.native] +patterns = [".github/workflows/native-service.yml"] +suites = ["native"] + +[shared.zcode] +patterns = [".github/workflows/zcode-acceptance.yml"] +suites = ["zcode"] + +[shared.node_cache] +patterns = [".github/actions/cache-pnpm/**"] +suites = ["desktop", "website", "dsh", "pi", "opencode", "openclaw"] + +[shared.docs_deploy] +patterns = [".github/workflows/deploy-docs.yml"] +suites = ["website"] + +[shared.release_verify] +patterns = [".github/workflows/release-verify.yml"] +suites = ["upgrade", "packaging"] diff --git a/.github/workflows/candidate-schema-replay.yml b/.github/workflows/candidate-schema-replay.yml index dc30de99ff..3e8f37c03c 100644 --- a/.github/workflows/candidate-schema-replay.yml +++ b/.github/workflows/candidate-schema-replay.yml @@ -2,20 +2,13 @@ name: Candidate schema replay on: pull_request: - paths: - - src/powercontext/builtin/persistence/** - - src/powercontext/builtin/artifacts/** - - tests/builtin/persistence/** - - scripts/replay_candidate_migration.py - - .github/workflows/candidate-schema-replay.yml - - .github/actions/setup-upgrade-oceanbase/** - - pyproject.toml - - uv.lock + types: [opened, synchronize, reopened, ready_for_review] push: branches: [master] workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -23,7 +16,14 @@ concurrency: cancel-in-progress: true jobs: + changes: + uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: candidate-schema-replay.yml + replay: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').candidate_replay) }} name: Candidate replay (${{ matrix.backend }}) runs-on: ubuntu-24.04 timeout-minutes: 30 diff --git a/.github/workflows/capture-release-schema.yml b/.github/workflows/capture-release-schema.yml index ddcfda28c7..6e6e56c785 100644 --- a/.github/workflows/capture-release-schema.yml +++ b/.github/workflows/capture-release-schema.yml @@ -2,16 +2,11 @@ name: Capture released Server schema on: pull_request: - paths: - - scripts/capture_release_schema.py - - scripts/upgrade_database_probe.py - - scripts/upgrade_process.py - - tests/release_upgrades/test_full_server.py - - .github/workflows/capture-release-schema.yml - - .github/actions/setup-upgrade-oceanbase/** + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -19,7 +14,14 @@ concurrency: cancel-in-progress: true jobs: + changes: + uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: capture-release-schema.yml + capture: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').release_schema) }} name: Capture 1.4.0 (${{ matrix.backend }}, ${{ matrix.configuration }}) runs-on: ubuntu-24.04 timeout-minutes: 45 diff --git a/.github/workflows/ci-changes.yml b/.github/workflows/ci-changes.yml index 3d98a54ee4..dc1cb8f874 100644 --- a/.github/workflows/ci-changes.yml +++ b/.github/workflows/ci-changes.yml @@ -2,12 +2,18 @@ name: Detect CI scope on: workflow_call: + inputs: + baseline-workflow: + description: Calling workflow filename used to retain changes from cancelled pushes + required: true + type: string outputs: suites: description: JSON object of suites selected for this event value: ${{ jobs.detect.outputs.suites }} permissions: + actions: read contents: read jobs: @@ -17,7 +23,7 @@ jobs: outputs: suites: ${{ steps.select.outputs.suites }} steps: - - name: Check out the complete PR history + - name: Check out the complete branch history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 @@ -27,4 +33,6 @@ jobs: env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CI_BASELINE_WORKFLOW: ${{ inputs.baseline-workflow }} + GH_TOKEN: ${{ github.token }} run: python3 scripts/ci_changes.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index ee39e3ac73..e3343ba646 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -2,10 +2,9 @@ name: Desktop validation on: pull_request: - paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'tests/test_desktop_fixture_qualification.py', 'website/assets/**', '.github/workflows/desktop.yml'] + types: [opened, synchronize, reopened, ready_for_review] push: branches: [master, codex/desktop-preview] - paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'tests/test_desktop_fixture_qualification.py', 'website/assets/**', '.github/workflows/desktop.yml'] workflow_dispatch: inputs: installer_run: @@ -15,6 +14,7 @@ on: default: '' permissions: + actions: read contents: read concurrency: @@ -22,8 +22,16 @@ concurrency: cancel-in-progress: true jobs: + changes: + uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: desktop.yml + windows: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.installer_run == '' }} + needs: changes + if: >- + ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} runs-on: windows-2025 timeout-minutes: 40 defaults: @@ -148,7 +156,10 @@ jobs: if-no-files-found: ${{ job.status == 'success' && 'error' || 'warn' }} macos: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.installer_run == '' }} + needs: changes + if: >- + ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} strategy: fail-fast: false matrix: @@ -202,7 +213,10 @@ jobs: if-no-files-found: error linux: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.installer_run == '' }} + needs: changes + if: >- + ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} runs-on: ubuntu-24.04 timeout-minutes: 40 defaults: diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 686b8da638..7cce06ca98 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -17,6 +17,7 @@ on: - recall-effort permissions: + actions: read contents: read env: @@ -24,12 +25,14 @@ env: UV_VERSION: "0.10.12" concurrency: - group: e2e-harness-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: e2e-harness-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: changes: uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: e2e-harness.yml validate: needs: changes diff --git a/.github/workflows/kimi-code-acceptance.yml b/.github/workflows/kimi-code-acceptance.yml index 8d70880bf8..7b8ecf19a5 100644 --- a/.github/workflows/kimi-code-acceptance.yml +++ b/.github/workflows/kimi-code-acceptance.yml @@ -8,6 +8,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -22,6 +23,8 @@ env: jobs: changes: uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: kimi-code-acceptance.yml controlled-cli: needs: changes diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 12cc71e1de..94e6c1c7d1 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -18,6 +18,7 @@ on: - full permissions: + actions: read contents: read concurrency: @@ -26,6 +27,8 @@ concurrency: jobs: seekdb-managed-startup: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} name: SeekDB managed startup regression (priority) timeout-minutes: 20 runs-on: ubuntu-latest @@ -80,31 +83,41 @@ jobs: changes: uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: master.yml upgrade-acceptance: - needs: seekdb-managed-startup - # Collect selected upgrade cases even when the priority regression fails. - if: ${{ !cancelled() }} + needs: [changes, seekdb-managed-startup] + # Keep diagnostics when the selected priority regression fails. + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} uses: ./.github/workflows/upgrade-release.yml with: selection: ${{ github.event_name == 'workflow_dispatch' && inputs.upgrade_selection || 'automatic' }} upgrade-release-gate: name: Upgrade release gate - needs: upgrade-acceptance + needs: [changes, upgrade-acceptance] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Require the selected parallel acceptance workflow env: + SCOPE_RESULT: ${{ needs.changes.result }} + UPGRADE_SELECTED: ${{ fromJSON(needs.changes.outputs.suites || '{}').upgrade }} UPGRADE_RESULT: ${{ needs.upgrade-acceptance.result }} - run: test "$UPGRADE_RESULT" = success + run: | + if [ "$SCOPE_RESULT" = success ] && [ "$UPGRADE_SELECTED" = false ]; then + test "$UPGRADE_RESULT" = skipped + echo "Upgrade acceptance is not required by this change." + else + test "$UPGRADE_RESULT" = success + fi oceanbase-engine-recovery: name: OceanBase engine crash recovery needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} runs-on: ubuntu-24.04 timeout-minutes: 20 env: @@ -247,7 +260,7 @@ jobs: isolated-installations: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').packaging) }} timeout-minutes: 20 runs-on: ubuntu-latest strategy: @@ -318,6 +331,8 @@ jobs: tests/e2e/test_mcp_full_profile.py worker-observability: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').observability) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index 69e1055663..3e8e03b304 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -9,6 +9,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -18,6 +19,8 @@ concurrency: jobs: changes: uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: native-service.yml linux-systemd-user: needs: changes diff --git a/.github/workflows/seekdb-lock-probe.yml b/.github/workflows/seekdb-lock-probe.yml index 5172aa0c78..a4b2519163 100644 --- a/.github/workflows/seekdb-lock-probe.yml +++ b/.github/workflows/seekdb-lock-probe.yml @@ -2,12 +2,11 @@ name: SeekDB migration lock diagnostics on: pull_request: - paths: - - scripts/probe_seekdb_migration_lock.py - - .github/workflows/seekdb-lock-probe.yml + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -15,7 +14,14 @@ concurrency: cancel-in-progress: true jobs: + changes: + uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: seekdb-lock-probe.yml + reproduce: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').seekdb_lock) }} runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.github/workflows/skill-guidance.yml b/.github/workflows/skill-guidance.yml index 743071218f..d6b1e85ae6 100644 --- a/.github/workflows/skill-guidance.yml +++ b/.github/workflows/skill-guidance.yml @@ -2,25 +2,28 @@ name: Skill guidance validation on: pull_request: - paths: - - evaluation/skills/skill-up/** - - openapi/powercontext.yaml - - integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/** - - .github/workflows/skill-guidance.yml + types: [opened, synchronize, reopened, ready_for_review] push: branches: [master] - paths: - - evaluation/skills/skill-up/** - - openapi/powercontext.yaml - - integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/** - - .github/workflows/skill-guidance.yml workflow_dispatch: permissions: + actions: read contents: read +concurrency: + group: skill-guidance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + changes: + uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: skill-guidance.yml + validate: + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').skill_guidance) }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/zcode-acceptance.yml b/.github/workflows/zcode-acceptance.yml index fc74e32c7d..31aa35448f 100644 --- a/.github/workflows/zcode-acceptance.yml +++ b/.github/workflows/zcode-acceptance.yml @@ -10,6 +10,7 @@ on: - cron: '23 3 * * 0' permissions: + actions: read contents: read concurrency: @@ -26,6 +27,8 @@ env: jobs: changes: uses: ./.github/workflows/ci-changes.yml + with: + baseline-workflow: zcode-acceptance.yml controlled-cli: needs: changes diff --git a/docs/en/development/ci-scope.md b/docs/en/development/ci-scope.md new file mode 100644 index 0000000000..ed0e9dfe82 --- /dev/null +++ b/docs/en/development/ci-scope.md @@ -0,0 +1,31 @@ +# CI scope selection + +Continuous integration uses `.github/ci-paths.toml` to select task groups before allocating their runners. Each participating workflow calls `ci-changes.yml` with its own filename. A documentation change selects website and documentation-contract checks; an isolated host plugin selects its existing Python regressions and host checks. Unrelated upgrade, worker observability, installation-role, Desktop and diagnostic matrices are skipped. + +The lightweight scope jobs, Main quality checks, license check and upgrade result gate can still appear on an unrelated change. A skipped upgrade matrix is an explicit not-required result, not evidence of upgrade acceptance. A selected matrix must succeed; failure, cancellation or unexpected skipping does not pass its gate. Release publication continues to call the installed-release acceptance workflow directly and does not use path selection to bypass acceptance. + +## Comparison baseline + +- Pull requests compare the merge base with the complete PR head, including all commits. Unrelated changes on the base branch are excluded. +- Pushes compare the current commit with a successful ancestor from the same workflow and branch. The selector examines up to 100 successful push runs through the read-only Actions API. An ancestor check rejects rewritten histories. Cancelled or failed runs do not become baselines, so their unverified changes remain in the next comparison. +- Missing history, unavailable API access, malformed metadata or a failed diff selects every task group. A new workflow with no successful baseline therefore starts with full validation. +- Scheduled and manual runs select every task group available in that workflow. Workflow-specific manual inputs, such as Desktop installer diagnostics or the recall-only harness, retain their own restrictions. + +The selector reads Git history rather than GitHub's truncated path-filter list. Rename detection is disabled so both the removed and added paths participate. Branch names are encoded as API query parameters; commit identities must be full hexadecimal SHAs. The scope summary records the comparison range, selected groups and fallback reason. + +## Maintaining ownership rules + +`[suites]` assigns files to individual task groups. `[shared.]` assigns shared inputs to multiple consumers. A workflow change selects its own consumers, and a composite action change selects workflows that use that action. The selection configuration, selector, shared test fixtures and shared environment setup retain full coverage. Unknown paths also select every group until their ownership is declared. + +Shared Python runtime, API and root dependency changes conservatively exercise runtime consumers, backend and upgrade acceptance, worker observability and packaging. Historical schema capture and the SeekDB lock diagnostic have separate inputs; a routine runtime change does not launch those development tools. Python-version and backend matrices are preserved when their groups are selected. + +When adding an input, include its consumers outside the directory that owns it. For example, Pi changes also select ZCode cross-host acceptance; a migration-related test must select the corresponding real-backend acceptance. Add a selection regression in `tests/test_ci_changes.py`. Verify moves, deletions, unavailable diffs and accumulated push changes when altering comparison behavior. + +Run focused validation with: + +```sh +uv run pytest -q tests/test_ci_changes.py +uv run python scripts/check_workflow_actions.py .github/workflows .github/actions +``` + +Local scope tests exercise real Git histories and simulated Actions run metadata. They do not execute the GitHub scheduler or establish native-host/backend acceptance. Measure queue wait, runner minutes and launched job counts after deployment before claiming a throughput improvement. diff --git a/docs/en/development/meta.json b/docs/en/development/meta.json index 39bb09b4cd..b67b973289 100644 --- a/docs/en/development/meta.json +++ b/docs/en/development/meta.json @@ -4,6 +4,7 @@ "defaultOpen": false, "pages": [ "releasing", + "ci-scope", "core-protocol", "memory-layer", "plugin-contract", diff --git a/docs/zh/development/ci-scope.md b/docs/zh/development/ci-scope.md new file mode 100644 index 0000000000..31b18ffd8e --- /dev/null +++ b/docs/zh/development/ci-scope.md @@ -0,0 +1,31 @@ +# CI 范围选择 + +持续集成通过 `.github/ci-paths.toml` 选择任务组,再为选中的任务分配 runner。各工作流使用自己的文件名调用 `ci-changes.yml`。文档变更选择网站与文档契约检查;独立宿主插件变更选择现有 Python 回归与对应宿主检查。无关的升级、worker 可观测性、安装角色、Desktop 和诊断矩阵会跳过。 + +无关变更仍可能出现轻量范围检测、Main 质量检查、许可证检查和升级结果门禁。升级矩阵跳过时,门禁明确表示本次不需要该验收,不代表升级已经通过。选中的矩阵必须成功;失败、取消或意外跳过不能通过门禁。发布仍直接调用安装包升级验收工作流,不通过路径选择绕过验收。 + +## 比较基线 + +- PR 比较 merge-base 到整个 PR head 的累计变化,覆盖全部提交,排除基准分支上的无关变更。 +- push 比较同一工作流、同一分支上成功祖先提交到当前提交的变化。选择器通过只读 Actions API 查询最近最多 100 次成功的 push 运行,并检查祖先关系,拒绝采用重写历史中的不相关提交。取消或失败的运行不能作为基线,其尚未验证的变更会留在下一次比较中。 +- 历史缺失、API 无法访问、元数据格式异常或差异获取失败时,选择全部任务组。因此,没有成功基线的新工作流会先执行完整验证。 +- 定时和手动运行选择该工作流中的全部任务组。Desktop 安装包诊断、仅 recall 的 harness 等手动参数继续保留各自的限制。 + +选择器读取 Git 历史,不依赖 GitHub 截断后的路径过滤列表。关闭重命名检测,使移除和新增路径都参与选择。分支名按 API 查询参数编码,提交身份必须是完整十六进制 SHA。范围摘要记录比较区间、选择结果和回退原因。 + +## 维护归属规则 + +`[suites]` 将文件映射到单个任务组,`[shared.]` 将共享输入映射到多个消费者。修改工作流会选择它自己的消费者,修改 composite action 会选择使用该 action 的工作流。选择配置、选择器、共享测试夹具和共享环境安装继续保留完整覆盖。未知路径在声明归属前也会选择全部任务组。 + +共享 Python Runtime、API 和根依赖变更仍保守覆盖运行时消费者、后端与升级验收、worker 可观测性及打包。历史 schema 捕获和 SeekDB 锁诊断具有独立输入,普通运行时代码变更不会启动这些开发工具。任务组选中后,原有 Python 版本和后端矩阵保持完整。 + +增加输入时,应包含所属目录之外的消费者。例如,Pi 变更也选择 ZCode 跨宿主验收;迁移相关测试变更应选择对应的真实后端验收。在 `tests/test_ci_changes.py` 增加选择回归。调整比较行为时,应验证移动、删除、差异不可用和 push 累计变更。 + +运行聚焦验证: + +```sh +uv run pytest -q tests/test_ci_changes.py +uv run python scripts/check_workflow_actions.py .github/workflows .github/actions +``` + +本地范围测试使用真实 Git 历史和模拟 Actions 运行元数据,不执行 GitHub 调度器,也不代表原生宿主或后端验收。部署后应测量排队时间、runner 分钟和启动 job 数,再判断吞吐量是否改善。 diff --git a/docs/zh/development/meta.json b/docs/zh/development/meta.json index 26d69af5ee..c0c5290533 100644 --- a/docs/zh/development/meta.json +++ b/docs/zh/development/meta.json @@ -4,6 +4,7 @@ "defaultOpen": false, "pages": [ "releasing", + "ci-scope", "core-protocol", "memory-layer", "plugin-contract", diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index 59817c341f..983fd9bf3b 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Select PR checks from the complete Git diff; other events run every suite.""" +"""Select checks from complete PR changes or unverified branch changes.""" from __future__ import annotations @@ -26,6 +26,7 @@ from fnmatch import fnmatchcase from pathlib import Path from typing import Any +from urllib.parse import urlencode ROOT = Path(__file__).resolve().parents[1] @@ -40,8 +41,9 @@ def select_suites(paths: list[str], rules: dict[str, Any]) -> dict[str, bool]: for suite, patterns in rules["suites"].items() if any(fnmatchcase(path, pattern) for pattern in patterns) } - if any(fnmatchcase(path, pattern) for pattern in rules["shared"]["patterns"]): - matches.update(rules["shared"]["suites"]) + for group in rules["shared"].values(): + if any(fnmatchcase(path, pattern) for pattern in group["patterns"]): + matches.update(group["suites"]) if not matches and not any(fnmatchcase(path, pattern) for pattern in rules["ignore"]): # New directories must not silently bypass checks before they have an owner. return dict.fromkeys(selected, True) @@ -50,14 +52,18 @@ def select_suites(paths: list[str], rules: dict[str, Any]) -> dict[str, bool]: return selected -def changed_paths(base: str, head: str) -> list[str]: +def changed_paths(base: str, head: str, *, pull_request: bool = True) -> list[str]: if not all(re.fullmatch(r"[0-9a-fA-F]{40}", commit) for commit in (base, head)): message = "Expected full base and head commit SHAs" raise ValueError(message) + if not pull_request: + # A rewritten branch must not silently drop changes from the old baseline. + subprocess.run(["git", "merge-base", "--is-ancestor", base, head], check=True, cwd=ROOT) # noqa: S603, S607 + comparison = f"{base}...{head}" if pull_request else f"{base}..{head}" # Three-dot comparison covers the whole PR, excluding unrelated base changes. # Disabling rename detection includes both sides of moves across suite boundaries. result = subprocess.run( # noqa: S603 -- validated SHAs, no shell - ["git", "diff", "--name-only", "--no-renames", "-z", f"{base}...{head}", "--"], # noqa: S607 -- runner tool + ["git", "diff", "--name-only", "--no-renames", "-z", comparison, "--"], # noqa: S607 -- runner tool check=True, capture_output=True, cwd=ROOT, @@ -65,18 +71,62 @@ def changed_paths(base: str, head: str) -> list[str]: return [os.fsdecode(path) for path in result.stdout.split(b"\0") if path] +def successful_baseline(workflow: str, repository: str, branch: str, head: str) -> str: + """Find a successful ancestor for this workflow, retaining cancelled changes.""" + if ( + not re.fullmatch(r"[A-Za-z0-9_-]+\.ya?ml", workflow) + or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) + or not re.fullmatch(r"[0-9a-fA-F]{40}", head) + ): + raise ValueError("Expected a workflow filename and repository owner/name") # noqa: TRY003 + query = urlencode({"branch": branch, "event": "push", "status": "success", "per_page": 100}) + result = subprocess.run( # noqa: S603 -- validated API path, encoded query, no shell + ["gh", "api", f"repos/{repository}/actions/workflows/{workflow}/runs?{query}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + timeout=30, + cwd=ROOT, + ) + for run in json.loads(result.stdout)["workflow_runs"]: + candidate = run["head_sha"] + if not re.fullmatch(r"[0-9a-fA-F]{40}", candidate): + continue + ancestor = subprocess.run( # noqa: S603 -- validated commit SHAs, no shell + ["git", "merge-base", "--is-ancestor", candidate, head], # noqa: S607 + capture_output=True, + cwd=ROOT, + check=False, + ) + if ancestor.returncode == 0: + return candidate + raise ValueError("No successful workflow ancestor found") # noqa: TRY003 + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", "")) parser.add_argument("--base", default=os.environ.get("PR_BASE_SHA", "")) - parser.add_argument("--head", default=os.environ.get("PR_HEAD_SHA", "")) + parser.add_argument("--head", default=os.environ.get("PR_HEAD_SHA") or os.environ.get("GITHUB_SHA", "")) + parser.add_argument("--baseline-workflow", default=os.environ.get("CI_BASELINE_WORKFLOW", "")) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "")) + parser.add_argument("--branch", default=os.environ.get("GITHUB_REF_NAME", "")) arguments = parser.parse_args() rules = tomllib.loads((ROOT / ".github/ci-paths.toml").read_text(encoding="utf-8")) - if arguments.event_name == "pull_request": + reason = "Full validation requested by this event." + if arguments.event_name in {"pull_request", "push"}: try: - selected = select_suites(changed_paths(arguments.base, arguments.head), rules) - except (OSError, subprocess.CalledProcessError, ValueError): - print("::warning::PR diff unavailable; running every CI suite.") + base = arguments.base + if arguments.event_name == "push" and not base: + base = successful_baseline( + arguments.baseline_workflow, arguments.repository, arguments.branch, arguments.head + ) + paths = changed_paths(base, arguments.head, pull_request=arguments.event_name == "pull_request") + selected = select_suites(paths, rules) + reason = f"Compared {base} to {arguments.head} ({len(paths)} changed paths)." + except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError): + reason = "A trustworthy change baseline is unavailable; running every CI suite." + print(f"::warning::{reason}") selected = dict.fromkeys(rules["suites"], True) else: selected = dict.fromkeys(rules["suites"], True) @@ -87,7 +137,7 @@ def main() -> None: output.write(f"suites={json.dumps(selected)}\n") if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"): with Path(summary_path).open("a", encoding="utf-8") as summary: - summary.write("## CI scope\n\n| Suite | Run |\n| --- | --- |\n") + summary.write(f"## CI scope\n\n{reason}\n\n| Suite | Run |\n| --- | --- |\n") for suite, enabled in selected.items(): summary.write(f"| {suite} | {'yes' if enabled else 'no'} |\n") diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index f5b4c9e4fc..f48129e662 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -22,7 +22,9 @@ import tomllib from pathlib import Path +import ci_changes import pytest +import yaml from ci_changes import select_suites ROOT = Path(__file__).parents[1] @@ -41,11 +43,29 @@ (["evaluation/coding/work_continuity/run.py"], {"evaluation"}), (["evaluation/zcode_guidance/pin.py"], {"evaluation", "zcode"}), (["tests/e2e/test_kimi_code_host.py"], {"tests", "kimi"}), - (["tests/builtin/persistence/test_mysql_schema.py"], {"tests", "database"}), + (["tests/builtin/persistence/test_mysql_schema.py"], {"tests", "database", "upgrade", "candidate_replay"}), (["tests/native/test_personal_service_lifecycle.py"], {"tests", "native"}), (["e2e/bub/compose.yaml"], {"harness"}), (["scripts/scan_e2e_evidence.sh"], {"tests", "harness"}), (["scripts/sync_dsh_plugin_tree.py"], {"tests", "dsh"}), + (["desktop/ui/src/app/App.tsx"], {"desktop"}), + (["release/upgrade-support.json"], {"upgrade", "packaging"}), + ([".github/workflows/desktop.yml"], {"desktop"}), + ([".github/workflows/native-service.yml"], {"native"}), + ([".github/workflows/kimi-code-acceptance.yml"], {"kimi"}), + ([".github/workflows/zcode-acceptance.yml"], {"zcode"}), + ([".github/workflows/upgrade-release.yml"], {"upgrade"}), + ( + [".github/actions/setup-upgrade-oceanbase/action.yml"], + {"database", "upgrade", "candidate_replay", "release_schema"}, + ), + (["tests/e2e/test_worker_otlp.py"], {"tests", "observability"}), + (["scripts/ci_role_smoke.py"], {"tests", "packaging"}), + (["scripts/capture_release_schema.py"], {"upgrade", "release_schema"}), + (["scripts/probe_seekdb_migration_lock.py"], {"seekdb_lock"}), + (["tests/test_database_migration_cli.py"], {"tests", "database", "upgrade"}), + ([".github/workflows/skill-guidance.yml"], {"skill_guidance"}), + ([".github/workflows/license-check.yml"], set()), (["integrations/bub/src/powercontext_bub/__init__.py"], {"tests", "harness"}), ( ["docs/en/docs/quickstart.md", "integrations/opencode/package.json"], @@ -65,12 +85,8 @@ def test_pr_selects_affected_suites(paths: list[str], expected: set[str]) -> Non @pytest.mark.parametrize( "path", [ - "src/powercontext/client.py", - "pyproject.toml", - "uv.lock", - "openapi/powercontext.yaml", ".github/actions/setup-python-env/action.yml", - ".github/workflows/master.yml", + ".github/workflows/ci-changes.yml", ".github/ci-paths.toml", "tests/e2e/conftest.py", "new-component/code.py", @@ -80,6 +96,16 @@ def test_shared_or_unclassified_changes_run_every_suite(path: str) -> None: assert all(select_suites(["docs/en/docs/quickstart.md", path], RULES).values()) +@pytest.mark.parametrize("path", ["src/powercontext/server/authz/owner_recovery.py", "uv.lock", "pyproject.toml"]) +def test_shared_runtime_inputs_keep_backend_upgrade_and_consumer_coverage(path: str) -> None: + selected = select_suites([path], RULES) + assert {"tests", "database", "upgrade", "observability", "packaging", "native", "desktop", "pi", "zcode"} <= { + suite for suite, enabled in selected.items() if enabled + } + assert not selected["release_schema"] + assert not selected["seekdb_lock"] + + def git(repository: Path, *arguments: str) -> str: return subprocess.check_output(["git", "-C", str(repository), *arguments], text=True).strip() # noqa: S607 @@ -195,10 +221,12 @@ def test_moves_and_deletions_keep_the_original_suite(repository: Path) -> None: def test_large_diff_includes_code_after_hundreds_of_docs(repository: Path) -> None: base = git(repository, "rev-parse", "HEAD") head = commit(repository, *(f"docs/page-{index}.md" for index in range(350)), "src/powercontext/client.py") - assert all(run_selector(repository, "pull_request", base, head).values()) + selected = run_selector(repository, "pull_request", base, head) + assert selected["tests"] and selected["database"] and selected["upgrade"] + assert selected["desktop"] and selected["website"] -@pytest.mark.parametrize("event", ["push", "workflow_dispatch", "schedule"]) +@pytest.mark.parametrize("event", ["workflow_dispatch", "schedule", "merge_group"]) def test_non_pr_events_run_every_suite(repository: Path, event: str) -> None: head = commit(repository, "docs/README.md") assert all(run_selector(repository, event, head, head).values()) @@ -207,3 +235,121 @@ def test_non_pr_events_run_every_suite(repository: Path, event: str) -> None: @pytest.mark.parametrize("base", ["", "not-a-commit", "0" * 40]) def test_unavailable_diff_runs_every_suite(repository: Path, base: str) -> None: assert all(run_selector(repository, "pull_request", base, git(repository, "rev-parse", "HEAD")).values()) + + +def test_push_uses_two_dot_range_from_the_verified_baseline(repository: Path) -> None: + base = git(repository, "rev-parse", "HEAD") + commit(repository, "scripts/upgrade_release_gate.py") + head = commit(repository, "docs/zh/说明.md") + + selected = run_selector(repository, "push", base, head) + + assert {suite for suite, enabled in selected.items() if enabled} == {"upgrade", "website", "docs_tests"} + + +def test_rewritten_push_baseline_runs_every_suite(repository: Path) -> None: + initial = git(repository, "rev-parse", "HEAD") + old_head = commit(repository, "integrations/pi/index.ts") + git(repository, "checkout", "-b", "rewritten", initial) + head = commit(repository, "docs/new.md") + + assert all(run_selector(repository, "push", old_head, head).values()) + + +def test_successful_push_baseline_retains_changes_from_cancelled_runs(repository: Path, monkeypatch) -> None: + base = git(repository, "rev-parse", "HEAD") + # The migration commit has no successful run; the docs push must still test it. + commit(repository, "scripts/upgrade_release_gate.py") + head = commit(repository, "docs/new.md") + real_run = subprocess.run + + def run(command, **kwargs): + if command[0] == "gh": + return subprocess.CompletedProcess(command, 0, json.dumps({"workflow_runs": [{"head_sha": base}]})) + return real_run(command, **kwargs) + + monkeypatch.setattr(ci_changes, "ROOT", repository) + monkeypatch.setattr(subprocess, "run", run) + output = repository / "outputs.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr( + sys, + "argv", + [ + "ci_changes.py", + "--event-name", + "push", + "--base", + "", + "--head", + head, + "--baseline-workflow", + "master.yml", + "--repository", + "example/project", + "--branch", + "master", + ], + ) + + ci_changes.main() + + selected = json.loads(output.read_text().removeprefix("suites=")) + assert {suite for suite, enabled in selected.items() if enabled} == {"upgrade", "website", "docs_tests"} + + +def test_no_successful_push_baseline_runs_every_suite(repository: Path) -> None: + head = commit(repository, "docs/new.md") + assert all(run_selector(repository, "push", "", head).values()) + + +def test_successful_baseline_ignores_a_newer_success_on_rewritten_history(repository: Path, monkeypatch) -> None: + base = git(repository, "rev-parse", "HEAD") + unrelated = commit(repository, "integrations/pi/index.ts") + git(repository, "checkout", "-b", "rewritten", base) + head = commit(repository, "docs/new.md") + real_run = subprocess.run + + def run(command, **kwargs): + if command[0] == "gh": + history = {"workflow_runs": [{"head_sha": unrelated}, {"head_sha": base}]} + return subprocess.CompletedProcess(command, 0, json.dumps(history)) + return real_run(command, **kwargs) + + monkeypatch.setattr(ci_changes, "ROOT", repository) + monkeypatch.setattr(subprocess, "run", run) + + assert ci_changes.successful_baseline("master.yml", "example/project", "master", head) == base + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="the Actions gate runs in Bash") +@pytest.mark.parametrize( + ("scope_result", "selected", "upgrade_result", "passes"), + [ + ("success", "false", "skipped", True), + ("success", "false", "failure", False), + ("success", "true", "success", True), + ("success", "true", "skipped", False), + ("success", "true", "failure", False), + ("success", "true", "cancelled", False), + ("failure", "", "skipped", False), + ("success", "", "skipped", False), + ], +) +def test_upgrade_gate_distinguishes_not_required_from_missing_acceptance( + scope_result: str, selected: str, upgrade_result: str, passes: bool +) -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/master.yml").read_text()) + script = workflow["jobs"]["upgrade-release-gate"]["steps"][0]["run"] + result = subprocess.run( + ["bash", "-e", "-c", script], # noqa: S607 -- execute the checked-in Actions gate + env={ + **os.environ, + "SCOPE_RESULT": scope_result, + "UPGRADE_SELECTED": selected, + "UPGRADE_RESULT": upgrade_result, + }, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) == passes From a672273a04cb70502220092e92d8197d2a17fe47 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Wed, 28 Oct 2026 12:08:53 +0800 Subject: [PATCH 2/8] fix(ci): validate harness changes through acceptance --- .github/ci-paths.toml | 1 + tests/test_ci_changes.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/.github/ci-paths.toml b/.github/ci-paths.toml index 4ab3c0e73e..79d98437a1 100644 --- a/.github/ci-paths.toml +++ b/.github/ci-paths.toml @@ -54,6 +54,7 @@ docs_tests = ["docs/**", "README*.md"] native = ["tests/native/**", "tests/test_service*.py", "tests/test_cursor_secret.py", "tests/test_server.py"] harness = [ "e2e/**", "integrations/bub/**", "scripts/scan_e2e_evidence.sh", "tests/test_e2e_workflow_contract.py", + ".github/workflows/e2e-harness.yml", "tests/e2e/test_atomic_memory_search_authorization_snapshot.py", "tests/e2e/test_atomic_memory_read_snapshot.py", "tests/e2e/test_runtime_server.py", ] diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index f48129e662..f9cc792895 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -179,6 +179,15 @@ def test_legacy_evaluation_and_cross_host_changes_select_behavioral_suites( assert {suite for suite, enabled in selected.items() if enabled} == expected +def test_harness_workflow_change_selects_acceptance_and_database_suites(repository: Path) -> None: + base = git(repository, "rev-parse", "HEAD") + head = commit(repository, ".github/workflows/e2e-harness.yml") + + selected = run_selector(repository, "pull_request", base, head) + + assert {suite for suite, enabled in selected.items() if enabled} == {"harness", "database"} + + def test_diff_covers_all_pr_commits_and_excludes_base_branch_changes(repository: Path) -> None: git(repository, "checkout", "-b", "feature") commit(repository, "integrations/pi/plugin.ts") From efee6a049a595796e55851745d76a86d7c748d92 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Wed, 28 Oct 2026 14:36:51 +0800 Subject: [PATCH 3/8] fix(ci): align upgrade gate tests and bound Windows tool setup --- .github/workflows/desktop.yml | 3 +++ tests/test_ci_changes.py | 34 ------------------------- tests/test_upgrade_release_gate.py | 40 ++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index e3343ba646..5ab76fc2b9 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -81,9 +81,12 @@ jobs: - name: Verify Windows vault across processes run: cargo run --locked --manifest-path src-tauri/Cargo.toml --example credential_probe - name: Set up Python fixture tooling + timeout-minutes: 5 uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: "0.10.12" + # Use the release source directly; a stalled mirror download can exhaust the job budget. + download-from-astral-mirror: false enable-cache: true cache-suffix: desktop-${{ github.job }}-py3.12 cache-dependency-glob: | diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index f9cc792895..218e73974e 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -24,7 +24,6 @@ import ci_changes import pytest -import yaml from ci_changes import select_suites ROOT = Path(__file__).parents[1] @@ -329,36 +328,3 @@ def run(command, **kwargs): monkeypatch.setattr(subprocess, "run", run) assert ci_changes.successful_baseline("master.yml", "example/project", "master", head) == base - - -@pytest.mark.skipif(shutil.which("bash") is None, reason="the Actions gate runs in Bash") -@pytest.mark.parametrize( - ("scope_result", "selected", "upgrade_result", "passes"), - [ - ("success", "false", "skipped", True), - ("success", "false", "failure", False), - ("success", "true", "success", True), - ("success", "true", "skipped", False), - ("success", "true", "failure", False), - ("success", "true", "cancelled", False), - ("failure", "", "skipped", False), - ("success", "", "skipped", False), - ], -) -def test_upgrade_gate_distinguishes_not_required_from_missing_acceptance( - scope_result: str, selected: str, upgrade_result: str, passes: bool -) -> None: - workflow = yaml.safe_load((ROOT / ".github/workflows/master.yml").read_text()) - script = workflow["jobs"]["upgrade-release-gate"]["steps"][0]["run"] - result = subprocess.run( - ["bash", "-e", "-c", script], # noqa: S607 -- execute the checked-in Actions gate - env={ - **os.environ, - "SCOPE_RESULT": scope_result, - "UPGRADE_SELECTED": selected, - "UPGRADE_RESULT": upgrade_result, - }, - capture_output=True, - check=False, - ) - assert (result.returncode == 0) == passes diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 2344c9d956..c91b04029e 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -448,8 +448,44 @@ def test_publishing_jobs_require_upgrade_acceptance_unless_manually_bypassed(): assert main["upgrade-acceptance"]["uses"] == build["uses"] verdict = main["upgrade-release-gate"] assert verdict["name"] == "Upgrade release gate" and verdict["if"] == "always()" - assert verdict["needs"] == "upgrade-acceptance" - assert verdict["steps"][0]["run"] == 'test "$UPGRADE_RESULT" = success' + dependencies = verdict["needs"] + assert {"changes", "upgrade-acceptance"} <= set([dependencies] if isinstance(dependencies, str) else dependencies) + assert not verdict.get("continue-on-error") + assert all(not step.get("continue-on-error") for step in verdict["steps"]) + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="the Actions gate runs in Bash") +@pytest.mark.parametrize( + ("scope_result", "selected", "upgrade_result", "passes"), + [ + ("success", "false", "skipped", True), + ("success", "false", "failure", False), + ("success", "true", "success", True), + ("success", "true", "skipped", False), + ("success", "true", "failure", False), + ("success", "true", "cancelled", False), + ("failure", "", "skipped", False), + ("success", "", "skipped", False), + ], +) +def test_upgrade_gate_distinguishes_not_required_from_missing_acceptance( + scope_result: str, selected: str, upgrade_result: str, passes: bool +) -> None: + root = Path(__file__).parents[1] + workflow = yaml.safe_load((root / ".github/workflows/master.yml").read_text()) + script = workflow["jobs"]["upgrade-release-gate"]["steps"][0]["run"] + result = subprocess.run( + ["bash", "-e", "-c", script], # noqa: S607 -- execute the checked-in Actions gate + env={ + **os.environ, + "SCOPE_RESULT": scope_result, + "UPGRADE_SELECTED": selected, + "UPGRADE_RESULT": upgrade_result, + }, + capture_output=True, + check=False, + ) + assert (result.returncode == 0) == passes @pytest.mark.parametrize( From b84f6681f8ab851062f1a0b216a37b32ed128f99 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Thu, 29 Oct 2026 19:30:57 +0800 Subject: [PATCH 4/8] ci: consolidate validation and tier pull request coverage --- .github/ci-paths.toml | 44 ++- .github/workflows/candidate-schema-replay.yml | 16 +- .github/workflows/capture-release-schema.yml | 14 +- .github/workflows/ci-changes.yml | 9 + .github/workflows/desktop.yml | 22 +- .github/workflows/e2e-harness.yml | 26 +- .github/workflows/kimi-code-acceptance.yml | 15 +- .github/workflows/license-check.yml | 6 +- .github/workflows/master.yml | 265 ++++++++++++++---- .github/workflows/native-service.yml | 20 +- .github/workflows/seekdb-lock-observation.yml | 3 - .github/workflows/seekdb-lock-probe.yml | 13 +- .github/workflows/skill-guidance.yml | 15 +- .github/workflows/upgrade-release.yml | 1 + .github/workflows/zcode-acceptance.yml | 17 +- docs/en/development/ci-scope.md | 119 ++++++-- docs/zh/development/ci-scope.md | 119 ++++++-- scripts/ci_changes.py | 36 +++ scripts/ci_queue_report.py | 130 +++++++++ tests/test_ci_changes.py | 44 ++- tests/test_ci_pipeline.py | 156 +++++++++++ tests/test_upgrade_release_gate.py | 3 +- 22 files changed, 926 insertions(+), 167 deletions(-) create mode 100644 scripts/ci_queue_report.py create mode 100644 tests/test_ci_pipeline.py diff --git a/.github/ci-paths.toml b/.github/ci-paths.toml index 248bc4ca0a..007f13a48f 100644 --- a/.github/ci-paths.toml +++ b/.github/ci-paths.toml @@ -3,8 +3,8 @@ full = [ "Makefile", "tox.ini", ".mise.toml", ".pre-commit-config.yaml", ".github/actions/setup-python-env/**", - ".github/workflows/ci-changes.yml", - ".github/ci-paths.toml", "scripts/ci_changes.py", "tests/test_ci_changes.py", + ".github/workflows/ci-changes.yml", ".github/workflows/master.yml", + ".github/ci-paths.toml", "scripts/ci_changes.py", "tests/test_ci_changes.py", "tests/test_ci_pipeline.py", "tests/conftest.py", "tests/**/conftest.py", "tests/fixtures/**", "integrations/capabilities.toml", "integrations/langchain/pyproject.toml", "integrations/langgraph/pyproject.toml", "integrations/opendal/pyproject.toml", @@ -15,6 +15,7 @@ ignore = [ "AGENTS.md", "REVIEW.md", "CONTRIBUTING.md", "GOVERNANCE.md", "LICENSE", "LICENSES/**", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md", ".github/CODEOWNERS", ".github/dependabot.yml", ".github/workflows/license-check.yml", ".licenserc.yaml", + "scripts/ci_queue_report.py", ] [suites] @@ -104,7 +105,7 @@ release_schema = [ "tests/release_upgrades/test_full_server.py", ".github/workflows/capture-release-schema.yml", ".github/actions/setup-upgrade-oceanbase/**", ] -seekdb_lock = ["scripts/probe_seekdb_migration_lock.py", ".github/workflows/seekdb-lock-probe.yml"] +seekdb_lock = ["scripts/probe_seekdb_migration_lock.py", ".github/workflows/seekdb-lock-probe.yml", ".github/workflows/seekdb-lock-observation.yml"] skill_guidance = [ "evaluation/skills/skill-up/**", "integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/**", @@ -132,14 +133,6 @@ suites = [ patterns = ["openapi/powercontext.yaml"] suites = ["skill_guidance"] -# Editing one workflow must exercise its consumers without launching unrelated hosts. -[shared.main] -patterns = [".github/workflows/master.yml"] -suites = [ - "tests", "evaluation", "database", "dify", "hermes", "dsh", "cindy", "pi", "opencode", "openclaw", "opendal", - "website", "docs_tests", "upgrade", "observability", "packaging", -] - [shared.native] patterns = [".github/workflows/native-service.yml"] suites = ["native"] @@ -159,3 +152,32 @@ suites = ["website"] [shared.release_verify] patterns = [".github/workflows/release-verify.yml"] suites = ["upgrade", "packaging"] + +# A selected scope must produce successful root jobs, not silent skips. +# Main's result gate consumes this contract; reusable workflows own their children. +[checks] +tests = ["tests", "windows-unit-portability"] +evaluation = ["evaluation-tests"] +database = ["oceanbase-engine-recovery", "code-seekdb", "atomic-memory-clean-seekdb", "harness-validation"] +dify = ["dify-tools"] +hermes = ["hermes-skills"] +dsh = ["dsh-package"] +cindy = ["cindy-package"] +pi = ["pi-package"] +opencode = ["opencode-package"] +openclaw = ["openclaw-package"] +opendal = ["opendal-package"] +website = ["check-website", "website-link-validation-windows"] +docs_tests = ["docs-tests"] +native = ["native-service"] +harness = ["harness-validation"] +zcode = ["zcode-acceptance"] +kimi = ["kimi-acceptance"] +upgrade = ["seekdb-managed-startup", "upgrade-acceptance"] +observability = ["worker-observability"] +packaging = ["isolated-installations"] +desktop = ["desktop-validation"] +candidate_replay = ["candidate-replay"] +release_schema = ["release-schema"] +seekdb_lock = ["seekdb-lock"] +skill_guidance = ["skill-guidance"] diff --git a/.github/workflows/candidate-schema-replay.yml b/.github/workflows/candidate-schema-replay.yml index 3e8f37c03c..b7f65fca51 100644 --- a/.github/workflows/candidate-schema-replay.yml +++ b/.github/workflows/candidate-schema-replay.yml @@ -1,10 +1,12 @@ name: Candidate schema replay on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - push: - branches: [master] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -12,22 +14,24 @@ permissions: contents: read concurrency: - group: candidate-replay-${{ github.event.pull_request.number || github.ref }} + group: candidate-replay-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: candidate-schema-replay.yml replay: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').candidate_replay) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').candidate_replay) }} name: Candidate replay (${{ matrix.backend }}) runs-on: ubuntu-24.04 timeout-minutes: 30 strategy: + max-parallel: 2 fail-fast: false matrix: backend: [sqlite, seekdb, oceanbase] diff --git a/.github/workflows/capture-release-schema.yml b/.github/workflows/capture-release-schema.yml index 6e6e56c785..1f8125013e 100644 --- a/.github/workflows/capture-release-schema.yml +++ b/.github/workflows/capture-release-schema.yml @@ -1,8 +1,12 @@ name: Capture released Server schema on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -10,22 +14,24 @@ permissions: contents: read concurrency: - group: release-schema-${{ github.event.pull_request.number || github.ref }} + group: release-schema-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: capture-release-schema.yml capture: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').release_schema) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').release_schema) }} name: Capture 1.4.0 (${{ matrix.backend }}, ${{ matrix.configuration }}) runs-on: ubuntu-24.04 timeout-minutes: 45 strategy: + max-parallel: 2 fail-fast: false matrix: backend: [sqlite, seekdb, oceanbase] diff --git a/.github/workflows/ci-changes.yml b/.github/workflows/ci-changes.yml index dc1cb8f874..d92991954a 100644 --- a/.github/workflows/ci-changes.yml +++ b/.github/workflows/ci-changes.yml @@ -11,6 +11,12 @@ on: suites: description: JSON object of suites selected for this event value: ${{ jobs.detect.outputs.suites }} + required-jobs: + description: Selected root jobs that must succeed + value: ${{ jobs.detect.outputs.required-jobs }} + test-matrix: + description: Python and suite combinations for this validation profile + value: ${{ jobs.detect.outputs.test-matrix }} permissions: actions: read @@ -22,6 +28,8 @@ jobs: timeout-minutes: 5 outputs: suites: ${{ steps.select.outputs.suites }} + test-matrix: ${{ steps.select.outputs.test-matrix }} + required-jobs: ${{ steps.select.outputs.required-jobs }} steps: - name: Check out the complete branch history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -34,5 +42,6 @@ jobs: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} CI_BASELINE_WORKFLOW: ${{ inputs.baseline-workflow }} + CI_FULL_VALIDATION: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} GH_TOKEN: ${{ github.token }} run: python3 scripts/ci_changes.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 90907879f0..372949fef0 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,10 +1,17 @@ name: Desktop validation on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string + installer_run: + type: string + default: '' push: - branches: [master, codex/desktop-preview] + branches: [codex/desktop-preview] workflow_dispatch: inputs: installer_run: @@ -18,11 +25,12 @@ permissions: contents: read concurrency: - group: desktop-${{ github.event.pull_request.number || github.ref }}-${{ inputs.installer_run || 'build' }} + group: desktop-${{ github.event.pull_request.number || github.ref }}-${{ inputs.installer_run || 'build' }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: desktop.yml @@ -31,7 +39,7 @@ jobs: needs: changes if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').desktop) }} runs-on: windows-2025 timeout-minutes: 40 defaults: @@ -162,7 +170,7 @@ jobs: needs: changes if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').desktop) }} strategy: fail-fast: false matrix: @@ -219,7 +227,7 @@ jobs: needs: changes if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').desktop) }} runs-on: ubuntu-24.04 timeout-minutes: 40 defaults: diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 3bb6371c54..5ac26e8cf1 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -1,11 +1,15 @@ name: E2E harness on: - push: - branches: - - master - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string + suite: + type: string + default: all workflow_dispatch: inputs: suite: @@ -25,11 +29,12 @@ env: UV_VERSION: "0.10.12" concurrency: - group: e2e-harness-${{ github.event.pull_request.number || github.ref }} + group: e2e-harness-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: e2e-harness.yml @@ -38,7 +43,7 @@ jobs: needs: changes if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.suite != 'recall-effort') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').harness) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').harness) }} runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -68,7 +73,7 @@ jobs: recall-effort: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').database) }} name: Recall effort (OceanBase) runs-on: ubuntu-latest timeout-minutes: 60 @@ -305,10 +310,11 @@ jobs: needs: changes if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.suite != 'recall-effort') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').database) }} runs-on: ubuntu-latest timeout-minutes: 145 strategy: + max-parallel: 2 fail-fast: false matrix: shard: [0, 1, 2] @@ -465,7 +471,7 @@ jobs: needs: [changes, atomic-memory-clean-oceanbase-shards] if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.suite != 'recall-effort') && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').database) }} runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/kimi-code-acceptance.yml b/.github/workflows/kimi-code-acceptance.yml index 7b8ecf19a5..a8a195f278 100644 --- a/.github/workflows/kimi-code-acceptance.yml +++ b/.github/workflows/kimi-code-acceptance.yml @@ -1,10 +1,12 @@ name: Kimi Code acceptance on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - push: - branches: [master] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -12,7 +14,7 @@ permissions: contents: read concurrency: - group: kimi-code-acceptance-${{ github.event.pull_request.number || github.ref }} + group: kimi-code-acceptance-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true env: @@ -22,13 +24,14 @@ env: jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: kimi-code-acceptance.yml controlled-cli: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').kimi) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').kimi) }} name: controlled-cli (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 15 diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index e611461631..ab8cce4cae 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -1,15 +1,13 @@ name: License Check on: - pull_request: - push: - branches: [master] + workflow_call: permissions: contents: read concurrency: - group: license-check-${{ github.event.pull_request.number || github.ref }} + group: license-check-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 7c801c47fc..95ae7b11b6 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -4,8 +4,11 @@ on: push: branches: - master + - main pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + schedule: + - cron: '17 20 * * *' workflow_dispatch: inputs: upgrade_selection: @@ -22,13 +25,17 @@ permissions: contents: read concurrency: - group: master-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + license-check: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + uses: ./.github/workflows/license-check.yml + seekdb-managed-startup: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} name: SeekDB managed startup regression (priority) timeout-minutes: 20 runs-on: ubuntu-latest @@ -82,14 +89,15 @@ jobs: retention-days: 7 changes: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: master.yml upgrade-acceptance: - needs: [changes, seekdb-managed-startup] + needs: [changes, seekdb-managed-startup, quality, tests] # Keep diagnostics when the selected priority regression fails. - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} uses: ./.github/workflows/upgrade-release.yml with: selection: ${{ github.event_name == 'workflow_dispatch' && inputs.upgrade_selection || 'full' }} @@ -97,7 +105,7 @@ jobs: upgrade-release-gate: name: Upgrade release gate needs: [changes, upgrade-acceptance] - if: always() + if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -116,8 +124,8 @@ jobs: oceanbase-engine-recovery: name: OceanBase engine crash recovery - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} runs-on: ubuntu-24.04 timeout-minutes: 20 env: @@ -175,6 +183,8 @@ jobs: retention-days: 7 quality: + needs: license-check + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -203,15 +213,15 @@ jobs: run: make contract-test tests: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + name: tests (${{ matrix.python-version }}, ${{ matrix.suite }}) + needs: [changes, quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} timeout-minutes: 40 runs-on: ubuntu-latest strategy: - matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] - suite: [unit, e2e] - fail-fast: false + fail-fast: true + max-parallel: 3 + matrix: ${{ fromJSON(needs.changes.outputs.test-matrix || '{"python-version":["3.11","3.12","3.13","3.14"],"suite":["unit","e2e"]}') }} defaults: run: shell: bash @@ -259,12 +269,13 @@ jobs: run: make e2e-test isolated-installations: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').packaging) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').packaging) }} timeout-minutes: 20 runs-on: ubuntu-latest strategy: - fail-fast: false + fail-fast: true + max-parallel: 2 matrix: python-version: ["3.11", "3.14"] extras: ["builtin", "cli", "builtin,cli", "server,cli"] @@ -306,8 +317,8 @@ jobs: "$RUNNER_TEMP/role-venv/bin/python" -I scripts/ci_role_smoke.py cli docs-tests: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').docs_tests) }} + needs: [changes, quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').docs_tests) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -331,8 +342,8 @@ jobs: tests/e2e/test_mcp_full_profile.py worker-observability: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').observability) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').observability) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -374,8 +385,8 @@ jobs: if-no-files-found: error evaluation-tests: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').evaluation) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').evaluation) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -436,8 +447,8 @@ jobs: echo "::error title=evaluation unit tests failed::${detail}" code-seekdb: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} timeout-minutes: 30 runs-on: ubuntu-latest strategy: @@ -536,8 +547,8 @@ jobs: if-no-files-found: ignore atomic-memory-clean-seekdb: - needs: [changes, seekdb-managed-startup] - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [changes, seekdb-managed-startup, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} name: Atomic Memory clean migration (SeekDB) timeout-minutes: 55 runs-on: ubuntu-latest @@ -598,8 +609,8 @@ jobs: retention-days: 7 windows-unit-portability: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} timeout-minutes: 20 runs-on: windows-latest steps: @@ -694,8 +705,8 @@ jobs: tests/test_service_environment.py dify-tools: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dify) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dify) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -760,8 +771,8 @@ jobs: run: make dify-test hermes-skills: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').hermes) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').hermes) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -787,8 +798,8 @@ jobs: run: uv run pytest tests/e2e/test_hermes_skills.py dsh-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dsh) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dsh) }} timeout-minutes: 30 runs-on: ubuntu-latest steps: @@ -834,8 +845,8 @@ jobs: run: uv run pytest tests/test_integration_guidance_evaluation.py::test_dsh_controlled_native_handoff_uses_only_mcp_tools_and_preserves_complete_carriers cindy-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').cindy) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').cindy) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -877,8 +888,8 @@ jobs: run: uv run pytest tests/e2e/test_cindy_mcp_chain.py tests/e2e/test_cindy_hooks_chain.py -q pi-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').pi) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').pi) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -906,8 +917,8 @@ jobs: run: uv run pytest "tests/test_integration_guidance_evaluation.py::test_native_adapter_handoff_uses_real_request_mapping_and_response_envelopes[pi]" opencode-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opencode) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opencode) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -930,8 +941,8 @@ jobs: run: uv run pytest "tests/test_integration_guidance_evaluation.py::test_native_adapter_handoff_uses_real_request_mapping_and_response_envelopes[opencode]" openclaw-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').openclaw) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').openclaw) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -956,8 +967,8 @@ jobs: run: mise exec node@24.15.0 -- uv run pytest tests/test_integration_guidance_evaluation.py::test_openclaw_handoff_adapter_preserves_generated_source_identity opendal-package: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opendal) }} + needs: [changes, quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opendal) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -976,8 +987,8 @@ jobs: website-link-validation-windows: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} + needs: [changes, quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} timeout-minutes: 20 runs-on: windows-latest steps: @@ -1002,8 +1013,8 @@ jobs: run: pnpm --dir website test:links check-website: - needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} + needs: [changes, quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -1031,3 +1042,157 @@ jobs: env: POWERCONTEXT_TEST_BLOG_SITE: "1" run: uv run pytest tests/e2e/test_blog_site.py -q + + harness-validation: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database || fromJSON(needs.changes.outputs.suites || '{}').harness) }} + uses: ./.github/workflows/e2e-harness.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + desktop-validation: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} + uses: ./.github/workflows/desktop.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + native-service: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').native) }} + uses: ./.github/workflows/native-service.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + kimi-acceptance: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').kimi) }} + uses: ./.github/workflows/kimi-code-acceptance.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + zcode-acceptance: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').zcode) }} + uses: ./.github/workflows/zcode-acceptance.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + skill-guidance: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').skill_guidance) }} + uses: ./.github/workflows/skill-guidance.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + candidate-replay: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').candidate_replay) }} + uses: ./.github/workflows/candidate-schema-replay.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + release-schema: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').release_schema) }} + uses: ./.github/workflows/capture-release-schema.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + seekdb-lock: + needs: [changes, quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').seekdb_lock) }} + uses: ./.github/workflows/seekdb-lock-probe.yml + with: + suites: ${{ needs.changes.outputs.suites }} + + ci-result: + name: CI result + needs: + - license-check + - seekdb-managed-startup + - changes + - upgrade-acceptance + - upgrade-release-gate + - oceanbase-engine-recovery + - quality + - tests + - isolated-installations + - docs-tests + - worker-observability + - evaluation-tests + - code-seekdb + - atomic-memory-clean-seekdb + - windows-unit-portability + - dify-tools + - hermes-skills + - dsh-package + - cindy-package + - pi-package + - opencode-package + - openclaw-package + - opendal-package + - website-link-validation-windows + - check-website + - harness-validation + - desktop-validation + - native-service + - kimi-acceptance + - zcode-acceptance + - skill-guidance + - candidate-replay + - release-schema + - seekdb-lock + if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require successful selected checks + env: + RESULTS: ${{ toJSON(needs) }} + REQUIRED: ${{ needs.changes.outputs.required-jobs }} + run: | + python3 - <<'PYTHON' + import json + import os + results = json.loads(os.environ["RESULTS"]) + failed = {name: job["result"] for name, job in results.items() + if job["result"] not in {"success", "skipped"}} + if failed: + raise SystemExit(f"CI checks did not succeed: {failed}") + required = json.loads(os.environ["REQUIRED"]) + if not {"changes", "quality", "license-check", "upgrade-release-gate"} <= set(required): + raise SystemExit("The required CI result contract is missing") + missing = {name: results.get(name, {}).get("result", "missing") for name in required + if results.get(name, {}).get("result") != "success"} + if missing: + raise SystemExit(f"Selected CI checks did not run successfully: {missing}") + print("All selected CI checks passed.") + PYTHON diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index 3e8e03b304..50a1907fdc 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -1,11 +1,12 @@ name: Native personal service on: - push: - branches: - - master - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -13,18 +14,19 @@ permissions: contents: read concurrency: - group: native-service-${{ github.event.pull_request.number || github.ref }} + group: native-service-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: native-service.yml linux-systemd-user: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').native) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -87,7 +89,7 @@ jobs: macos-launchagent: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').native) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} runs-on: macos-latest timeout-minutes: 30 env: @@ -143,7 +145,7 @@ jobs: windows-task-scheduler: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').native) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} runs-on: windows-latest timeout-minutes: 30 env: diff --git a/.github/workflows/seekdb-lock-observation.yml b/.github/workflows/seekdb-lock-observation.yml index fc281623cf..8962e0608e 100644 --- a/.github/workflows/seekdb-lock-observation.yml +++ b/.github/workflows/seekdb-lock-observation.yml @@ -1,9 +1,6 @@ name: SeekDB extended lock observation on: - pull_request: - paths: - - .github/workflows/seekdb-lock-observation.yml workflow_dispatch: permissions: diff --git a/.github/workflows/seekdb-lock-probe.yml b/.github/workflows/seekdb-lock-probe.yml index a4b2519163..7d5a4ebc70 100644 --- a/.github/workflows/seekdb-lock-probe.yml +++ b/.github/workflows/seekdb-lock-probe.yml @@ -1,8 +1,12 @@ name: SeekDB migration lock diagnostics on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -10,18 +14,19 @@ permissions: contents: read concurrency: - group: seekdb-lock-probe-${{ github.event.pull_request.number || github.ref }} + group: seekdb-lock-probe-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: seekdb-lock-probe.yml reproduce: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').seekdb_lock) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').seekdb_lock) }} runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/.github/workflows/skill-guidance.yml b/.github/workflows/skill-guidance.yml index d6b1e85ae6..44566d3c21 100644 --- a/.github/workflows/skill-guidance.yml +++ b/.github/workflows/skill-guidance.yml @@ -1,10 +1,12 @@ name: Skill guidance validation on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - push: - branches: [master] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: permissions: @@ -12,18 +14,19 @@ permissions: contents: read concurrency: - group: skill-guidance-${{ github.event.pull_request.number || github.ref }} + group: skill-guidance-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: skill-guidance.yml validate: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').skill_guidance) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').skill_guidance) }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/upgrade-release.yml b/.github/workflows/upgrade-release.yml index ad4748593d..d62a895969 100644 --- a/.github/workflows/upgrade-release.yml +++ b/.github/workflows/upgrade-release.yml @@ -247,6 +247,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 strategy: + max-parallel: 3 fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} env: diff --git a/.github/workflows/zcode-acceptance.yml b/.github/workflows/zcode-acceptance.yml index 31aa35448f..5ac24ab145 100644 --- a/.github/workflows/zcode-acceptance.yml +++ b/.github/workflows/zcode-acceptance.yml @@ -1,20 +1,20 @@ name: ZCode acceptance on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - push: - branches: [master] + workflow_call: + inputs: + suites: + description: Scope already selected by the Main orchestrator + required: true + type: string workflow_dispatch: - schedule: - - cron: '23 3 * * 0' permissions: actions: read contents: read concurrency: - group: zcode-acceptance-${{ github.event.pull_request.number || github.ref }} + group: zcode-acceptance-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} cancel-in-progress: true env: @@ -26,13 +26,14 @@ env: jobs: changes: + if: ${{ inputs.suites == '' }} uses: ./.github/workflows/ci-changes.yml with: baseline-workflow: zcode-acceptance.yml controlled-cli: needs: changes - if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').zcode) }} + if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').zcode) }} runs-on: windows-latest timeout-minutes: 30 defaults: diff --git a/docs/en/development/ci-scope.md b/docs/en/development/ci-scope.md index ed0e9dfe82..2c8c49af4e 100644 --- a/docs/en/development/ci-scope.md +++ b/docs/en/development/ci-scope.md @@ -1,31 +1,118 @@ -# CI scope selection +# GitHub CI design and operation -Continuous integration uses `.github/ci-paths.toml` to select task groups before allocating their runners. Each participating workflow calls `ci-changes.yml` with its own filename. A documentation change selects website and documentation-contract checks; an isolated host plugin selects its existing Python regressions and host checks. Unrelated upgrade, worker observability, installation-role, Desktop and diagnostic matrices are skipped. +CI must validate affected behavior while controlling duplicate work, wasted work and bursts of runner demand. The upstream default branch is `master`; a `main` mirror also receives trunk validation. Workflow policy, path ownership and required outcomes are reviewed with code. Release acceptance remains independent. -The lightweight scope jobs, Main quality checks, license check and upgrade result gate can still appear on an unrelated change. A skipped upgrade matrix is an explicit not-required result, not evidence of upgrade acceptance. A selected matrix must succeed; failure, cancellation or unexpected skipping does not pass its gate. Release publication continues to call the installed-release acceptance workflow directly and does not use path selection to bypass acceptance. +## Entry points and admission -## Comparison baseline +Ordinary PRs and trunk pushes start only `Main`. It selects the complete change scope once and calls existing reusable workflows. Called workflows reuse this selection without allocating another detector runner. The Desktop `codex/desktop-preview` branch and workflow-specific manual diagnostics retain independent entry points. -- Pull requests compare the merge base with the complete PR head, including all commits. Unrelated changes on the base branch are excluded. -- Pushes compare the current commit with a successful ancestor from the same workflow and branch. The selector examines up to 100 successful push runs through the read-only Actions API. An ancestor check rejects rewritten histories. Cancelled or failed runs do not become baselines, so their unverified changes remain in the next comparison. -- Missing history, unavailable API access, malformed metadata or a failed diff selects every task group. A new workflow with no successful baseline therefore starts with full validation. -- Scheduled and manual runs select every task group available in that workflow. Workflow-specific manual inputs, such as Desktop installer diagnostics or the recall-only harness, retain their own restrictions. +```mermaid +flowchart TD + E[PR / trunk push / daily / manual] --> S[One scope selection] + E --> L[License] + L --> Q[Quality and API contract] + S --> T[Selected Python tests] + Q --> T + Q --> W[Selected website and documentation checks] + T --> H[Selected backend, upgrade, platform and host acceptance] + H --> U[Upgrade release gate] + S --> R[CI result] + T --> R + W --> R + H --> R + U --> R +``` + +License or quality failure prevents downstream tests and heavy work from starting. Python-test failure prevents dependent backend, upgrade, platform and host acceptance from expanding. Website and documentation checks can run alongside Python tests after quality succeeds. Skipping downstream work never turns the original failure green. + +Draft PRs allocate no automatic CI runners. Marking a PR ready validates its complete diff; conversion back to draft cancels the old run. Only the latest automatic run survives for each PR. Schedule, push and manual entry points use separate concurrency groups so diagnostics do not cancel trunk or daily validation. These groups replace obsolete work for the same change; they are not cross-PR resource queues. + +## Validation profiles + +| Entry point or input | Python validation | Other acceptance | +| --- | --- | --- | +| Ordinary ready PR | 3.11/3.14 unit tests and 3.12 E2E: 3 combinations | Complete selected backend, upgrade, installation-role, platform and host acceptance | +| PR changes dependencies, shared fixtures, CI policy or unclassified files | 3.11–3.14 × unit/E2E: 8 combinations | Conservative affected or full selection | +| PR update/reopen already carrying `ci:full` | 8 combinations | Complete PR scope, without forcing unrelated diagnostics | +| `master`/`main` push | 8 combinations | Compare with a successful trunk baseline, retaining unverified changes | +| Daily at 20:17 UTC | 8 combinations | Full ordinary acceptance, excluding historical schema generation and lock experiments | +| Manual Main | 8 combinations | Full scope; upgrade selection defaults to `full`, with an explicit diagnostic subset available | +| Published release | Independent installed-package and publication checks | Complete upgrade evidence and validated publication bytes; PR scope cannot bypass acceptance | + +Ordinary PR Python combinations drop from eight to three, a 62.5% reduction in combination count. This is not a measured reduction in runner minutes or queue latency. Dependency and CI-policy changes keep the full matrix, so a PR editing CI itself may still run eight combinations. + +`ci:full` is read from opened/synchronize/reopened/ready PR events. Adding the label to a running PR does not dispatch another workflow: update the branch or reopen the PR afterward. Manual Main uses the repository ref selected in Actions. For a fork PR, select its branch in the source repository; a successful default-branch run is not evidence for the PR head. + +## Ownership and comparison baseline + +`.github/ci-paths.toml` declares independent scopes in `[suites]`, shared inputs in `[shared.*]`, and successful Main root jobs required for each selected scope in `[checks]`. A new scope must declare its required terminal jobs. + +- PRs compare their merge base to the complete head, covering all commits and excluding unrelated base changes. +- Trunk pushes compare with a successful ancestor from the same Main workflow and branch, examining up to 100 successful pushes. Failed or cancelled commits never become baselines; their unverified changes remain in the next comparison. +- Missing history, unavailable APIs or failed diffs select every scope and the full Python matrix. Unknown paths also fall back conservatively. +- Git supplies the full diff, without native path-filter truncation. Disabled rename detection includes both removed and added paths. +- Runtime, API and root dependencies conservatively cover consumers. Websites and isolated integrations retain their ownership rules. Extended lock observation is manual. Historical schema capture and short lock probes run for their own changed inputs or manual requests, outside ordinary daily validation. + +For example, documentation selects website and documentation-contract checks; Cindy changes select Python regression and Cindy-package checks; migrations and shared runtime changes retain relevant real-database and upgrade acceptance. A smaller PR Python matrix does not replace selected real backends with simulations. + +## Parallelism and cancellation budgets + +| Matrix | Maximum parallel jobs per run | Failure behavior | +| --- | --- | --- | +| Python tests | 3 | Stop remaining combinations after the first failure | +| Isolated installation roles | 2 | Stop remaining combinations after the first failure | +| Installed-release upgrades | 3 | Preserve all cases and complete evidence collection | +| OceanBase clean-migration shards | 2 | Preserve all shards and completeness checks | +| Candidate replay | 2 | Preserve every backend result | +| Historical schema capture | 2 | Preserve every configuration result | + +`max-parallel` bounds one matrix run, not the repository or organization. Do not place unrelated PRs in a normal concurrency group as a global semaphore: the default group retains only one pending run and replaces other waiting work. Release publication must not share a cancellable PR group. + +Organization capacity should provide separate pools: reserve capacity for basic checks and Python tests, isolate database/upgrade workloads in a heavy pool, and budget Windows/macOS separately. GitHub larger-runner groups or appropriately isolated ephemeral runners can provide these pools. Create and verify actual group names and capacity before changing `runs-on`; nonexistent labels would only queue indefinitely. Account for other repositories sharing organization capacity. + +These rollout tasks require organization administration. Workflow code does not change paid plans, budgets, runner groups or organization concurrency: + +1. Inspect organization Settings → Actions → Runners for active jobs, total/OS limits and other repositories' usage. Check billing, budgets and service availability as well. +2. Create and qualify runner groups before migrating workflows. A starting allocation of roughly 20% basic checks, 40% ordinary tests and 40% heavy acceptance can be evaluated against measured minutes; it is not a capacity configuration already applied by this change. +3. After merging and validating the workflows, require the stable `CI result` check while preserving existing review requirements. Reusable workflow check names acquire Main caller prefixes, so external status-check rules must be reconciled. + +See GitHub's [concurrency semantics](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) and [organization job and capacity monitoring](https://docs.github.com/en/actions/how-tos/manage-runners/github-hosted-runners/view-current-jobs). + +## Terminal gates + +`CI result` depends on every Main root job. Any failure or cancellation blocks it. Root jobs required by selected scopes must succeed: unexpected skipping or missing results also block. Only explicitly unrelated jobs may skip. + +`Upgrade release gate` accepts skipped acceptance only after successful scope selection explicitly excludes upgrades. Selected acceptance must succeed. Cancelled runs allocate no new summary-gate runners; necessary in-job cleanup and diagnostics retain `always()`. Publication still checks the complete upgrade matrix and evidence files independently of path selection. + +## Clearing an existing backlog + +Inspect jobs, rather than treating a workflow awaiting its final summary as an entirely unstarted matrix: + +```sh +python3 scripts/ci_queue_report.py --repo oceanbase/powercontext \ + --max-runs 40 --output /data/codex-tmp/ci-queue.json +``` -The selector reads Git history rather than GitHub's truncated path-filter list. Rename detection is disabled so both the removed and added paths participate. Branch names are encoded as API query parameters; commit identities must be full hexadecimal SHAs. The scope summary records the comparison range, selected groups and fallback reason. +This bounded read-only report records sample coverage and actual job states. It never cancels, retries or changes a run. `workflow_age_minutes` is the workflow's age, not an individual job's queue latency. Incomplete or failed queries are not evidence of zero backlog. -## Maintaining ownership rules +Operational sequence: -`[suites]` assigns files to individual task groups. `[shared.]` assigns shared inputs to multiple consumers. A workflow change selects its own consumers, and a composite action change selects workflows that use that action. The selection configuration, selector, shared test fixtures and shared environment setup retain full coverage. Unknown paths also select every group until their ownership is declared. +1. Compare queued work with current PR heads and state. Remove only obsolete runs for closed PRs or superseded heads. Preserve current-head checks, latest trunk verification, daily full verification and releases. Updated concurrency policy does not establish that historical runs have already been cleared. +2. For summary jobs remaining after cancellation, confirm the parent run and current PR head before removing the old run. The new cancellation predicates prevent recurrence. +3. If jobs queue while no actual jobs run, investigate organization capacity, budgets and runner availability first. A smaller matrix cannot substitute for capacity troubleshooting. +4. Rerun only failures supported by evidence of nondeterminism. Fix deterministic assertions, contract drift and incompatible database schemas first. Retain failure logs and real-backend evidence; do not add unbounded automatic retries. -Shared Python runtime, API and root dependency changes conservatively exercise runtime consumers, backend and upgrade acceptance, worker observability and packaging. Historical schema capture and the SeekDB lock diagnostic have separate inputs; a routine runtime change does not launch those development tools. Python-version and backend matrices are preserved when their groups are selected. +Observe at least one full working day and one daily full validation after rollout. Track ready-PR to basic-check completion, selected jobs' queue p50/p95, actual runner minutes, consumed minutes of cancelled jobs, OS usage and failure/rerun rate. Suggested targets are basic-check p95 under five minutes and ordinary PR test-start queue p95 under ten minutes. These require capacity and runtime measurements; they are not guarantees from a code change. -When adding an input, include its consumers outside the directory that owns it. For example, Pi changes also select ZCode cross-host acceptance; a migration-related test must select the corresponding real-backend acceptance. Add a selection regression in `tests/test_ci_changes.py`. Verify moves, deletions, unavailable diffs and accumulated push changes when altering comparison behavior. +To roll back coverage policy, restore the full PR matrix while retaining orchestration, scope selection and cancellation fixes. Never simultaneously enable automatic child triggers and Main calls, which would duplicate executions. -Run focused validation with: +## Validation ```sh -uv run pytest -q tests/test_ci_changes.py +uv run pytest -q tests/test_ci_changes.py tests/test_ci_pipeline.py \ + tests/test_upgrade_release_gate.py tests/test_workflow_actions.py tests/test_e2e_workflow_contract.py uv run python scripts/check_workflow_actions.py .github/workflows .github/actions +actionlint -shellcheck= -pyflakes= ``` -Local scope tests exercise real Git histories and simulated Actions run metadata. They do not execute the GitHub scheduler or establish native-host/backend acceptance. Measure queue wait, runner minutes and launched job counts after deployment before claiming a throughput improvement. +Local checks use real Git histories, simulated Actions metadata and executable terminal-gate scripts. They validate selection, budgets and outcome contracts, not the GitHub scheduler, native operating systems or real-database acceptance. Measure throughput improvement after deployment. diff --git a/docs/zh/development/ci-scope.md b/docs/zh/development/ci-scope.md index 31b18ffd8e..a131cea108 100644 --- a/docs/zh/development/ci-scope.md +++ b/docs/zh/development/ci-scope.md @@ -1,31 +1,118 @@ -# CI 范围选择 +# GitHub CI 设计与运行 -持续集成通过 `.github/ci-paths.toml` 选择任务组,再为选中的任务分配 runner。各工作流使用自己的文件名调用 `ci-changes.yml`。文档变更选择网站与文档契约检查;独立宿主插件变更选择现有 Python 回归与对应宿主检查。无关的升级、worker 可观测性、安装角色、Desktop 和诊断矩阵会跳过。 +CI 的目标是让每次变更获得必要的验证,同时控制重复工作、无效工作和瞬时 runner 占用。上游默认分支为 `master`;`main` 镜像分支也接收主干验证。工作流、路径归属与终态要求均随代码审查,发布验收独立保留。 -无关变更仍可能出现轻量范围检测、Main 质量检查、许可证检查和升级结果门禁。升级矩阵跳过时,门禁明确表示本次不需要该验收,不代表升级已经通过。选中的矩阵必须成功;失败、取消或意外跳过不能通过门禁。发布仍直接调用安装包升级验收工作流,不通过路径选择绕过验收。 +## 入口和执行顺序 -## 比较基线 +普通 PR 和主干 push 只启动 `Main`。它调用现有工作流,统一计算一次完整变更范围;被调用工作流复用结果,不再分别分配 runner 检测变更。Desktop 的 `codex/desktop-preview` 分支和各工作流手动诊断入口仍可独立运行。 -- PR 比较 merge-base 到整个 PR head 的累计变化,覆盖全部提交,排除基准分支上的无关变更。 -- push 比较同一工作流、同一分支上成功祖先提交到当前提交的变化。选择器通过只读 Actions API 查询最近最多 100 次成功的 push 运行,并检查祖先关系,拒绝采用重写历史中的不相关提交。取消或失败的运行不能作为基线,其尚未验证的变更会留在下一次比较中。 -- 历史缺失、API 无法访问、元数据格式异常或差异获取失败时,选择全部任务组。因此,没有成功基线的新工作流会先执行完整验证。 -- 定时和手动运行选择该工作流中的全部任务组。Desktop 安装包诊断、仅 recall 的 harness 等手动参数继续保留各自的限制。 +```mermaid +flowchart TD + E[PR / 主干 push / 每日 / 手动] --> S[一次变更检测] + E --> L[License] + L --> Q[质量和 API 合约] + S --> T[选中的 Python 测试] + Q --> T + Q --> W[选中的网站与文档检查] + T --> H[选中的后端、升级、平台和宿主验收] + H --> U[Upgrade release gate] + S --> R[CI result] + T --> R + W --> R + H --> R + U --> R +``` + +License 或质量检查失败后,不展开后续测试和重型任务;Python 测试失败后,不展开依赖它们的后端、升级、平台和宿主任务。网站和文档检查可以在质量检查后与 Python 测试并行。失败保留在最终状态中,不因跳过后续任务而变绿。 + +草稿 PR 不分配自动 CI runner。标记为 ready 时验证完整 PR;转回 draft 会取消旧运行。每个 PR 只保留最新一次自动运行。定时、push、手动入口使用不同的并发组,避免手动诊断取消主干或每日验证。并发组中的取消机制用于淘汰同一变更的旧运行,不作为跨 PR 的资源队列。 + +## 验证层级 + +| 入口或输入 | Python 验证 | 其他验收 | +| --- | --- | --- | +| 普通 ready PR | 3.11/3.14 单元测试,3.12 E2E,共 3 个组合 | 运行变更选中的完整后端、升级、安装角色、平台和宿主验收 | +| PR 修改依赖、共享夹具、CI 策略,或出现未分类文件 | 3.11–3.14 × unit/E2E,共 8 个组合 | 保守选择相关范围或全部范围 | +| 已带 `ci:full` 标签的 PR 更新或重新打开 | 8 个组合 | 按完整 PR 变更范围选择,不强行运行无关诊断 | +| `master`/`main` push | 8 个组合 | 从成功主干基线选择,保留此前未验证变更 | +| 每日 UTC 20:17(北京时间次日 04:17) | 8 个组合 | 完整常规验收;不自动生成历史 schema 或执行锁实验 | +| 手动运行 Main | 8 个组合 | 全量范围;升级默认 `full`,可选专门诊断子集 | +| 正式发布 | 独立的安装包与发布验收 | 完整升级证据、汇总与发布字节绑定;不受 PR 路径跳过影响 | + +普通 PR 的 Python 测试组合数由 8 降至 3,减少 62.5%。这是组合数量的变化,不是已经测得的 runner 分钟或等待时间降幅。依赖和 CI 策略变更保持全矩阵,因此修改 CI 的 PR 本身可能仍运行 8 个组合。 + +`ci:full` 在 PR 的 opened/synchronize/reopened/ready 事件中读取。给一个正在运行的 PR 添加标签不会再触发一套流水线;需要随后更新提交或重新打开 PR。手动 Main 使用 Actions 界面选择的仓库内 ref;fork PR 应在其来源仓库选择对应分支,不能把默认分支的通过结果当成 PR head 的通过结果。 + +## 变更归属与基线 + +`.github/ci-paths.toml` 是归属表:`[suites]` 声明独立范围,`[shared.*]` 声明共同输入,`[checks]` 声明该范围选中后必须成功的 Main 根任务。新增范围必须同时声明终态任务。 + +- PR 使用 merge base 到完整 head 的三点差异,覆盖全部提交,不混入无关基线变更。 +- 主干 push 比较同一 Main 工作流、同一分支上最近的成功祖先,最多检查 100 次成功 push。失败或取消的提交不会成为基线,其变更保留到后续比较。 +- 无可信基线、API 不可用、历史缺失或 diff 失败时,选择全部范围和完整 Python 矩阵。未知路径也保守回退,不能静默漏检。 +- 通过 Git 读取完整差异,禁用重命名检测,同时考虑迁移前后的路径,不受原生 paths 文件数量截断影响。 +- 运行时、API、根依赖变更保守覆盖消费者;网站和独立集成仍按归属选择。长时间锁观察是手动操作。历史 schema 捕获和短锁探针只有自身输入变化或手动请求才执行,不加入每日常规验证。 + +例如:纯文档变更只选择网站和文档合约;Cindy 改动选择 Python 回归和 Cindy 包检查;迁移与共享运行时改动仍选择相关真实数据库和升级验收。调小 PR Python 矩阵不会将被选中的真实后端换成模拟测试。 + +## 并发预算与取消 + +| 矩阵 | 每次运行的最大并发 | 失败策略 | +| --- | --- | --- | +| Python 测试 | 3 | 首个失败后停止其余组合 | +| 隔离安装角色 | 2 | 首个失败后停止其余组合 | +| 安装发布升级 | 3 | 保留全部用例与证据汇总 | +| OceanBase 干净迁移分片 | 2 | 保留全部分片与完整性检查 | +| Candidate replay | 2 | 保留所有后端结果 | +| 历史 schema 捕获 | 2 | 保留所有配置结果 | + +`max-parallel` 只限制一条矩阵运行,不限制整个仓库或组织。不能把多个 PR 放进一个普通 concurrency 组来模拟全局 semaphore:默认组只保留一个 pending 项,会替换其他 PR 的等待任务。正式发布也不能与普通 PR 共用取消组。 + +组织层应按实际 runner 并发和 OS 配额建立独立容量池:基础检查与 Python 测试优先保留容量,数据库/升级使用隔离的重型池,Windows/macOS 使用独立平台预算。需要时采用 GitHub larger runner 的 runner group 或有明确隔离边界的临时 runner;具体名称和容量要在组织侧创建后才能写入 `runs-on`,不能在工作流里填写不存在的标签。容量不足应按组织全体仓库核算,不只观察本仓库。 + +以下配置需要组织管理员在上线时操作,代码不会修改付费计划、预算、runner group 或组织并发额度: + +1. 在组织 Settings → Actions → Runners 检查实际活跃任务、并发上限、OS 上限及其他仓库占用;同时核查预算、账单和服务状态。 +2. 若需要容量隔离,先创建并验证 runner group,再迁移工作流。初始配比可按基础检查/普通测试/重型后端约 20%/40%/40% 试运行,根据实际分钟数调整;这不是当前已配置的容量。 +3. 工作流合并并验证后,将稳定的 `CI result` 配置为合并必需检查,同时保留现有审批要求。工作流改为可复用后,部分检查名称会带 Main 调用任务前缀,外部规则必须同步核对。 + +GitHub 语义参见 [concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) 和 [当前任务与组织容量](https://docs.github.com/en/actions/how-tos/manage-runners/github-hosted-runners/view-current-jobs)。 + +## 汇总门禁 + +`CI result` 依赖 Main 的全部根任务:任何失败或取消都会阻断;被选中范围对应的根任务必须成功,意外 skipped 或缺失同样阻断。仅明确无关的任务允许跳过。 + +`Upgrade release gate` 只在范围检测成功且明确未选择升级时接受 skipped。选中的升级必须成功。旧运行被取消后,汇总门禁不会再分配 runner;任务内部的必要清理和诊断仍保留 `always()`。升级发布继续检查全矩阵和证据文件,不能用 CI 路径判定替代发布验收。 + +## 处理当前积压 + +先按 job 检查,不把一个等待汇总的 workflow 算成整套矩阵尚未运行: + +```sh +python3 scripts/ci_queue_report.py --repo oceanbase/powercontext \ + --max-runs 40 --output /data/codex-tmp/ci-queue.json +``` -选择器读取 Git 历史,不依赖 GitHub 截断后的路径过滤列表。关闭重命名检测,使移除和新增路径都参与选择。分支名按 API 查询参数编码,提交身份必须是完整十六进制 SHA。范围摘要记录比较区间、选择结果和回退原因。 +该命令只读,限制查询数量,输出采样覆盖范围和每条运行的真实 job 状态;不会取消、重试或修改任何任务。`workflow_age_minutes` 是整条运行的年龄,不能当成具体 job 的等待时间。查询不完整或出错不能解释成零积压。 -## 维护归属规则 +处理顺序: -`[suites]` 将文件映射到单个任务组,`[shared.]` 将共享输入映射到多个消费者。修改工作流会选择它自己的消费者,修改 composite action 会选择使用该 action 的工作流。选择配置、选择器、共享测试夹具和共享环境安装继续保留完整覆盖。未知路径在声明归属前也会选择全部任务组。 +1. 按 PR 当前 head 和状态核对积压;仅清理已关闭或已被新 head 替代的旧运行。保留当前 head、主干最新验证、每日完整验证和发布任务。并发策略更新不应被当成已清理所有历史运行。 +2. 如只剩被取消运行的汇总任务,核对父运行和 PR head 后清理旧运行;新工作流的取消条件用于防止重现。 +3. 若大量 job 排队而没有实际运行任务,先处理组织容量、预算或 runner 可用性;继续降低矩阵并不能替代容量排障。 +4. 仅对确认具有非确定性的失败进行定向重跑。固定断言、合约漂移、数据库 schema 不兼容应先修复。失败日志和真实后端证据保留,不自动无限重试。 -共享 Python Runtime、API 和根依赖变更仍保守覆盖运行时消费者、后端与升级验收、worker 可观测性及打包。历史 schema 捕获和 SeekDB 锁诊断具有独立输入,普通运行时代码变更不会启动这些开发工具。任务组选中后,原有 Python 版本和后端矩阵保持完整。 +部署后连续观察至少一个完整工作日及一次每日完整验证:记录 ready PR 到基础检查完成、选中任务 job 级排队 p50/p95、实际 runner 分钟、取消但已消耗的分钟、每个 OS 的占用与失败重跑率。建议目标是基础检查 p95 小于 5 分钟、常规 PR 开始测试的等待 p95 小于 10 分钟;目标需要容量与运行数据验证,不是代码修改后的保证。 -增加输入时,应包含所属目录之外的消费者。例如,Pi 变更也选择 ZCode 跨宿主验收;迁移相关测试变更应选择对应的真实后端验收。在 `tests/test_ci_changes.py` 增加选择回归。调整比较行为时,应验证移动、删除、差异不可用和 push 累计变更。 +回滚时可将 PR profile 恢复为完整矩阵,同时保留统一入口、范围选择和取消修复。不要同时启用子工作流的自动触发和 Main 调用,否则会重新产生重复执行。 -运行聚焦验证: +## 验证 ```sh -uv run pytest -q tests/test_ci_changes.py +uv run pytest -q tests/test_ci_changes.py tests/test_ci_pipeline.py \ + tests/test_upgrade_release_gate.py tests/test_workflow_actions.py tests/test_e2e_workflow_contract.py uv run python scripts/check_workflow_actions.py .github/workflows .github/actions +actionlint -shellcheck= -pyflakes= ``` -本地范围测试使用真实 Git 历史和模拟 Actions 运行元数据,不执行 GitHub 调度器,也不代表原生宿主或后端验收。部署后应测量排队时间、runner 分钟和启动 job 数,再判断吞吐量是否改善。 +本地测试使用真实 Git 历史、模拟 Actions 元数据和实际执行的门禁脚本;它们验证选择、预算与终态合约,不能代替 GitHub 调度器、各原生 OS 和真实数据库验收。吞吐量改善应在上线后测量。 diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index 983fd9bf3b..b24e98dbb9 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -52,6 +52,22 @@ def select_suites(paths: list[str], rules: dict[str, Any]) -> dict[str, bool]: return selected +def test_matrix(event: str, paths: list[str], rules: dict[str, Any], *, full: bool = False) -> dict[str, Any]: + """Retain full compatibility for dependencies, CI policy and unclassified inputs.""" + shared = any(fnmatchcase(path, pattern) for path in paths for pattern in rules["full"]) + dependencies = any(path in {"pyproject.toml", "uv.lock"} for path in paths) + unknown = any(all(select_suites([path], rules).values()) for path in paths) + if event != "pull_request" or full or shared or dependencies or unknown: + return {"python-version": ["3.11", "3.12", "3.13", "3.14"], "suite": ["unit", "e2e"]} + return { + "include": [ + {"python-version": "3.11", "suite": "unit"}, + {"python-version": "3.14", "suite": "unit"}, + {"python-version": "3.12", "suite": "e2e"}, + ] + } + + def changed_paths(base: str, head: str, *, pull_request: bool = True) -> list[str]: if not all(re.fullmatch(r"[0-9a-fA-F]{40}", commit) for commit in (base, head)): message = "Expected full base and head commit SHAs" @@ -111,9 +127,14 @@ def main() -> None: parser.add_argument("--baseline-workflow", default=os.environ.get("CI_BASELINE_WORKFLOW", "")) parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "")) parser.add_argument("--branch", default=os.environ.get("GITHUB_REF_NAME", "")) + parser.add_argument( + "--full-validation", action="store_true", default=os.environ.get("CI_FULL_VALIDATION") == "true" + ) arguments = parser.parse_args() rules = tomllib.loads((ROOT / ".github/ci-paths.toml").read_text(encoding="utf-8")) reason = "Full validation requested by this event." + paths: list[str] = [] + full = arguments.full_validation if arguments.event_name in {"pull_request", "push"}: try: base = arguments.base @@ -128,18 +149,33 @@ def main() -> None: reason = "A trustworthy change baseline is unavailable; running every CI suite." print(f"::warning::{reason}") selected = dict.fromkeys(rules["suites"], True) + full = True else: selected = dict.fromkeys(rules["suites"], True) + if arguments.event_name == "schedule": + # Archive generation and lock experiments have explicit manual entries. + for suite in ("release_schema", "seekdb_lock"): + selected[suite] = False + matrix = test_matrix(arguments.event_name, paths, rules, full=full) + if rules["checks"].keys() != selected.keys(): + raise ValueError("Every CI scope must declare its required result jobs") # noqa: TRY003 + required = sorted( + {"changes", "quality", "license-check", "upgrade-release-gate"} + | {job for suite, jobs in rules["checks"].items() if selected[suite] for job in jobs} + ) print(json.dumps(selected, indent=2)) if output_path := os.environ.get("GITHUB_OUTPUT"): with Path(output_path).open("a", encoding="utf-8") as output: output.write(f"suites={json.dumps(selected)}\n") + output.write(f"test-matrix={json.dumps(matrix)}\n") + output.write(f"required-jobs={json.dumps(required)}\n") if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"): with Path(summary_path).open("a", encoding="utf-8") as summary: summary.write(f"## CI scope\n\n{reason}\n\n| Suite | Run |\n| --- | --- |\n") for suite, enabled in selected.items(): summary.write(f"| {suite} | {'yes' if enabled else 'no'} |\n") + summary.write(f"\nPython matrix: `{json.dumps(matrix)}`\n") if __name__ == "__main__": diff --git a/scripts/ci_queue_report.py b/scripts/ci_queue_report.py new file mode 100644 index 0000000000..3f74057c70 --- /dev/null +++ b/scripts/ci_queue_report.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read a bounded sample of Actions jobs without cancelling or changing any run.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +def api(path: str) -> Any: + response = subprocess.run( # noqa: S603 -- validated repository and encoded fixed API queries, no shell + ["gh", "api", path], # noqa: S607 -- standard authenticated GitHub CLI + check=True, + capture_output=True, + text=True, + timeout=60, + ) + return json.loads(response.stdout) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="oceanbase/powercontext") + parser.add_argument("--max-runs", type=int, default=40) + parser.add_argument("--max-job-pages", type=int, default=3) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args() + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", arguments.repo): + parser.error("--repo must be owner/name") + if not 1 <= arguments.max_runs <= 100 or not 1 <= arguments.max_job_pages <= 3: + parser.error("Use 1..100 runs and 1..3 job pages per run") + root = f"repos/{arguments.repo}/actions" + pending: dict[int, Any] = {} + totals = {} + for status in ("queued", "in_progress", "waiting", "pending"): + data = api(f"{root}/runs?status={status}&per_page=100") + totals[status] = data["total_count"] + pending.update((run["id"], run) for run in data["workflow_runs"]) + # The queue bottleneck is visible in the oldest jobs. A capped sample is + # explicit; unqueried workflows must never be reported as having no jobs. + runs = sorted(pending.values(), key=lambda run: run["created_at"])[: arguments.max_runs] + now = datetime.now(UTC) + records = [] + counts: Counter[str] = Counter() + incomplete = [] + for run in runs: + jobs = [] + total = 0 + for page in range(1, arguments.max_job_pages + 1): + data = api(f"{root}/runs/{run['id']}/jobs?filter=latest&per_page=100&page={page}") + total = data["total_count"] + jobs.extend(data["jobs"]) + if len(jobs) >= total: + break + if len(jobs) < total: + incomplete.append(run["id"]) + states = Counter(job["status"] for job in jobs) + counts.update(states) + records.append({ + "id": run["id"], + "workflow": run["name"], + "status": run["status"], + "event": run["event"], + "head_sha": run["head_sha"], + "branch": run["head_branch"], + "url": run["html_url"], + "created_at": run["created_at"], + "workflow_age_minutes": round( + (now - datetime.fromisoformat(run["created_at"].replace("Z", "+00:00"))).total_seconds() / 60, 1 + ), + "job_counts": dict(states), + "job_total": total, + "unfinished_jobs": [ + { + "id": job["id"], + "name": job["name"], + "status": job["status"], + "labels": job["labels"], + "runner": job["runner_name"], + "url": job["html_url"], + } + for job in jobs + if job["status"] != "completed" + ], + }) + report = { + "captured_at": now.isoformat(), + "repository": arguments.repo, + "workflow_totals": totals, + "sampled_workflows": len(runs), + "workflow_sample_complete": len(runs) == len(pending) and all(value <= 100 for value in totals.values()), + "job_samples_incomplete": incomplete, + "sampled_job_counts": dict(counts), + "runs": records, + } + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Workflow totals: {totals}; sampled {len(runs)} workflows") + print(f"Actual job states in sample: {dict(counts)}") + if not report["workflow_sample_complete"] or incomplete: + print("Partial sample: increase the run limit or query the remaining workflows separately.") + for record in records: + print( + f"{record['id']} {record['workflow']}: {record['job_counts']} " + f"(workflow age {record['workflow_age_minutes']} min; this is not job queue latency)" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index 3e4efbdea3..a8eb04cb77 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -96,6 +96,7 @@ def test_pr_selects_affected_suites(paths: list[str], expected: set[str]) -> Non [ ".github/actions/setup-python-env/action.yml", ".github/workflows/ci-changes.yml", + ".github/workflows/master.yml", ".github/ci-paths.toml", "tests/e2e/conftest.py", "new-component/code.py", @@ -108,9 +109,18 @@ def test_shared_or_unclassified_changes_run_every_suite(path: str) -> None: @pytest.mark.parametrize("path", ["src/powercontext/server/authz/owner_recovery.py", "uv.lock", "pyproject.toml"]) def test_shared_runtime_inputs_keep_backend_upgrade_and_consumer_coverage(path: str) -> None: selected = select_suites([path], RULES) - assert {"tests", "database", "upgrade", "observability", "packaging", "native", "desktop", "pi", "zcode"} <= { - suite for suite, enabled in selected.items() if enabled - } + assert { + "tests", + "database", + "upgrade", + "observability", + "packaging", + "native", + "desktop", + "pi", + "zcode", + "cindy", + } <= {suite for suite, enabled in selected.items() if enabled} assert not selected["release_schema"] assert not selected["seekdb_lock"] @@ -162,7 +172,8 @@ def run_selector(repository: Path, event: str, base: str = "", head: str = "") - text=True, env={**os.environ, "GITHUB_OUTPUT": str(output), "GITHUB_STEP_SUMMARY": str(repository / "summary.md")}, ) - return json.loads(output.read_text(encoding="utf-8").removeprefix("suites=")) + outputs = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) + return json.loads(outputs["suites"]) @pytest.mark.parametrize( @@ -244,12 +255,18 @@ def test_large_diff_includes_code_after_hundreds_of_docs(repository: Path) -> No assert selected["desktop"] and selected["website"] -@pytest.mark.parametrize("event", ["workflow_dispatch", "schedule", "merge_group"]) +@pytest.mark.parametrize("event", ["workflow_dispatch", "merge_group"]) def test_non_pr_events_run_every_suite(repository: Path, event: str) -> None: head = commit(repository, "docs/README.md") assert all(run_selector(repository, event, head, head).values()) +def test_nightly_keeps_full_acceptance_without_archive_generation_or_lock_experiments(repository: Path) -> None: + head = commit(repository, "docs/README.md") + selected = run_selector(repository, "schedule", head, head) + assert {suite for suite, enabled in selected.items() if not enabled} == {"release_schema", "seekdb_lock"} + + @pytest.mark.parametrize("base", ["", "not-a-commit", "0" * 40]) def test_unavailable_diff_runs_every_suite(repository: Path, base: str) -> None: assert all(run_selector(repository, "pull_request", base, git(repository, "rev-parse", "HEAD")).values()) @@ -312,7 +329,8 @@ def run(command, **kwargs): ci_changes.main() - selected = json.loads(output.read_text().removeprefix("suites=")) + outputs = dict(line.split("=", 1) for line in output.read_text().splitlines()) + selected = json.loads(outputs["suites"]) assert {suite for suite, enabled in selected.items() if enabled} == {"upgrade", "website", "docs_tests"} @@ -338,3 +356,17 @@ def run(command, **kwargs): monkeypatch.setattr(subprocess, "run", run) assert ci_changes.successful_baseline("master.yml", "example/project", "master", head) == base + + +def test_selector_cli_exposes_full_matrix_and_required_jobs_on_missing_baseline(repository: Path) -> None: + run_selector(repository, "pull_request", "", git(repository, "rev-parse", "HEAD")) + outputs = dict(line.split("=", 1) for line in (repository / "outputs.txt").read_text().splitlines()) + matrix = json.loads(outputs["test-matrix"]) + assert set(matrix["python-version"]) == {"3.11", "3.12", "3.13", "3.14"} + assert set(matrix["suite"]) == {"unit", "e2e"} + assert set(json.loads(outputs["required-jobs"])) == {job for jobs in RULES["checks"].values() for job in jobs} | { + "changes", + "quality", + "license-check", + "upgrade-release-gate", + } diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py new file mode 100644 index 0000000000..892f3b2bd5 --- /dev/null +++ b/tests/test_ci_pipeline.py @@ -0,0 +1,156 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CI admission, coverage and terminal-result budgets.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +import ci_changes +import pytest +import yaml + +ROOT = Path(__file__).parents[1] +RULES = tomllib.loads((ROOT / ".github/ci-paths.toml").read_text()) + + +def workflow(name): + result = yaml.safe_load((ROOT / ".github/workflows" / name).read_text()) + result["on"] = result.pop(True) + return result + + +def combinations(matrix): + if "include" in matrix: + return {(row["python-version"], row["suite"]) for row in matrix["include"]} + return {(version, suite) for version in matrix["python-version"] for suite in matrix["suite"]} + + +def test_ordinary_pr_has_three_compatibility_jobs(): + assert combinations(ci_changes.test_matrix("pull_request", ["src/powercontext/server/settings.py"], RULES)) == { + ("3.11", "unit"), + ("3.14", "unit"), + ("3.12", "e2e"), + } + + +@pytest.mark.parametrize("event", ["push", "schedule", "workflow_dispatch"]) +def test_full_validation_keeps_all_supported_python_suite_pairs(event): + assert combinations(ci_changes.test_matrix(event, [], RULES)) == { + (version, suite) for version in ("3.11", "3.12", "3.13", "3.14") for suite in ("unit", "e2e") + } + + +@pytest.mark.parametrize("path", ["uv.lock", "pyproject.toml", "tox.ini", "tests/conftest.py", "new-component/x.py"]) +def test_dependency_and_unclassified_pr_changes_keep_full_compatibility(path): + assert len(combinations(ci_changes.test_matrix("pull_request", [path], RULES))) == 8 + + +def test_explicit_full_pr_profile_overrides_the_small_matrix(): + assert ( + len( + combinations( + ci_changes.test_matrix("pull_request", ["src/powercontext/server/settings.py"], RULES, full=True) + ) + ) + == 8 + ) + + +def test_selected_scopes_have_a_complete_terminal_result_contract(): + main = workflow("master.yml")["jobs"] + assert RULES["checks"].keys() == RULES["suites"].keys() + covered = {job for jobs in RULES["checks"].values() for job in jobs} + assert covered | {"changes", "quality", "license-check", "upgrade-release-gate", "ci-result"} == set(main) + assert set(main["ci-result"]["needs"]) == set(main) - {"ci-result"} + + +def test_automatic_host_workflows_share_one_scope_and_admission_gate(): + main = workflow("master.yml")["jobs"] + for path in (ROOT / ".github/workflows").glob("*.yml"): + if path.name != "master.yml": + events = workflow(path.name)["on"] + assert "pull_request" not in events, path.name + assert not {"master", "main"} & set(events.get("push", {}).get("branches", [])), path.name + for job in main.values(): + if ( + not job.get("uses", "").endswith(".yml") + or "ci-changes" in job["uses"] + or "upgrade-release" in job["uses"] + or "license-check" in job["uses"] + ): + continue + child = workflow(Path(job["uses"]).name) + assert "pull_request" not in child["on"] + assert "schedule" not in child["on"] + assert "master" not in child["on"].get("push", {}).get("branches", []) + assert "main" not in child["on"].get("push", {}).get("branches", []) + assert job["with"]["suites"] == "${{ needs.changes.outputs.suites }}" + assert {"changes", "quality", "tests"} <= set(job["needs"]) + assert "needs.quality.result == 'success'" in job["if"] + assert "needs.tests.result" in job["if"] + assert child["jobs"]["changes"]["if"] == "${{ inputs.suites == '' }}" + for child_job in child["jobs"].values(): + if "needs.changes.result" in child_job.get("if", ""): + assert "(inputs.suites == '' && needs.changes.result != 'success')" in child_job["if"] + assert "fromJSON(inputs.suites || needs.changes.outputs.suites || '{}')" in child_job["if"] + + +def test_compatibility_and_upgrade_matrices_have_bounded_runner_bursts(): + assert int(workflow("master.yml")["jobs"]["tests"]["strategy"]["max-parallel"]) <= 3 + assert int(workflow("master.yml")["jobs"]["isolated-installations"]["strategy"]["max-parallel"]) <= 2 + upgrade = workflow("upgrade-release.yml")["jobs"]["cases"]["strategy"] + assert int(upgrade["max-parallel"]) <= 3 + assert upgrade["fail-fast"] is False # Complete upgrade evidence remains mandatory. + + +def test_cancelled_or_draft_runs_cannot_start_terminal_runner_jobs(): + jobs = workflow("master.yml")["jobs"] + for name in ("upgrade-release-gate", "ci-result"): + assert "!cancelled()" in jobs[name]["if"] + assert "!github.event.pull_request.draft" in jobs[name]["if"] + assert "converted_to_draft" in workflow("master.yml")["on"]["pull_request"]["types"] + + +@pytest.mark.parametrize( + ("selected_result", "unselected_result", "passes"), + [ + ("success", "skipped", True), + ("skipped", "skipped", False), + ("cancelled", "skipped", False), + ("failure", "skipped", False), + ("success", "failure", False), + ], +) +def test_actual_ci_result_script_rejects_missing_selected_acceptance(selected_result, unselected_result, passes): + script = workflow("master.yml")["jobs"]["ci-result"]["steps"][0]["run"] + script = script.split("<<'PYTHON'\n", 1)[1].rsplit("PYTHON", 1)[0] + required = ["changes", "quality", "license-check", "upgrade-release-gate", "harness-validation"] + results = {job: {"result": "success"} for job in required} + results["harness-validation"]["result"] = selected_result + results["desktop-validation"] = {"result": unselected_result} + run = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "RESULTS": json.dumps(results), "REQUIRED": json.dumps(required)}, + capture_output=True, + text=True, + check=False, + ) + assert (run.returncode == 0) == passes, run.stdout + run.stderr diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 4c9a64bcf2..ed2a57a0f7 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -447,7 +447,8 @@ def test_publishing_jobs_require_upgrade_acceptance_unless_manually_bypassed(): main = yaml.safe_load((root / ".github/workflows/master.yml").read_text())["jobs"] assert main["upgrade-acceptance"]["uses"] == build["uses"] verdict = main["upgrade-release-gate"] - assert verdict["name"] == "Upgrade release gate" and verdict["if"] == "always()" + assert verdict["name"] == "Upgrade release gate" + assert "!cancelled()" in verdict["if"] dependencies = verdict["needs"] assert {"changes", "upgrade-acceptance"} <= set([dependencies] if isinstance(dependencies, str) else dependencies) assert not verdict.get("continue-on-error") From 89dabd98e7fd58dd4302df953e87512cddbc848d Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Thu, 29 Oct 2026 21:11:59 +0800 Subject: [PATCH 5/8] ci: restrict upstream automation to master --- .github/workflows/master.yml | 2 +- docs/en/development/ci-scope.md | 6 +++--- docs/zh/development/ci-scope.md | 6 +++--- tests/test_ci_pipeline.py | 5 +++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 95ae7b11b6..61b788bdf3 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -4,8 +4,8 @@ on: push: branches: - master - - main pull_request: + branches: [master] types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] schedule: - cron: '17 20 * * *' diff --git a/docs/en/development/ci-scope.md b/docs/en/development/ci-scope.md index 2c8c49af4e..ede8b4426b 100644 --- a/docs/en/development/ci-scope.md +++ b/docs/en/development/ci-scope.md @@ -1,10 +1,10 @@ # GitHub CI design and operation -CI must validate affected behavior while controlling duplicate work, wasted work and bursts of runner demand. The upstream default branch is `master`; a `main` mirror also receives trunk validation. Workflow policy, path ownership and required outcomes are reviewed with code. Release acceptance remains independent. +CI must validate affected behavior while controlling duplicate work, wasted work and bursts of runner demand. The upstream trunk branch is `master`, and automatic trunk validation listens only to that branch. Workflow policy, path ownership and required outcomes are reviewed with code. Release acceptance remains independent. ## Entry points and admission -Ordinary PRs and trunk pushes start only `Main`. It selects the complete change scope once and calls existing reusable workflows. Called workflows reuse this selection without allocating another detector runner. The Desktop `codex/desktop-preview` branch and workflow-specific manual diagnostics retain independent entry points. +Ordinary PRs targeting `master` and pushes to `master` start only `Main`. It selects the complete change scope once and calls existing reusable workflows. Called workflows reuse this selection without allocating another detector runner. The Desktop `codex/desktop-preview` branch and workflow-specific manual diagnostics retain independent entry points. ```mermaid flowchart TD @@ -34,7 +34,7 @@ Draft PRs allocate no automatic CI runners. Marking a PR ready validates its com | Ordinary ready PR | 3.11/3.14 unit tests and 3.12 E2E: 3 combinations | Complete selected backend, upgrade, installation-role, platform and host acceptance | | PR changes dependencies, shared fixtures, CI policy or unclassified files | 3.11–3.14 × unit/E2E: 8 combinations | Conservative affected or full selection | | PR update/reopen already carrying `ci:full` | 8 combinations | Complete PR scope, without forcing unrelated diagnostics | -| `master`/`main` push | 8 combinations | Compare with a successful trunk baseline, retaining unverified changes | +| `master` push | 8 combinations | Compare with a successful trunk baseline, retaining unverified changes | | Daily at 20:17 UTC | 8 combinations | Full ordinary acceptance, excluding historical schema generation and lock experiments | | Manual Main | 8 combinations | Full scope; upgrade selection defaults to `full`, with an explicit diagnostic subset available | | Published release | Independent installed-package and publication checks | Complete upgrade evidence and validated publication bytes; PR scope cannot bypass acceptance | diff --git a/docs/zh/development/ci-scope.md b/docs/zh/development/ci-scope.md index a131cea108..40085e4cbd 100644 --- a/docs/zh/development/ci-scope.md +++ b/docs/zh/development/ci-scope.md @@ -1,10 +1,10 @@ # GitHub CI 设计与运行 -CI 的目标是让每次变更获得必要的验证,同时控制重复工作、无效工作和瞬时 runner 占用。上游默认分支为 `master`;`main` 镜像分支也接收主干验证。工作流、路径归属与终态要求均随代码审查,发布验收独立保留。 +CI 的目标是让每次变更获得必要的验证,同时控制重复工作、无效工作和瞬时 runner 占用。上游主分支为 `master`,主干自动验证只监听该分支。工作流、路径归属与终态要求均随代码审查,发布验收独立保留。 ## 入口和执行顺序 -普通 PR 和主干 push 只启动 `Main`。它调用现有工作流,统一计算一次完整变更范围;被调用工作流复用结果,不再分别分配 runner 检测变更。Desktop 的 `codex/desktop-preview` 分支和各工作流手动诊断入口仍可独立运行。 +面向 `master` 的普通 PR 和 `master` push 只启动 `Main`。它调用现有工作流,统一计算一次完整变更范围;被调用工作流复用结果,不再分别分配 runner 检测变更。Desktop 的 `codex/desktop-preview` 分支和各工作流手动诊断入口仍可独立运行。 ```mermaid flowchart TD @@ -34,7 +34,7 @@ License 或质量检查失败后,不展开后续测试和重型任务;Python | 普通 ready PR | 3.11/3.14 单元测试,3.12 E2E,共 3 个组合 | 运行变更选中的完整后端、升级、安装角色、平台和宿主验收 | | PR 修改依赖、共享夹具、CI 策略,或出现未分类文件 | 3.11–3.14 × unit/E2E,共 8 个组合 | 保守选择相关范围或全部范围 | | 已带 `ci:full` 标签的 PR 更新或重新打开 | 8 个组合 | 按完整 PR 变更范围选择,不强行运行无关诊断 | -| `master`/`main` push | 8 个组合 | 从成功主干基线选择,保留此前未验证变更 | +| `master` push | 8 个组合 | 从成功主干基线选择,保留此前未验证变更 | | 每日 UTC 20:17(北京时间次日 04:17) | 8 个组合 | 完整常规验收;不自动生成历史 schema 或执行锁实验 | | 手动运行 Main | 8 个组合 | 全量范围;升级默认 `full`,可选专门诊断子集 | | 正式发布 | 独立的安装包与发布验收 | 完整升级证据、汇总与发布字节绑定;不受 PR 路径跳过影响 | diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index 892f3b2bd5..2592fc4de2 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -83,12 +83,14 @@ def test_selected_scopes_have_a_complete_terminal_result_contract(): def test_automatic_host_workflows_share_one_scope_and_admission_gate(): + assert workflow("master.yml")["on"]["push"]["branches"] == ["master"] + assert workflow("master.yml")["on"]["pull_request"]["branches"] == ["master"] main = workflow("master.yml")["jobs"] for path in (ROOT / ".github/workflows").glob("*.yml"): if path.name != "master.yml": events = workflow(path.name)["on"] assert "pull_request" not in events, path.name - assert not {"master", "main"} & set(events.get("push", {}).get("branches", [])), path.name + assert "master" not in events.get("push", {}).get("branches", []), path.name for job in main.values(): if ( not job.get("uses", "").endswith(".yml") @@ -101,7 +103,6 @@ def test_automatic_host_workflows_share_one_scope_and_admission_gate(): assert "pull_request" not in child["on"] assert "schedule" not in child["on"] assert "master" not in child["on"].get("push", {}).get("branches", []) - assert "main" not in child["on"].get("push", {}).get("branches", []) assert job["with"]["suites"] == "${{ needs.changes.outputs.suites }}" assert {"changes", "quality", "tests"} <= set(job["needs"]) assert "needs.quality.result == 'success'" in job["if"] From 377f25a2bdf9766e863a8810f15b051693b9bdd4 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Sat, 31 Oct 2026 00:39:53 +0800 Subject: [PATCH 6/8] ci: batch installation roles and combine preflight checks --- .github/ci-paths.toml | 9 +- .github/workflows/license-check.yml | 29 ---- .github/workflows/master.yml | 242 +++++++++++++++------------- docs/en/development/ci-scope.md | 23 ++- docs/zh/development/ci-scope.md | 23 ++- scripts/ci_changes.py | 2 +- scripts/ci_installation_roles.py | 146 +++++++++++++++++ tests/test_ci_changes.py | 6 +- tests/test_ci_installation_roles.py | 150 +++++++++++++++++ tests/test_ci_pipeline.py | 63 ++++++-- tests/test_upgrade_release_gate.py | 2 +- 11 files changed, 510 insertions(+), 185 deletions(-) delete mode 100644 .github/workflows/license-check.yml create mode 100644 scripts/ci_installation_roles.py create mode 100644 tests/test_ci_installation_roles.py diff --git a/.github/ci-paths.toml b/.github/ci-paths.toml index 37dfedd322..f9f2a53bf4 100644 --- a/.github/ci-paths.toml +++ b/.github/ci-paths.toml @@ -10,11 +10,11 @@ full = [ "integrations/opendal/pyproject.toml", ] -# These files still receive the always-running quality and license checks. +# These files still receive the always-running preflight checks. ignore = [ "AGENTS.md", "REVIEW.md", "CONTRIBUTING.md", "GOVERNANCE.md", "LICENSE", "LICENSES/**", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md", ".github/CODEOWNERS", - ".github/dependabot.yml", ".github/workflows/license-check.yml", ".licenserc.yaml", + ".github/dependabot.yml", ".licenserc.yaml", "scripts/ci_queue_report.py", ] @@ -25,7 +25,7 @@ tests = [ "scripts/sync_dsh_plugin_tree.py", "scripts/generate_integration_manifest_docs.py", "scripts/run_atomic_memory_clean_migration.py", "scripts/scan_e2e_evidence.sh", "scripts/check_atomic_memory_migration_shards.py", - "scripts/ci_role_smoke.py", + "scripts/ci_role_smoke.py", "scripts/ci_installation_roles.py", # These evaluator suites still live under the root tests/ directory. "evaluation/memory/locomo/**", "evaluation/memory/locomo_plus/**", "evaluation/memory/write_gate/**", ] @@ -90,7 +90,8 @@ observability = [ ] packaging = [ "scripts/build_ops_bundle.py", - "tests/test_installation.py", "tests/test_upgrade_publication.py", "scripts/ci_role_smoke.py", "release/**", + "tests/test_installation.py", "tests/test_upgrade_publication.py", "scripts/ci_role_smoke.py", + "scripts/ci_installation_roles.py", "tests/test_ci_installation_roles.py", "release/**", ".github/workflows/release.yml", ".github/workflows/build-artifacts.yml", ".github/workflows/build-docker.yml", "docker/**", ] diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml deleted file mode 100644 index ab8cce4cae..0000000000 --- a/.github/workflows/license-check.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: License Check - -on: - workflow_call: - -permissions: - contents: read - -concurrency: - group: license-check-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - license: - name: Check License Header - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - - name: Check License Header - uses: apache/skywalking-eyes/header@a196742f472feaffafea537ce5a2a4c3c53a8de4 # v0.9.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - config: .licenserc.yaml - mode: check diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 3fcb7a97bd..95679a1bbe 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -29,13 +29,9 @@ concurrency: cancel-in-progress: true jobs: - license-check: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - uses: ./.github/workflows/license-check.yml - seekdb-managed-startup: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').upgrade) }} name: SeekDB managed startup regression (priority) timeout-minutes: 20 runs-on: ubuntu-latest @@ -88,31 +84,25 @@ jobs: if-no-files-found: error retention-days: 7 - changes: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - uses: ./.github/workflows/ci-changes.yml - with: - baseline-workflow: master.yml - upgrade-acceptance: - needs: [changes, seekdb-managed-startup, quality, tests] + needs: [quality, seekdb-managed-startup, tests] # Keep diagnostics when the selected priority regression fails. - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').upgrade) }} + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').upgrade) }} uses: ./.github/workflows/upgrade-release.yml with: selection: ${{ github.event_name == 'workflow_dispatch' && inputs.upgrade_selection || 'full' }} upgrade-release-gate: name: Upgrade release gate - needs: [changes, upgrade-acceptance] + needs: [quality, upgrade-acceptance] if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Require the selected parallel acceptance workflow env: - SCOPE_RESULT: ${{ needs.changes.result }} - UPGRADE_SELECTED: ${{ fromJSON(needs.changes.outputs.suites || '{}').upgrade }} + SCOPE_RESULT: ${{ needs.quality.result }} + UPGRADE_SELECTED: ${{ fromJSON(needs.quality.outputs.suites || '{}').upgrade }} UPGRADE_RESULT: ${{ needs.upgrade-acceptance.result }} run: | if [ "$SCOPE_RESULT" = success ] && [ "$UPGRADE_SELECTED" = false ]; then @@ -124,8 +114,8 @@ jobs: oceanbase-engine-recovery: name: OceanBase engine crash recovery - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').database) }} runs-on: ubuntu-24.04 timeout-minutes: 20 env: @@ -183,16 +173,41 @@ jobs: retention-days: 7 quality: - needs: license-check + name: CI preflight if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - timeout-minutes: 20 + timeout-minutes: 30 runs-on: ubuntu-latest + outputs: + suites: ${{ steps.select.outputs.suites }} + test-matrix: ${{ steps.select.outputs.test-matrix }} + required-jobs: ${{ steps.select.outputs.required-jobs }} steps: - - name: Check out + - name: Check out the complete branch history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + fetch-depth: 0 persist-credentials: false + - name: Select checks + id: select + timeout-minutes: 5 + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CI_BASELINE_WORKFLOW: master.yml + CI_FULL_VALIDATION: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci_changes.py + + - name: Check License Header + timeout-minutes: 10 + uses: apache/skywalking-eyes/header@a196742f472feaffafea537ce5a2a4c3c53a8de4 # v0.9.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config: .licenserc.yaml + mode: check + - name: Set up the environment uses: ./.github/actions/setup-python-env @@ -214,14 +229,14 @@ jobs: tests: name: tests (${{ matrix.python-version }}, ${{ matrix.suite }}) - needs: [changes, quality] - if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + needs: [quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (fromJSON(needs.quality.outputs.suites || '{}').tests) }} timeout-minutes: 40 runs-on: ubuntu-latest strategy: fail-fast: true max-parallel: 3 - matrix: ${{ fromJSON(needs.changes.outputs.test-matrix || '{"python-version":["3.11","3.12","3.13","3.14"],"suite":["unit","e2e"]}') }} + matrix: ${{ fromJSON(needs.quality.outputs.test-matrix || '{"python-version":["3.11","3.12","3.13","3.14"],"suite":["unit","e2e"]}') }} defaults: run: shell: bash @@ -269,8 +284,8 @@ jobs: run: make e2e-test isolated-installations: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').packaging) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').packaging) }} timeout-minutes: 20 runs-on: ubuntu-latest strategy: @@ -278,7 +293,6 @@ jobs: max-parallel: 2 matrix: python-version: ["3.11", "3.14"] - extras: ["builtin", "cli", "builtin,cli", "server,cli"] steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -295,30 +309,38 @@ jobs: with: version: "0.10.12" - - name: Build and install only the requested wheel extras - env: - ROLE_EXTRAS: ${{ matrix.extras }} + - name: Build one wheel for all installation roles run: | - uv build --wheel --out-dir "$RUNNER_TEMP/role-wheel" - uv venv --python "$(command -v python)" "$RUNNER_TEMP/role-venv" - wheel="$(find "$RUNNER_TEMP/role-wheel" -name '*.whl' -print -quit)" - uv pip install --python "$RUNNER_TEMP/role-venv/bin/python" "$wheel[$ROLE_EXTRAS]" + uv build --wheel --python "$(command -v python)" --out-dir "$RUNNER_TEMP/role-wheel" - - name: Verify installed role behavior without checkout imports - env: - ROLE_EXTRAS: ${{ matrix.extras }} + - name: Verify all roles in independent environments run: | - "$RUNNER_TEMP/role-venv/bin/python" -I scripts/ci_role_smoke.py "$ROLE_EXTRAS" + wheel="$(find "$RUNNER_TEMP/role-wheel" -name '*.whl' -print -quit)" + python scripts/ci_installation_roles.py --wheel "$wheel" \ + --python "$(command -v python)" --output-dir "$RUNNER_TEMP/installation-roles" - - name: Verify unrelated packages do not enable optional CLI roles - if: matrix.extras == 'cli' + - name: Summarize installation roles + if: always() run: | - uv pip install --python "$RUNNER_TEMP/role-venv/bin/python" sqlalchemy fastapi - "$RUNNER_TEMP/role-venv/bin/python" -I scripts/ci_role_smoke.py cli + if [ -f "$RUNNER_TEMP/installation-roles/summary.md" ]; then + cat "$RUNNER_TEMP/installation-roles/summary.md" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Save installation role reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: installation-roles-py${{ matrix.python-version }}-${{ github.sha }} + path: | + ${{ runner.temp }}/installation-roles/summary.json + ${{ runner.temp }}/installation-roles/summary.md + ${{ runner.temp }}/installation-roles/logs/*.log + if-no-files-found: warn + retention-days: 7 docs-tests: - needs: [changes, quality] - if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').docs_tests) }} + needs: [quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (fromJSON(needs.quality.outputs.suites || '{}').docs_tests) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -342,8 +364,8 @@ jobs: tests/e2e/test_mcp_full_profile.py worker-observability: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').observability) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').observability) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -385,8 +407,8 @@ jobs: if-no-files-found: error evaluation-tests: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').evaluation) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').evaluation) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -447,8 +469,8 @@ jobs: echo "::error title=evaluation unit tests failed::${detail}" code-seekdb: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').database) }} timeout-minutes: 30 runs-on: ubuntu-latest strategy: @@ -582,8 +604,8 @@ jobs: if-no-files-found: ignore atomic-memory-clean-seekdb: - needs: [changes, seekdb-managed-startup, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database) }} + needs: [quality, seekdb-managed-startup, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').database) }} name: Atomic Memory clean migration (SeekDB) timeout-minutes: 55 runs-on: ubuntu-latest @@ -644,8 +666,8 @@ jobs: retention-days: 7 windows-unit-portability: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').tests) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').tests) }} timeout-minutes: 20 runs-on: windows-latest steps: @@ -740,8 +762,8 @@ jobs: tests/test_service_environment.py dify-tools: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dify) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').dify) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -806,8 +828,8 @@ jobs: run: make dify-test hermes-skills: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').hermes) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').hermes) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -833,8 +855,8 @@ jobs: run: uv run pytest tests/e2e/test_hermes_skills.py dsh-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').dsh) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').dsh) }} timeout-minutes: 30 runs-on: ubuntu-latest steps: @@ -880,8 +902,8 @@ jobs: run: uv run pytest tests/test_integration_guidance_evaluation.py::test_dsh_controlled_native_handoff_uses_only_mcp_tools_and_preserves_complete_carriers cindy-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').cindy) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').cindy) }} timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -923,8 +945,8 @@ jobs: run: uv run pytest tests/e2e/test_cindy_mcp_chain.py tests/e2e/test_cindy_hooks_chain.py -q pi-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').pi) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').pi) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -952,8 +974,8 @@ jobs: run: uv run pytest "tests/test_integration_guidance_evaluation.py::test_native_adapter_handoff_uses_real_request_mapping_and_response_envelopes[pi]" opencode-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opencode) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').opencode) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -976,8 +998,8 @@ jobs: run: uv run pytest "tests/test_integration_guidance_evaluation.py::test_native_adapter_handoff_uses_real_request_mapping_and_response_envelopes[opencode]" openclaw-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').openclaw) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').openclaw) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -1002,8 +1024,8 @@ jobs: run: mise exec node@24.15.0 -- uv run pytest tests/test_integration_guidance_evaluation.py::test_openclaw_handoff_adapter_preserves_generated_source_identity opendal-package: - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').opendal) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').opendal) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -1022,8 +1044,8 @@ jobs: website-link-validation-windows: - needs: [changes, quality] - if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} + needs: [quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (fromJSON(needs.quality.outputs.suites || '{}').website) }} timeout-minutes: 20 runs-on: windows-latest steps: @@ -1048,8 +1070,8 @@ jobs: run: pnpm --dir website test:links check-website: - needs: [changes, quality] - if: ${{ !cancelled() && needs.quality.result == 'success' && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').website) }} + needs: [quality] + if: ${{ !cancelled() && needs.quality.result == 'success' && (fromJSON(needs.quality.outputs.suites || '{}').website) }} timeout-minutes: 20 runs-on: ubuntu-latest steps: @@ -1079,111 +1101,109 @@ jobs: run: uv run pytest tests/e2e/test_blog_site.py -q harness-validation: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').database || fromJSON(needs.changes.outputs.suites || '{}').harness) }} + (fromJSON(needs.quality.outputs.suites || '{}').database || fromJSON(needs.quality.outputs.suites || '{}').harness) }} uses: ./.github/workflows/e2e-harness.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} desktop-validation: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').desktop) }} + (fromJSON(needs.quality.outputs.suites || '{}').desktop) }} uses: ./.github/workflows/desktop.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} managed-resource-candidates: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').managed_resources) }} + (fromJSON(needs.quality.outputs.suites || '{}').managed_resources) }} uses: ./.github/workflows/managed-resource-candidates.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} native-service: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').native) }} + (fromJSON(needs.quality.outputs.suites || '{}').native) }} uses: ./.github/workflows/native-service.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} kimi-acceptance: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').kimi) }} + (fromJSON(needs.quality.outputs.suites || '{}').kimi) }} uses: ./.github/workflows/kimi-code-acceptance.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} zcode-acceptance: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').zcode) }} + (fromJSON(needs.quality.outputs.suites || '{}').zcode) }} uses: ./.github/workflows/zcode-acceptance.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} skill-guidance: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').skill_guidance) }} + (fromJSON(needs.quality.outputs.suites || '{}').skill_guidance) }} uses: ./.github/workflows/skill-guidance.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} candidate-replay: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').candidate_replay) }} + (fromJSON(needs.quality.outputs.suites || '{}').candidate_replay) }} uses: ./.github/workflows/candidate-schema-replay.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} release-schema: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').release_schema) }} + (fromJSON(needs.quality.outputs.suites || '{}').release_schema) }} uses: ./.github/workflows/capture-release-schema.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} seekdb-lock: - needs: [changes, quality, tests] + needs: [quality, tests] if: >- ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && - (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').seekdb_lock) }} + (fromJSON(needs.quality.outputs.suites || '{}').seekdb_lock) }} uses: ./.github/workflows/seekdb-lock-probe.yml with: - suites: ${{ needs.changes.outputs.suites }} + suites: ${{ needs.quality.outputs.suites }} ci-result: name: CI result needs: - - license-check - seekdb-managed-startup - - changes - upgrade-acceptance - upgrade-release-gate - oceanbase-engine-recovery @@ -1224,7 +1244,7 @@ jobs: - name: Require successful selected checks env: RESULTS: ${{ toJSON(needs) }} - REQUIRED: ${{ needs.changes.outputs.required-jobs }} + REQUIRED: ${{ needs.quality.outputs.required-jobs }} run: | python3 - <<'PYTHON' import json @@ -1235,7 +1255,7 @@ jobs: if failed: raise SystemExit(f"CI checks did not succeed: {failed}") required = json.loads(os.environ["REQUIRED"]) - if not {"changes", "quality", "license-check", "upgrade-release-gate"} <= set(required): + if not {"quality", "upgrade-release-gate"} <= set(required): raise SystemExit("The required CI result contract is missing") missing = {name: results.get(name, {}).get("result", "missing") for name in required if results.get(name, {}).get("result") != "success"} @@ -1246,8 +1266,8 @@ jobs: helm: name: Helm deployment contracts - needs: [changes, quality, tests] - if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').helm) }} + needs: [quality, tests] + if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').helm) }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/docs/en/development/ci-scope.md b/docs/en/development/ci-scope.md index b4661dc613..1d60f442f5 100644 --- a/docs/en/development/ci-scope.md +++ b/docs/en/development/ci-scope.md @@ -4,26 +4,23 @@ CI must validate affected behavior while controlling duplicate work, wasted work ## Entry points and admission -Ordinary PRs targeting `master` and pushes to `master` start only `Main`. It selects the complete change scope once and calls existing reusable workflows. Called workflows reuse this selection without allocating another detector runner. The Desktop `codex/desktop-preview` branch and workflow-specific manual diagnostics retain independent entry points. +Ordinary PRs targeting `master` and pushes to `master` start only `Main`. Its `CI preflight` job checks out the repository once and runs scope selection, license validation, quality checks and API-contract validation as separate steps on one runner. It then calls existing reusable workflows with the complete change scope. Called workflows reuse this selection without allocating another detector runner; directly invoked manual workflows retain their own scope detection. The Desktop `codex/desktop-preview` branch and workflow-specific manual diagnostics retain independent entry points. ```mermaid flowchart TD - E[PR / trunk push / daily / manual] --> S[One scope selection] - E --> L[License] - L --> Q[Quality and API contract] - S --> T[Selected Python tests] - Q --> T + E[PR / trunk push / daily / manual] --> Q[CI preflight: scope, license, quality and API contract] + Q --> T[Selected Python tests] Q --> W[Selected website and documentation checks] T --> H[Selected backend, upgrade, platform and host acceptance] H --> U[Upgrade release gate] - S --> R[CI result] + Q --> R[CI result] T --> R W --> R H --> R U --> R ``` -License or quality failure prevents downstream tests and heavy work from starting. Python-test failure prevents dependent backend, upgrade, platform and host acceptance from expanding. Website and documentation checks can run alongside Python tests after quality succeeds. Skipping downstream work never turns the original failure green. +Preflight failure prevents downstream tests and heavy work from starting. Python-test failure prevents dependent backend, upgrade, platform and host acceptance from expanding. Website and documentation checks can run alongside Python tests after preflight succeeds. Skipping downstream work never turns the original failure green. Grouping the four preflight checks in one job removes two runner allocations per ordinary validation run and avoids a separate runner wait between license and quality checks; each check retains its own step result and logs. Draft PRs allocate no automatic CI runners. Marking a PR ready validates its complete diff; conversion back to draft cancels the old run. Only the latest automatic run survives for each PR. Schedule, push and manual entry points use separate concurrency groups so diagnostics do not cancel trunk or daily validation. These groups replace obsolete work for the same change; they are not cross-PR resource queues. @@ -41,6 +38,8 @@ Draft PRs allocate no automatic CI runners. Marking a PR ready validates its com Ordinary PR Python combinations drop from eight to three, a 62.5% reduction in combination count. This is not a measured reduction in runner minutes or queue latency. Dependency and CI-policy changes keep the full matrix, so a PR editing CI itself may still run eight combinations. +When packaging is selected, installation validation uses two jobs, one each for Python 3.11 and 3.14. Each job builds its wheel once and verifies all four extra sets: `builtin`, `cli`, `builtin,cli` and `server,cli`. Each role gets its own virtual environment, application state and temporary directory, and runs against the installed wheel with checkout imports excluded. The CLI role also verifies that adding unrelated SQLAlchemy and FastAPI packages does not enable optional roles. Every role produces a result; any failed role fails its job. All eight Python/role combinations remain covered while six runner allocations and six duplicate wheel builds are removed. The job-count reduction does not establish a queue-latency or throughput improvement without measurements. + `ci:full` is read from opened/synchronize/reopened/ready PR events. Adding the label to a running PR does not dispatch another workflow: update the branch or reopen the PR afterward. Manual Main uses the repository ref selected in Actions. For a fork PR, select its branch in the source repository; a successful default-branch run is not evidence for the PR head. ## Ownership and comparison baseline @@ -64,7 +63,7 @@ Shared helpers select their actual consumers: the clean-migration runner also se | Matrix | Maximum parallel jobs per run | Failure behavior | | --- | --- | --- | | Python tests | 3 | Stop remaining combinations after the first failure | -| Isolated installation roles | 2 | Stop remaining combinations after the first failure | +| Isolated installations, one job per Python version | 2 | Attempt every role within a job; a failed job cancels the other Python job if still running | | Installed-release upgrades | 3 | Preserve all cases and complete evidence collection | | OceanBase clean-migration shards | 2 | Preserve all shards and completeness checks | | Candidate replay | 2 | Preserve every backend result | @@ -73,7 +72,7 @@ Shared helpers select their actual consumers: the clean-migration runner also se `max-parallel` bounds one matrix run, not the repository or organization. Do not place unrelated PRs in a normal concurrency group as a global semaphore: the default group retains only one pending run and replaces other waiting work. Release publication must not share a cancellable PR group. -A concurrency cap does not remove work and can lengthen one PR's completion time. Queue reduction comes from skipping unrelated scopes, eliminating duplicate entries, using the smaller PR compatibility matrix and cancelling superseded runs. Runtime and CI-policy changes still require broad acceptance, so their completion time remains sensitive to organization capacity. +A concurrency cap does not remove work and can lengthen one PR's completion time. Queue reduction comes from skipping unrelated scopes, eliminating duplicate entries, grouping short checks on one runner, reusing installation builds, using the smaller PR compatibility matrix and cancelling superseded runs. Runtime and CI-policy changes still require broad acceptance, so their completion time remains sensitive to organization capacity. Organization capacity should provide separate pools: reserve capacity for basic checks and Python tests, isolate database/upgrade workloads in a heavy pool, and budget Windows/macOS separately. GitHub larger-runner groups or appropriately isolated ephemeral runners can provide these pools. Create and verify actual group names and capacity before changing `runs-on`; nonexistent labels would only queue indefinitely. Account for other repositories sharing organization capacity. @@ -81,7 +80,7 @@ These rollout tasks require organization administration. Workflow code does not 1. Inspect organization Settings → Actions → Runners for active jobs, total/OS limits and other repositories' usage. Check billing, budgets and service availability as well. 2. Create and qualify runner groups before migrating workflows. A starting allocation of roughly 20% basic checks, 40% ordinary tests and 40% heavy acceptance can be evaluated against measured minutes; it is not a capacity configuration already applied by this change. -3. After merging and validating the workflows, require the stable `CI result` check while preserving existing review requirements. Reusable workflow check names acquire Main caller prefixes, so external status-check rules must be reconciled. +3. After merging and validating the workflows, require the stable `CI result` check while preserving existing review requirements. The quality job's displayed check name is `CI preflight`; license and scope validation are its steps, rather than separate status checks. Replace required-check rules that still name the old quality, license or scope checks. Reusable workflow check names acquire Main caller prefixes, so external status-check rules must be reconciled. See GitHub's [concurrency semantics](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) and [organization job and capacity monitoring](https://docs.github.com/en/actions/how-tos/manage-runners/github-hosted-runners/view-current-jobs). @@ -89,7 +88,7 @@ See GitHub's [concurrency semantics](https://docs.github.com/en/actions/how-tos/ `CI result` depends on every Main root job. Any failure or cancellation blocks it. Root jobs required by selected scopes must succeed: unexpected skipping or missing results also block. Only explicitly unrelated jobs may skip. -`Upgrade release gate` accepts skipped acceptance only after successful scope selection explicitly excludes upgrades. Selected acceptance must succeed. Cancelled runs allocate no new summary-gate runners; necessary in-job cleanup and diagnostics retain `always()`. Publication still checks the complete upgrade matrix and evidence files independently of path selection. +`Upgrade release gate` accepts skipped acceptance only after successful preflight explicitly excludes upgrades. Selected acceptance must succeed. Cancelled runs allocate no new summary-gate runners; necessary in-job cleanup and diagnostics retain `always()`. Publication still checks the complete upgrade matrix and evidence files independently of path selection. The upgrade evidence collector runs after ordinary case failures so missing or failed evidence cannot pass; it stops on workflow cancellation. Harness acceptance explicitly checks successful validation with a cancellation-aware condition, so skipping the redundant child scope detector cannot silently skip its SQLite/OceanBase matrix. diff --git a/docs/zh/development/ci-scope.md b/docs/zh/development/ci-scope.md index 021cf499af..c54e04081b 100644 --- a/docs/zh/development/ci-scope.md +++ b/docs/zh/development/ci-scope.md @@ -4,26 +4,23 @@ CI 的目标是让每次变更获得必要的验证,同时控制重复工作 ## 入口和执行顺序 -面向 `master` 的普通 PR 和 `master` push 只启动 `Main`。它调用现有工作流,统一计算一次完整变更范围;被调用工作流复用结果,不再分别分配 runner 检测变更。Desktop 的 `codex/desktop-preview` 分支和各工作流手动诊断入口仍可独立运行。 +面向 `master` 的普通 PR 和 `master` push 只启动 `Main`。其中 `CI preflight` 任务只检出一次仓库,在同一 runner 上分别执行范围检测、许可证、质量和 API 合约检查,再将完整变更范围传给现有可复用工作流。被调用工作流复用结果,不再分别分配 runner 检测变更;直接手动运行子工作流时仍自行检测范围。Desktop 的 `codex/desktop-preview` 分支和各工作流手动诊断入口仍可独立运行。 ```mermaid flowchart TD - E[PR / 主干 push / 每日 / 手动] --> S[一次变更检测] - E --> L[License] - L --> Q[质量和 API 合约] - S --> T[选中的 Python 测试] - Q --> T + E[PR / 主干 push / 每日 / 手动] --> Q[CI preflight: 范围、许可证、质量和 API 合约] + Q --> T[选中的 Python 测试] Q --> W[选中的网站与文档检查] T --> H[选中的后端、升级、平台和宿主验收] H --> U[Upgrade release gate] - S --> R[CI result] + Q --> R[CI result] T --> R W --> R H --> R U --> R ``` -License 或质量检查失败后,不展开后续测试和重型任务;Python 测试失败后,不展开依赖它们的后端、升级、平台和宿主任务。网站和文档检查可以在质量检查后与 Python 测试并行。失败保留在最终状态中,不因跳过后续任务而变绿。 +前置检查失败后,不展开后续测试和重型任务;Python 测试失败后,不展开依赖它们的后端、升级、平台和宿主任务。网站和文档检查可以在前置检查成功后与 Python 测试并行。失败保留在最终状态中,不因跳过后续任务而变绿。四项前置检查合并为一个任务,每次常规验证减少两次 runner 分配,许可证和质量检查之间也无需再次排队;每项检查仍有独立的步骤结果和日志。 草稿 PR 不分配自动 CI runner。标记为 ready 时验证完整 PR;转回 draft 会取消旧运行。每个 PR 只保留最新一次自动运行。定时、push、手动入口使用不同的并发组,避免手动诊断取消主干或每日验证。并发组中的取消机制用于淘汰同一变更的旧运行,不作为跨 PR 的资源队列。 @@ -41,6 +38,8 @@ License 或质量检查失败后,不展开后续测试和重型任务;Python 普通 PR 的 Python 测试组合数由 8 降至 3,减少 62.5%。这是组合数量的变化,不是已经测得的 runner 分钟或等待时间降幅。依赖和 CI 策略变更保持全矩阵,因此修改 CI 的 PR 本身可能仍运行 8 个组合。 +安装验证在 packaging 范围选中时运行两个任务,分别使用 Python 3.11 和 3.14。每个任务只构建一次 wheel,依次验证 `builtin`、`cli`、`builtin,cli`、`server,cli` 四种 extras。每个角色使用独立的虚拟环境、应用状态和临时目录,针对已安装 wheel 验证,排除 checkout 源码导入。CLI 角色还检查安装无关的 SQLAlchemy、FastAPI 包后不会启用可选角色。各角色分别产生结果,任何角色失败都会使所属任务失败。全部八个 Python/角色组合仍受验证,同时减少六次 runner 分配和六次重复 wheel 构建。任务数量减少不代表已测得等待时间或吞吐量改善。 + `ci:full` 在 PR 的 opened/synchronize/reopened/ready 事件中读取。给一个正在运行的 PR 添加标签不会再触发一套流水线;需要随后更新提交或重新打开 PR。手动 Main 使用 Actions 界面选择的仓库内 ref;fork PR 应在其来源仓库选择对应分支,不能把默认分支的通过结果当成 PR head 的通过结果。 ## 变更归属与基线 @@ -64,7 +63,7 @@ Helm chart 改动选择部署合约检查。托管资源生产器通过 Main 选 | 矩阵 | 每次运行的最大并发 | 失败策略 | | --- | --- | --- | | Python 测试 | 3 | 首个失败后停止其余组合 | -| 隔离安装角色 | 2 | 首个失败后停止其余组合 | +| 隔离安装,每个 Python 版本一个任务 | 2 | 单个任务尝试全部角色;任务失败后取消尚未结束的另一个 Python 任务 | | 安装发布升级 | 3 | 保留全部用例与证据汇总 | | OceanBase 干净迁移分片 | 2 | 保留全部分片与完整性检查 | | Candidate replay | 2 | 保留所有后端结果 | @@ -73,7 +72,7 @@ Helm chart 改动选择部署合约检查。托管资源生产器通过 Main 选 `max-parallel` 只限制一条矩阵运行,不限制整个仓库或组织。不能把多个 PR 放进一个普通 concurrency 组来模拟全局 semaphore:默认组只保留一个 pending 项,会替换其他 PR 的等待任务。正式发布也不能与普通 PR 共用取消组。 -并发上限不会减少工作总量,还可能延长单个 PR 的完成时间。减少排队主要依靠跳过无关范围、消除重复入口、缩小普通 PR 的兼容矩阵,以及取消被新提交替代的运行。运行时和 CI 策略改动仍需广泛验收,完成时间依然受组织可用容量影响。 +并发上限不会减少工作总量,还可能延长单个 PR 的完成时间。减少排队主要依靠跳过无关范围、消除重复入口、让短检查共用 runner、复用安装构建、缩小普通 PR 的兼容矩阵,以及取消被新提交替代的运行。运行时和 CI 策略改动仍需广泛验收,完成时间依然受组织可用容量影响。 组织层应按实际 runner 并发和 OS 配额建立独立容量池:基础检查与 Python 测试优先保留容量,数据库/升级使用隔离的重型池,Windows/macOS 使用独立平台预算。需要时采用 GitHub larger runner 的 runner group 或有明确隔离边界的临时 runner;具体名称和容量要在组织侧创建后才能写入 `runs-on`,不能在工作流里填写不存在的标签。容量不足应按组织全体仓库核算,不只观察本仓库。 @@ -81,7 +80,7 @@ Helm chart 改动选择部署合约检查。托管资源生产器通过 Main 选 1. 在组织 Settings → Actions → Runners 检查实际活跃任务、并发上限、OS 上限及其他仓库占用;同时核查预算、账单和服务状态。 2. 若需要容量隔离,先创建并验证 runner group,再迁移工作流。初始配比可按基础检查/普通测试/重型后端约 20%/40%/40% 试运行,根据实际分钟数调整;这不是当前已配置的容量。 -3. 工作流合并并验证后,将稳定的 `CI result` 配置为合并必需检查,同时保留现有审批要求。工作流改为可复用后,部分检查名称会带 Main 调用任务前缀,外部规则必须同步核对。 +3. 工作流合并并验证后,将稳定的 `CI result` 配置为合并必需检查,同时保留现有审批要求。quality 任务显示的检查名称为 `CI preflight`,许可证和范围检测是其内部步骤,不再提供独立状态检查;仍要求旧 quality、许可证或范围检查名称的规则需要替换。工作流改为可复用后,部分检查名称会带 Main 调用任务前缀,外部规则必须同步核对。 GitHub 语义参见 [concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) 和 [当前任务与组织容量](https://docs.github.com/en/actions/how-tos/manage-runners/github-hosted-runners/view-current-jobs)。 @@ -89,7 +88,7 @@ GitHub 语义参见 [concurrency](https://docs.github.com/en/actions/how-tos/wri `CI result` 依赖 Main 的全部根任务:任何失败或取消都会阻断;被选中范围对应的根任务必须成功,意外 skipped 或缺失同样阻断。仅明确无关的任务允许跳过。 -`Upgrade release gate` 只在范围检测成功且明确未选择升级时接受 skipped。选中的升级必须成功。旧运行被取消后,汇总门禁不会再分配 runner;任务内部的必要清理和诊断仍保留 `always()`。升级发布继续检查全矩阵和证据文件,不能用 CI 路径判定替代发布验收。 +`Upgrade release gate` 只在前置检查成功且明确未选择升级时接受 skipped。选中的升级必须成功。旧运行被取消后,汇总门禁不会再分配 runner;任务内部的必要清理和诊断仍保留 `always()`。升级发布继续检查全矩阵和证据文件,不能用 CI 路径判定替代发布验收。 升级证据收集在普通 case 失败后仍执行,缺失或失败的证据无法通过;整个运行被取消后停止。Harness Acceptance 显式检查 validate 成功及取消状态,跳过重复的子流程范围检测不会连带跳过 SQLite/OceanBase 验收矩阵。 diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index b24e98dbb9..061c64d755 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -161,7 +161,7 @@ def main() -> None: if rules["checks"].keys() != selected.keys(): raise ValueError("Every CI scope must declare its required result jobs") # noqa: TRY003 required = sorted( - {"changes", "quality", "license-check", "upgrade-release-gate"} + {"quality", "upgrade-release-gate"} | {job for suite, jobs in rules["checks"].items() if selected[suite] for job in jobs} ) print(json.dumps(selected, indent=2)) diff --git a/scripts/ci_installation_roles.py b/scripts/ci_installation_roles.py new file mode 100644 index 0000000000..9e6d5efaf2 --- /dev/null +++ b/scripts/ci_installation_roles.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validate every wheel installation role in a fresh environment on one runner.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROLES = ("builtin", "cli", "builtin,cli", "server,cli") + + +def write_summary(output: Path, report: dict[str, Any]) -> None: + (output / "summary.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + rows = ["## Isolated wheel installations", "", "| Role | Result | Stages |", "| --- | --- | --- |"] + for role in report["roles"]: + stages = ", ".join(f"{stage['name']}: {stage['status']}" for stage in role["stages"]) + rows.append(f"| `{role['extras']}` | {role['status']} | {stages} |") + (output / "summary.md").write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def run_roles(*, wheel: Path, python: Path, smoke_script: Path, output: Path, uv: str) -> int: + # Refuse an existing directory so retries cannot inherit an earlier role's state. + output.mkdir(parents=True, exist_ok=False) + (output / "logs").mkdir() + roles: list[dict[str, Any]] = [{"extras": extras, "status": "pending", "stages": []} for extras in ROLES] + report: dict[str, Any] = { + "python": str(python), + "wheel": wheel.name, + "status": "running", + "roles": roles, + } + write_summary(output, report) + for role in roles: + extras = role["extras"] + slug = extras.replace(",", "-") + root = output / "work" / slug + root.mkdir(parents=True) + environment = dict(os.environ) + # Share dependency downloads only; installed packages and application state stay per role. + environment.setdefault("UV_CACHE_DIR", str(output / "uv-cache")) + for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT"): + environment.pop(name, None) + for name in ("POWERCONTEXT_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"): + directory = root / name.lower() + directory.mkdir() + environment[name] = str(directory) + temporary = root / "tmp" + temporary.mkdir() + environment.update(TMPDIR=str(temporary), TEMP=str(temporary), TMP=str(temporary), NO_COLOR="1") + executable = root / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + commands = [ + ("create environment", [uv, "--no-config", "venv", "--python", str(python), str(root / "venv")]), + ("install wheel", [uv, "--no-config", "pip", "install", "--python", str(executable), f"{wheel}[{extras}]"]), + ("role smoke", [str(executable), "-I", str(smoke_script), extras]), + ] + if extras == "cli": + commands.extend([ + ( + "install unrelated packages", + [uv, "--no-config", "pip", "install", "--python", str(executable), "sqlalchemy", "fastapi"], + ), + ("optional CLI isolation", [str(executable), "-I", str(smoke_script), extras]), + ]) + stages: list[dict[str, Any]] = [{"name": name, "status": "pending"} for name, _ in commands] + role["stages"] = stages + role["status"] = "running" + write_summary(output, report) + log_path = output / "logs" / f"{slug}.log" + with log_path.open("w", encoding="utf-8") as log: + for stage, (name, command) in zip(stages, commands, strict=True): + print(f"[{extras}] {name}", flush=True) + log.write(f"\n=== {name} ===\n") + log.flush() + try: + result = subprocess.run( # noqa: S603 -- fixed commands with explicit wheel/interpreter paths + command, + cwd=root, + env=environment, + stdout=log, + stderr=subprocess.STDOUT, + check=False, + timeout=600, + ) + except (OSError, subprocess.TimeoutExpired) as error: + log.write(f"{type(error).__name__}: {error}\n") + stage["status"] = "failure" + else: + stage["returncode"] = result.returncode + if result.returncode < 0: + raise SystemExit(128 - result.returncode) + stage["status"] = "success" if result.returncode == 0 else "failure" + write_summary(output, report) + if stage["status"] != "success": + break + role["status"] = "success" if all(stage["status"] == "success" for stage in role["stages"]) else "failure" + write_summary(output, report) + print(f"[{extras}] {role['status']}; log: {log_path}", flush=True) + report["status"] = "success" if all(role["status"] == "success" for role in report["roles"]) else "failure" + write_summary(output, report) + return 0 if report["status"] == "success" else 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--python", type=Path, default=Path(sys.executable)) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--smoke-script", type=Path, default=Path(__file__).with_name("ci_role_smoke.py")) + arguments = parser.parse_args() + uv = shutil.which("uv") + if uv is None: + parser.error("uv must be installed on PATH") + for value in (arguments.wheel, arguments.python, arguments.smoke_script): + if not value.is_file(): + parser.error(f"Required file does not exist: {value}") + return run_roles( + wheel=arguments.wheel.resolve(), + python=arguments.python.absolute(), + smoke_script=arguments.smoke_script.resolve(), + output=arguments.output_dir.resolve(), + uv=uv, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index cc9b42e145..9695998396 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -85,11 +85,13 @@ ), (["tests/e2e/test_worker_otlp.py"], {"tests", "observability"}), (["scripts/ci_role_smoke.py"], {"tests", "packaging"}), + (["scripts/ci_installation_roles.py"], {"tests", "packaging"}), + (["tests/test_ci_installation_roles.py"], {"tests", "packaging"}), (["scripts/capture_release_schema.py"], {"upgrade", "release_schema"}), (["scripts/probe_seekdb_migration_lock.py"], {"seekdb_lock"}), (["tests/test_database_migration_cli.py"], {"tests", "database", "upgrade"}), ([".github/workflows/skill-guidance.yml"], {"skill_guidance"}), - ([".github/workflows/license-check.yml"], set()), + ([".licenserc.yaml"], set()), (["integrations/bub/src/powercontext_bub/__init__.py"], {"tests", "harness"}), ( ["docs/en/docs/quickstart.md", "integrations/opencode/package.json"], @@ -398,8 +400,6 @@ def test_selector_cli_exposes_full_matrix_and_required_jobs_on_missing_baseline( assert set(matrix["python-version"]) == {"3.11", "3.12", "3.13", "3.14"} assert set(matrix["suite"]) == {"unit", "e2e"} assert set(json.loads(outputs["required-jobs"])) == {job for jobs in RULES["checks"].values() for job in jobs} | { - "changes", "quality", - "license-check", "upgrade-release-gate", } diff --git a/tests/test_ci_installation_roles.py b/tests/test_ci_installation_roles.py new file mode 100644 index 0000000000..3bca6ca8b4 --- /dev/null +++ b/tests/test_ci_installation_roles.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise batch isolation and result collection with real disposable virtualenvs.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / "scripts/ci_installation_roles.py" +pytestmark = pytest.mark.skipif(os.name == "nt", reason="The Ubuntu batch job probe uses a POSIX uv executable") + + +@pytest.fixture +def installation_probe(tmp_path): + # Simulate only uv's downloads/installations, so the orchestration contract + # runs offline; environment creation and isolated Python smoke processes are real. + uv = tmp_path / "uv" + uv.write_text( + f"#!{sys.executable}\n" + """ +import pathlib +import subprocess +import sys +import venv + +args = sys.argv[1:] +if 'venv' in args: + venv.EnvBuilder(with_pip=False).create(args[-1]) +else: + python = args[args.index('--python') + 1] + site = pathlib.Path(subprocess.check_output( + [python, '-I', '-c', 'import sysconfig; print(sysconfig.get_path("purelib"))'], text=True).strip()) + if args[-1].endswith(']'): + extras = args[-1].split('[')[1].removesuffix(']') + (site / 'installed_role.py').write_text('ROLE = ' + repr(extras)) + else: + for name in ('sqlalchemy', 'fastapi'): + (site / (name + '.py')).write_text('') +""", + encoding="utf-8", + ) + uv.chmod(0o755) + smoke = tmp_path / "smoke.py" + smoke.write_text( + """ +import importlib.util +import json +import os +import pathlib +import sys +import tempfile + +import installed_role + +role = sys.argv[1] +assert sys.flags.isolated +assert sys.prefix != sys.base_prefix +assert installed_role.ROLE == role +root = pathlib.Path.cwd() +names = ('POWERCONTEXT_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME') +for name in names: + directory = pathlib.Path(os.environ[name]) + assert directory.parent == root + marker = directory / 'owner' + if marker.exists(): + assert marker.read_text() == role + marker.write_text(role) +assert pathlib.Path(tempfile.gettempdir()).parent == root +counter = pathlib.Path(os.environ['POWERCONTEXT_HOME']) / 'smoke-count' +attempt = int(counter.read_text()) + 1 if counter.exists() else 1 +counter.write_text(str(attempt)) +assert (importlib.util.find_spec('fastapi') is not None) == (role == 'cli' and attempt == 2) +with open(os.environ['PROBE_REPORT'], 'a') as report: + report.write(json.dumps({'role': role, 'venv': sys.prefix, 'cwd': str(root), 'attempt': attempt}) + '\\n') +if role == os.environ.get('FAIL_ROLE') and attempt == int(os.environ.get('FAIL_ATTEMPT', '1')): + raise RuntimeError('deliberate role failure') +""", + encoding="utf-8", + ) + wheel = tmp_path / "fixture.whl" + wheel.touch() + return [sys.executable, str(SCRIPT), "--wheel", str(wheel), "--smoke-script", str(smoke)] + + +@pytest.mark.parametrize(("failed_role", "failed_attempt"), [("", 1), ("builtin", 1), ("cli", 2)]) +def test_batch_keeps_all_roles_isolated_and_reports_every_failure( + installation_probe, tmp_path, failed_role, failed_attempt +): + output = tmp_path / "results" + evidence = tmp_path / "smokes.jsonl" + environment = { + **os.environ, + "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], + "PROBE_REPORT": str(evidence), + "FAIL_ROLE": failed_role, + "FAIL_ATTEMPT": str(failed_attempt), + } + result = subprocess.run( + [*installation_probe, "--output-dir", str(output)], + env=environment, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + assert result.returncode == (1 if failed_role else 0), result.stdout + result.stderr + report = json.loads((output / "summary.json").read_text()) + assert report["status"] == ("failure" if failed_role else "success") + roles = {row["extras"]: row for row in report["roles"]} + assert set(roles) == {"builtin", "cli", "builtin,cli", "server,cli"} + for role, row in roles.items(): + assert row["status"] == ("failure" if role == failed_role else "success") + assert all(stage["status"] == "success" for stage in row["stages"]) == (role != failed_role) + assert (output / "logs" / f"{role.replace(',', '-')}.log").is_file() + records = [json.loads(line) for line in evidence.read_text().splitlines()] + assert {record["role"] for record in records} == set(roles) + assert len({record["venv"] for record in records}) == 4 + assert len({record["cwd"] for record in records}) == 4 + assert [record["attempt"] for record in records if record["role"] == "cli"] == [1, 2] + assert all(role in (output / "summary.md").read_text() for role in roles) + + # A repeated invocation must not accidentally pass with environments from the first run. + rerun = subprocess.run( + [*installation_probe, "--output-dir", str(output)], + env=environment, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + assert rerun.returncode != 0 + assert [json.loads(line) for line in evidence.read_text().splitlines()] == records diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index 8cf4fe51d1..ea808a474c 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -78,10 +78,51 @@ def test_selected_scopes_have_a_complete_terminal_result_contract(): main = workflow("master.yml")["jobs"] assert RULES["checks"].keys() == RULES["suites"].keys() covered = {job for jobs in RULES["checks"].values() for job in jobs} - assert covered | {"changes", "quality", "license-check", "upgrade-release-gate", "ci-result"} == set(main) + assert covered | {"quality", "upgrade-release-gate", "ci-result"} == set(main) assert set(main["ci-result"]["needs"]) == set(main) - {"ci-result"} +def test_preflight_uses_one_runner_and_keeps_all_admission_checks_mandatory(): + jobs = workflow("master.yml")["jobs"] + assert {name for name, job in jobs.items() if not job.get("needs")} == {"quality"} + preflight = jobs["quality"] + assert "!github.event.pull_request.draft" in preflight["if"] + assert not preflight.get("continue-on-error") + steps = preflight["steps"] + checkouts = [step for step in steps if step.get("uses", "").startswith("actions/checkout@")] + assert len(checkouts) == 1 + assert checkouts[0]["with"]["fetch-depth"] == 0 + selection = next(step for step in steps if step.get("id") == "select") + license_check = next(step for step in steps if step.get("uses", "").startswith("apache/skywalking-eyes/header@")) + quality = next(step for step in steps if step.get("run") == "make check") + contract = next(step for step in steps if step.get("run") == "make contract-test") + assert selection["env"]["CI_BASELINE_WORKFLOW"] == "master.yml" + assert license_check["with"] == {"config": ".licenserc.yaml", "mode": "check"} + for step in (selection, license_check, quality, contract): + assert not step.get("continue-on-error") + assert "if" not in step # A preceding failure prevents further admission work. + for output in ("suites", "test-matrix", "required-jobs"): + assert preflight["outputs"][output] == "${{ steps.select.outputs." + output + " }}" + for name, job in jobs.items(): + if name not in {"quality", "upgrade-release-gate", "ci-result"}: + assert "quality" in job["needs"] + assert "needs.quality.result == 'success'" in job["if"] + + +def test_installation_roles_use_only_two_python_runners(): + job = workflow("master.yml")["jobs"]["isolated-installations"] + assert job["strategy"]["matrix"] == {"python-version": ["3.11", "3.14"]} + build = [step for step in job["steps"] if "uv build --wheel" in step.get("run", "")] + assert len(build) == 1 + verify = next(step for step in job["steps"] if "ci_installation_roles.py" in step.get("run", "")) + assert not verify.get("continue-on-error") + assert "if" not in verify + reports = next(step for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) + assert reports["if"] == "always()" + assert "summary.json" in reports["with"]["path"] + assert "logs/*.log" in reports["with"]["path"] + + def test_automatic_host_workflows_share_one_scope_and_admission_gate(): assert workflow("master.yml")["on"]["push"]["branches"] == ["master"] assert workflow("master.yml")["on"]["pull_request"]["branches"] == ["master"] @@ -92,19 +133,14 @@ def test_automatic_host_workflows_share_one_scope_and_admission_gate(): assert "pull_request" not in events, path.name assert "master" not in events.get("push", {}).get("branches", []), path.name for job in main.values(): - if ( - not job.get("uses", "").endswith(".yml") - or "ci-changes" in job["uses"] - or "upgrade-release" in job["uses"] - or "license-check" in job["uses"] - ): + if not job.get("uses", "").endswith(".yml") or "ci-changes" in job["uses"] or "upgrade-release" in job["uses"]: continue child = workflow(Path(job["uses"]).name) assert "pull_request" not in child["on"] assert "schedule" not in child["on"] assert "master" not in child["on"].get("push", {}).get("branches", []) - assert job["with"]["suites"] == "${{ needs.changes.outputs.suites }}" - assert {"changes", "quality", "tests"} <= set(job["needs"]) + assert job["with"]["suites"] == "${{ needs.quality.outputs.suites }}" + assert {"quality", "tests"} <= set(job["needs"]) assert "needs.quality.result == 'success'" in job["if"] assert "needs.tests.result" in job["if"] assert child["jobs"]["changes"]["if"] == "${{ inputs.suites == '' }}" @@ -133,6 +169,7 @@ def test_cancelled_or_draft_runs_cannot_start_terminal_runner_jobs(): assert "converted_to_draft" in workflow("master.yml")["on"]["pull_request"]["types"] +@pytest.mark.parametrize("selected_job", ["quality", "harness-validation"]) @pytest.mark.parametrize( ("selected_result", "unselected_result", "passes"), [ @@ -143,12 +180,14 @@ def test_cancelled_or_draft_runs_cannot_start_terminal_runner_jobs(): ("success", "failure", False), ], ) -def test_actual_ci_result_script_rejects_missing_selected_acceptance(selected_result, unselected_result, passes): +def test_actual_ci_result_script_rejects_missing_selected_acceptance( + selected_job, selected_result, unselected_result, passes +): script = workflow("master.yml")["jobs"]["ci-result"]["steps"][0]["run"] script = script.split("<<'PYTHON'\n", 1)[1].rsplit("PYTHON", 1)[0] - required = ["changes", "quality", "license-check", "upgrade-release-gate", "harness-validation"] + required = ["quality", "upgrade-release-gate", "harness-validation"] results = {job: {"result": "success"} for job in required} - results["harness-validation"]["result"] = selected_result + results[selected_job]["result"] = selected_result results["desktop-validation"] = {"result": unselected_result} run = subprocess.run( [sys.executable, "-c", script], diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index a56b3c30d3..0bf96eeb91 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -450,7 +450,7 @@ def test_publishing_jobs_require_upgrade_acceptance_unless_manually_bypassed(): assert verdict["name"] == "Upgrade release gate" assert "!cancelled()" in verdict["if"] dependencies = verdict["needs"] - assert {"changes", "upgrade-acceptance"} <= set([dependencies] if isinstance(dependencies, str) else dependencies) + assert {"quality", "upgrade-acceptance"} <= set([dependencies] if isinstance(dependencies, str) else dependencies) assert not verdict.get("continue-on-error") assert all(not step.get("continue-on-error") for step in verdict["steps"]) From 09602330e4cfc68d90ca3ab8660f74f4aa3e0dc1 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Sat, 31 Oct 2026 08:50:35 +0800 Subject: [PATCH 7/8] fix(ci): refresh code persistence review and test setup --- release/upgrade-support.json | 4 ++-- tests/builtin/test_code_publication.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index f29f0f4319..148a8e4234 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "6479e5cdb3ffeba35a45b1e1d0632b7890cbc2990c593b437c2e33b66116149b", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged." + "persistence_sha256": "e3a3265bf8e901025a58ad794f789f07e118fa3eac9c5573d2b2551eaa01b9c4", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance." }, "cases": [], "automatic_sources": { diff --git a/tests/builtin/test_code_publication.py b/tests/builtin/test_code_publication.py index 078203a5c7..f97d158449 100644 --- a/tests/builtin/test_code_publication.py +++ b/tests/builtin/test_code_publication.py @@ -308,7 +308,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") store.remove_generation("a" * 32, "binding", time.monotonic() + 30) @@ -490,7 +490,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") with pytest.raises(CodeError): store.remove_generation("not-hex", "binding", time.monotonic() + 30) @@ -538,7 +538,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") store.remove_generation("b" * 32, "binding", time.monotonic() + 30) From ebc1385b496f36f612f6e495e3406821c0a2a6b3 Mon Sep 17 00:00:00 2001 From: hidb4ai Date: Sat, 31 Oct 2026 10:58:51 +0800 Subject: [PATCH 8/8] test(migrations): align vector admission connection fixture --- tests/builtin/persistence/test_mysql_vector_admission.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/builtin/persistence/test_mysql_vector_admission.py b/tests/builtin/persistence/test_mysql_vector_admission.py index 14a92a7721..330dd3ea2d 100644 --- a/tests/builtin/persistence/test_mysql_vector_admission.py +++ b/tests/builtin/persistence/test_mysql_vector_admission.py @@ -69,7 +69,9 @@ def runner(selected=bundle, configured=configuration): monkeypatch.setattr(migration, "mysql_inventory", lambda _connection: copy.deepcopy(inventory)) monkeypatch.setattr(mysql_schema, "mysql_inventory", lambda _connection: copy.deepcopy(inventory)) - def run(operation, *, writable=False): + def run(operation, *, writable=False, allow_missing_database=False): + # This fixture always has a database, so missing-database admission has no effect. + del allow_missing_database assert not writable, "Admission must fail before requesting a write connection" engine = create_engine(f"sqlite:///{database}") try: