Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 91 additions & 18 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Python Tests

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
workflow_dispatch:
push:
branches:
- main
Expand Down Expand Up @@ -32,6 +34,10 @@ jobs:
timeout-minutes: 3
outputs:
core_tests: ${{ steps.classify.outputs.core_tests }}
change_kind: ${{ steps.classify.outputs.change_kind }}
python_tests: ${{ steps.classify.outputs.python_tests }}
stage2c_tests: ${{ steps.classify.outputs.stage2c_tests }}
presentation_tests: ${{ steps.classify.outputs.presentation_tests }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -40,21 +46,42 @@ jobs:
with:
python-version: "3.11"
- name: Validate merge gate semantics
run: python -m unittest discover -s scripts/ci -p 'test_review_gate.py'
run: python -m unittest discover -s scripts/ci -p 'test_*.py'
- name: Classify the exact pull-request change
id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CHECKOUT_SHA: ${{ github.sha }}
FORCE_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
shell: bash
run: |
set -euo pipefail
extra=()
if [[ "$FORCE_FULL" == true ]]; then extra+=(--force-full); fi
if [[ "$EVENT_NAME" == pull_request ]]; then
python scripts/ci/review_gate.py classify --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT"
python scripts/ci/review_gate.py classify --base "$BASE_SHA" --head "$HEAD_SHA" --plan impact-plan.json "${extra[@]}" >> "$GITHUB_OUTPUT"
else
echo 'core_tests=true' >> "$GITHUB_OUTPUT"
python scripts/ci/review_gate.py classify --base "$CHECKOUT_SHA" --head "$CHECKOUT_SHA" --non-pr --plan impact-plan.json >> "$GITHUB_OUTPUT"
fi
python - <<'PY'
import json, os
from pathlib import Path
plan = json.loads(Path("impact-plan.json").read_text())
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
summary.write("## CI job exemptions\n\n")
summary.write(f"Change kind: **{plan['change_kind']}**; Python shards: **{plan['python_shards']}**.\n\n")
summary.write(plan['reason'] + ".\n\n")
summary.write(f"Python: {plan['python_tests']}; Stage2c: {plan['stage2c_tests']}; frontend: {plan['presentation_tests']}.\n\n")
summary.write(plan["coverage_scope"] + ". Unknown/mixed changes and main run full. Label ci:full forces full qualification.\n")
PY
- uses: actions/upload-artifact@v7
with:
name: ci-impact-plan
path: impact-plan.json
if-no-files-found: error
retention-days: 7

checks:
needs: changes
Expand Down Expand Up @@ -193,13 +220,13 @@ jobs:

test-shard:
needs: changes
if: needs.changes.outputs.core_tests == 'true'
if: needs.changes.outputs.python_tests == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2]
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -223,11 +250,20 @@ jobs:
# Each runner retains the measured two-worker pool.
run: >-
python -m pytest -q -n 2 -m "not stage2c_e2e"
--splits 2 --group ${{ matrix.shard }}
--splits 4 --group ${{ matrix.shard }}
--splitting-algorithm least_duration
--durations=25 --durations-min=1
--junitxml=junit.xml
--cov=loopx
--cov-report=term
- name: Upload full-shard outcomes for selection audit
if: always()
uses: actions/upload-artifact@v7
with:
name: python-junit-${{ matrix.shard }}
path: junit.xml
if-no-files-found: error
retention-days: 7
- name: Upload shard coverage
uses: actions/upload-artifact@v7
with:
Expand All @@ -239,7 +275,7 @@ jobs:

pytest:
# Keep the required check name; a skipped/failed shard must not turn it green.
if: always() && needs.changes.outputs.core_tests == 'true'
if: always() && needs.changes.outputs.python_tests == 'true'
needs: [changes, checks, test-shard]
runs-on: ubuntu-latest
timeout-minutes: 10
Expand All @@ -263,9 +299,13 @@ jobs:
path: coverage-shards
- name: Combine complete coverage and enforce the existing floor
run: |
test -s coverage-shards/python-coverage-1/.coverage
test -s coverage-shards/python-coverage-2/.coverage
python -m coverage combine coverage-shards/python-coverage-1/.coverage coverage-shards/python-coverage-2/.coverage
shards=()
for shard in 1 2 3 4; do
path="coverage-shards/python-coverage-${shard}/.coverage"
test -s "$path"
shards+=("$path")
done
python -m coverage combine "${shards[@]}"
python -m coverage report --fail-under=19.6
python -m coverage xml -o coverage.xml
- uses: actions/upload-artifact@v7
Expand All @@ -283,14 +323,18 @@ jobs:

stage2c-suite:
needs: changes
if: needs.changes.outputs.core_tests == 'true'
name: stage2c (${{ matrix.suite }})
if: needs.changes.outputs.stage2c_tests == 'true'
name: stage2c (${{ matrix.suite }} ${{ matrix.shard }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
suite: [e2e, mutants, installed]
include:
- {suite: e2e, shard: 1}
- {suite: e2e, shard: 2}
- {suite: mutants, shard: 0}
- {suite: installed, shard: 0}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
Expand Down Expand Up @@ -332,7 +376,7 @@ jobs:
env:
LOOPX_SHADOW_COMPARISON_OUTPUT: .local/stage2c-observables
# Keep each module's shared workspace and ordered parity rows on one worker.
run: python -m pytest -q -n 4 --dist loadfile -m stage2c_e2e --durations=20 --junitxml=stage2c-e2e.xml
run: python -m pytest -q -n 2 --dist loadfile -m stage2c_e2e -p scripts.ci.module_shard --ci-module-shards 2 --ci-module-shard ${{ matrix.shard }} --durations=20 --junitxml=stage2c-e2e.xml
- name: Reject deliberate correctness regressions
if: matrix.suite == 'mutants'
run: python examples/shared-goal-authority-e2e/mutants.py --output .local/stage2c-mutants
Expand All @@ -349,7 +393,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: stage2c-correctness-evidence-${{ matrix.suite }}
name: stage2c-correctness-evidence-${{ matrix.suite }}-${{ matrix.shard }}
include-hidden-files: true
if-no-files-found: error
path: |
Expand All @@ -361,7 +405,7 @@ jobs:

stage2c-correctness-e2e:
# Preserve the public check name and reject failed, cancelled or skipped lanes.
if: always() && needs.changes.outputs.core_tests == 'true'
if: always() && needs.changes.outputs.stage2c_tests == 'true'
needs: [changes, stage2c-suite]
runs-on: ubuntu-latest
timeout-minutes: 2
Expand All @@ -373,7 +417,7 @@ jobs:

windows-powershell:
needs: changes
if: needs.changes.outputs.core_tests == 'true'
if: needs.changes.outputs.python_tests == 'true'
runs-on: windows-latest
timeout-minutes: 20
steps:
Expand Down Expand Up @@ -426,10 +470,39 @@ jobs:
tests/control_plane_ts/local_authority_provider.test.ts
tests/control_plane_ts/sqlite_runtime_admission.test.ts

presentation:
needs: changes
if: needs.changes.outputs.presentation_tests == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: pip
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
cache-dependency-path: apps/presentation/dashboard/package-lock.json
- run: python -m pip install --disable-pip-version-check -e ".[test]"
- name: Build and test the actual packaged Dashboard
working-directory: apps/presentation/dashboard
run: |
npm ci --ignore-scripts
npm run build:chat
if [[ -n "$(git -C ../../.. status --short --untracked-files=all -- loopx/web/chat)" ]]; then
echo "Packaged Dashboard differs from its source build." >&2
exit 1
fi
./node_modules/.bin/playwright install --with-deps chromium
npm run smoke:personal-workspace-packaged

merge-gate:
# Always publish one stable outcome, including documentation-only PRs.
if: always()
needs: [changes, pytest, node-minimum-compatibility, stage2c-correctness-e2e, windows-powershell]
needs: [changes, checks, pytest, node-minimum-compatibility, stage2c-correctness-e2e, windows-powershell, presentation]
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
Expand Down
88 changes: 88 additions & 0 deletions docs/development/ci-impact-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# CI job exemptions / 按职责免跑重型 CI

## Policy, not a hand-maintained test selection

Every PR receives the same stable merge gate. The classifier reads the complete
NUL-delimited Git diff at immutable base/head revisions; a PR title, label or
author description cannot claim that runtime changes are “just UI”.

| Whole PR | Common TS/lint/contracts | Full Python / Windows | Stage2c | Packaged Dashboard |
| --- | --- | --- | --- | --- |
| Existing Markdown-only documentation exemption | Skip | Skip | Skip | Existing Frontstage workflow |
| Client Dashboard source/assets only, optionally with docs | Run | Skip | Skip | Required build, freshness and browser smoke |
| Backend, prompt, tests, dependency, build, CI policy, mixed or unknown | Run | Run | Run | Also run for CI-policy rehearsal or forced-full UI |
| main push / manual run | Run | Run | Run | Existing surface workflows; CI-policy rehearsal when applicable |

The presentation boundary is deliberately small: Dashboard `src/`, `public/`
and packaged `loopx/web/chat/`, with explicit client-code/image/font extensions.
Package manifests, Vite/build configuration, native desktop code, backend Python
and arbitrary JSON are not exempt. Both sides of renames are classified; moving
runtime code into a UI directory stays full. Symlinks/type changes cannot qualify.

The new exemption is available only when the selector, gate and workflow blobs
match the already-reviewed target branch. Changing CI policy cannot exempt its
own PR. Missing policy or uncertain ownership runs full; missing Git revisions
fail classification. The pre-existing documentation exemption remains supported.

本方案不是为每类 PR 维护一套测试清单,而是明确重型 job 的职责。纯前端变化不需要
重跑后端持久化与崩溃恢复矩阵,但前端自己的实际构建与浏览器验收成为必需项。
预算、静态宿主 prompt 和 Python/TS 逻辑暂不享受免跑;它们仍可能改变核心行为。
新增一种豁免只需审阅其业务边界和保留的验收,不要求列举全部替代测试文件。

## Four complete Python shards

Full Python qualification uses four runners with two xdist workers each:
`--splits 4 --group N --splitting-algorithm least_duration`. It still partitions
the whole collection, excluding only the separately executed Stage2c marker.
No tests are removed. Without timing history the splitter uses equal weights;
four-way parallelism is not a claim of perfect duration balancing.

The aggregate requires every shard to succeed and all four coverage files to
exist before combining them. The existing full-suite coverage floor remains.
No Python coverage artifact or Sonar run is manufactured when Python is exempt.

全量 Python 从 2 个分片扩大到 4 个,每片仍为 2 个 worker,不提高单机进程争抢。
真实 pytest-split/xdist/coverage 回归覆盖分片集合互斥、并集完整、四份报告合并以及
缺失任意报告时拒绝通过。分片增加会增加安装开销与同时占用的 runner;应看实际
critical path 和 runner-minutes,而不是宣称“4 片必然快一倍”。

## Override and evidence

Add the **`ci:full`** PR label to force full qualification. Label addition/removal
reruns the workflow. Manually dispatching Python Tests also runs full. The label
can only add checks, never waive them. Main retains full qualification.

The `ci-impact-plan` artifact and job summary report exact revisions, change kind,
per-job execution flags, reason and coverage scope. The merge gate requires
success for required jobs and an explicit skip for exempt ones; failure,
cancellation, missing outputs, contradictory flags or unexpected skips fail.

Stage2c retains all correctness cases: its E2E lane uses two runners with two
workers each, while mutants and installed-package lanes remain separate. The
small pytest plugin assigns whole modules using deterministic largest-first
test-count balancing and retains collection order within each module. It does
not split a stateful module across machines or workers. This is not timing-based
optimal scheduling: one very large module can still dominate a shard.

Stage2c E2E 从单 runner 的 4 个 worker 改为两个 runner 各 2 个 worker;总 worker
数不增加,但不再挤在同一台机器。按完整模块分片,并保留 loadfile 与模块内顺序。
真实回归以共享状态、顺序敏感的模块验证两路并集完整且互斥,避免盲目按单测试分片。

The first implementation retains minimum-Node checks and removes the earlier vision selected-test runner,
selected/full comparison machinery and its extra shadow workload. The prior
shadow results remain historical evidence, not a permanent extra CI obligation.

## Qualification

```bash
python -m unittest discover -s scripts/ci -p 'test_*.py'
python -m pytest tests/test_python_ci_workflow.py tests/test_sonarcloud_workflow.py -q
python scripts/ci/review_gate.py classify --base origin/main --head HEAD --plan impact-plan.json
python scripts/ci/review_gate.py classify --base origin/main --head HEAD --force-full --plan impact-plan.json
```

Use repository-supported Python/test dependencies. Keep generated plans and
JUnit/coverage artifacts outside tracked source. Hosted CI must qualify the real
workflow after policy changes; unit checks do not prove runner scheduling or
latency. Paid model tests remain release/manual only. No Goal, automation,
authority provider, runtime permission or live state is changed by this policy.
28 changes: 19 additions & 9 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ golden 来让测试通过。

`python-tests.yml` publishes `merge-gate` for every pull request. Code,
workflow, policy and unknown paths require the existing `pytest` aggregate
(including TypeScript checks and both Python shards), Stage 2C correctness
(including TypeScript checks and all four Python shards), Stage 2C correctness
aggregate, and Windows tests to succeed. Failed, cancelled, missing or
unexpectedly skipped results cannot pass the gate.
unexpectedly skipped results cannot pass the gate. The client-only exception
below retains common checks and substitutes packaged Dashboard qualification
for unrelated backend jobs.

For a change limited to allowlisted root Markdown or `docs/**/*.md`, the
classifier explicitly skips the expensive core jobs and the aggregate checks
Expand Down Expand Up @@ -110,6 +112,14 @@ Changes to the classifier or workflow need both code-path and documentation-only
qualification. Keep required check names stable and never require a
workflow-level path-filtered check that cannot report on every PR.

The [job exemption policy](ci-impact-selection.md) additionally permits pure
Dashboard-client changes to skip backend Python/Windows and Stage2c, while
requiring common checks and the real packaged Dashboard build/browser smoke.
Mixed, prompt, dependency and unknown changes stay full. Full Python runs four
complete shards and combines all four coverage files. The `ci:full` label forces
full qualification; main stays full. No selected-only report impersonates full
coverage. 新的前端豁免由目标分支已审阅的策略控制;CI 自身变更仍全量验证。

PRs opened before activation may need a branch update to produce the new
required check; an old green suite alone does not supply a missing aggregate.

Expand Down Expand Up @@ -335,7 +345,7 @@ network latency, provider availability, or a two-hour matrix.
它刻意不包含真实模型调用和 full smoke catalog,因此普通迭代不依赖凭证、网络
时延、模型服务可用性或两小时级测试矩阵。

The Linux suite uses two hosted runners with two xdist workers each.
The Linux suite uses four hosted runners with two xdist workers each.
`pytest-split` partitions the complete collection using `least_duration`;
without a timing file, tests have equal weight and alternate between shards.
Lint, type checks, and the CLI budget run separately. The required `pytest`
Expand All @@ -345,18 +355,18 @@ individual shards. Relative coverage paths make reports portable across runners.
The reusable Sonar workflow consumes that same run's XML and never reruns
pytest or reads cross-run artifacts. Missing Sonar tokens still skip analysis
successfully; test jobs receive no Sonar secret. The trigger is the union of
the former Python and Sonar paths, so app-only and Sonar-configuration changes
also run this lane, including on forks without a token.
the former Python and Sonar paths. Client-only PRs use the exemption above;
Sonar-configuration changes remain full, including on forks without a token.

Linux 全套测试分到两台 hosted runner,每台保留两个 xdist worker。`pytest-split`
Linux 全套测试分到四台 hosted runner,每台保留两个 xdist worker。`pytest-split`
按完整 collection 分片;没有历史耗时时,等权测试交替分配。lint、类型检查和 CLI
预算独立执行。必需的 `pytest` 汇总检查会拒绝失败/跳过的分片和缺失的 coverage,
合并后再执行原有 19.6% 门槛;不要求单个分片达到全套覆盖率。coverage 使用相对路径,
Sonar 只复用同一次 run 的 XML,不重复测试、不跨 run 取产物。缺少 token 仍成功跳过
Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集,因此仅改
前端或 Sonar 配置也走此通道,包括没有 token 的 fork。
Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集;纯前端
PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token 的 fork。

Reproduce one shard locally with `python -m pytest -q -n 2 --splits 2 --group 1
Reproduce one shard locally with `python -m pytest -q -n 2 --splits 4 --group 1
--splitting-algorithm least_duration --cov=loopx`. Omit the split arguments to
run the complete suite locally. 全量本地测试仍省略分片参数即可。

Expand Down
Loading
Loading