diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 47bc9b5b7..d12e9cac4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,10 +123,30 @@ jobs: timeout-minutes: 10 run: uv run --locked --no-sync python tests/run.py --shard "${{ matrix.shard }}" - coverage: + coverage-shard: runs-on: ubuntu-latest - name: Coverage Report - timeout-minutes: 20 + name: Coverage Shard (${{ matrix.shard }}) + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - shard: '1/8' + index: '1' + - shard: '2/8' + index: '2' + - shard: '3/8' + index: '3' + - shard: '4/8' + index: '4' + - shard: '5/8' + index: '5' + - shard: '6/8' + index: '6' + - shard: '7/8' + index: '7' + - shard: '8/8' + index: '8' steps: - name: Checkout code @@ -144,14 +164,55 @@ jobs: - name: Install dependencies run: uv sync --locked - - name: Generate coverage reports - timeout-minutes: 15 + - name: Generate coverage data + timeout-minutes: 10 + run: uv run --locked --no-sync python -m coverage run --parallel-mode tests/run.py --shard "${{ matrix.shard }}" + + - name: Upload coverage data + uses: actions/upload-artifact@v7 + with: + name: coverage-data-${{ matrix.index }} + path: .coverage.* + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + coverage-report: + runs-on: ubuntu-latest + name: Coverage Report + needs: coverage-shard + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up uv + uses: astral-sh/setup-uv@v10.0.1 + with: + python-version: '3.14' + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Install dependencies + run: uv sync --locked + + - name: Download coverage data + uses: actions/download-artifact@v8 + with: + pattern: coverage-data-* + path: coverage-data + merge-multiple: true + + - name: Combine coverage data and check ratchet run: | - uv run --locked --no-sync python -m coverage erase - uv run --locked --no-sync python -m coverage run tests/run.py --serial + uv run --locked --no-sync python -m coverage combine coverage-data uv run --locked --no-sync python -m coverage report uv run --locked --no-sync python -m coverage json uv run --locked --no-sync python -m coverage xml + uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py - name: Upload coverage report uses: actions/upload-artifact@v7 @@ -161,6 +222,3 @@ jobs: coverage.xml coverage.json retention-days: 7 - - - name: Check coverage ratchet - run: uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 43c989906..10090e7fb 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -19,7 +19,7 @@ | RES-002 | `DELIVERED`(R2) | 复杂度门禁漏扫私有方法、类/文件和 Scheduler;原生并发清单不完整 | `f7ca7e517`、`5cd5780d3`:完整宿主 AST、canonical owner/count、低水位与零增长门禁已落地 | | RES-003 | `DELIVERED`(R3) | Outbox after_commit 失败只有内存 pending 标记,重启不可恢复 | `9d06f91bb`:持久 intent、唯一 handler、claim/fencing、重启回放、幂等和失败观测完整闭环 | | RES-004 | `DELIVERED`(R4) | Startup initializer 与插件市场存在多套 Transport/Adapter/Manager 构造 | `7f5b8b469` 至 `046b0b305`:构造回收到 composition,兼容门面消费同一 owner,canonical 无重复正式实现 | -| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.80%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` | +| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.86%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` | R2 门禁现已完整覆盖私有、dunder、任意控制流嵌套方法、类、文件与 `app/scheduler/`;`concurrency.py` 扫描完整宿主源码,按 canonical import/alias、 TaskGroup、可证明的 loop/executor 来源和词法 owner 聚合数量。新增 owner、数量增长以及 @@ -101,9 +101,9 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,986 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,983 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 | | Ruff 历史诊断 | 630 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 81.80%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| 覆盖率低水位 | Application 81.86%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 @@ -805,9 +805,11 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 # 广泛变更的最终本地回归 uv run --locked --no-sync python tests/run.py -# 需要覆盖率证据时按 CI 串行生成真实报告,再检查低水位 -uv run --locked --no-sync python -m coverage erase -uv run --locked --no-sync python -m coverage run tests/run.py --serial +# 需要覆盖率证据时按 CI 的 8 个分片采集并合并,再检查低水位 +for shard in 1/8 2/8 3/8 4/8 5/8 6/8 7/8 8/8; do + uv run --locked --no-sync python -m coverage run --parallel-mode tests/run.py --shard "$shard" +done +uv run --locked --no-sync python -m coverage combine uv run --locked --no-sync python -m coverage json uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py diff --git a/docs/development-setup.md b/docs/development-setup.md index 9df862f54..1ca939034 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -199,10 +199,11 @@ uvx --from pip-audit pip-audit \ `python tests/run.py` 在本地默认把排序后的测试文件按向上取整的连续区间切成 4 片, 并启动 4 个独立 pytest 进程;GitHub Actions 使用同一入口的 `--shard N/TOTAL` 参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。Coverage job - 会在 `v3` 的 PR / push 中串行运行同一全量入口,并检查 Application 与 Domain 的 - 已提交低水位;它不是只在手工触发时运行的建议性报告。该 job 总预算为 20 分钟, - 其中全量测试及报告生成 step 预算为 15 分钟,用于容纳 Ubuntu Runner 的性能波动, - 不得通过跳过测试文件或覆盖率产物规避超时。 + 会在 `v3` 的 PR / push 中将同一全量入口切成 8 个并行分片,分别上传覆盖率数据,再由 + 单一报告 job 合并并检查 Application 与 Domain 的已提交低水位;它不是只在手工触发时 + 运行的建议性报告。每个 Coverage 分片预算为 15 分钟(其中测试 step 为 10 分钟), + 报告合并与 ratchet 预算为 10 分钟,用于容纳 Ubuntu Runner 的性能波动,不得通过跳过 + 测试文件或覆盖率产物规避超时。 4. **运行架构与静态门禁**:主仓架构检查不依赖独立插件仓;官方插件兼容观察单独运行, 任何检查命令都不会写入 fixture。 diff --git a/docs/testing.md b/docs/testing.md index 7c65c8b78..d0bf0a7a5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -145,9 +145,9 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch): ## CI 与 PR -- **门禁**:`.github/workflows/test.yml` 在指向 `v3` 的 `pull_request` / `push` 及手动触发时,从 `uv.lock` 同步环境。独立 `architecture` job 先运行宿主依赖、运行契约和基线 CLI 快速门禁;全量测试再通过 `tests/run.py --shard N/TOTAL` 稳定分到 4 个 pytest job。每个分片都有独立进程和临时 `CONFIG_DIR`,不共用 SQLite 或进程级状态。 +- **门禁**:`.github/workflows/test.yml` 在指向 `v3` 的 `pull_request` / `push` 及手动触发时,从 `uv.lock` 同步环境。独立 `architecture` job 先运行宿主依赖、运行契约和基线 CLI 快速门禁;全量测试再通过 `tests/run.py --shard N/TOTAL` 稳定分到 4 个 pytest job。每个分片都有独立进程和临时 `CONFIG_DIR`,不共用 SQLite 或进程级状态。Coverage 另以 8 个并行分片采集数据,由单一报告 job 合并后检查低水位。 - **跨仓观察**:`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。 - **静态检查**:`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。 - **PR 本地验证**:提交前运行受影响测试和适用的静态检查。涉及依赖或锁文件、共享测试基建、数据库、启动链、跨模块生命周期、兼容层或大范围行为变化时,运行 `uv run --locked --no-sync python tests/run.py` 完成本地全量;需要断点、输出顺序或测试污染诊断时使用 `--serial`。所有测试都应确认受影响路径通过且 socket 探针无真实出站,验证说明准确标注执行范围。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更执行适用的文本、结构和 diff 检查,CI 继续运行全量门禁。 -- **覆盖率门禁**:`Coverage Report` job 会在 `v3` 的 PR、push 和手工触发中通过 `tests/run.py --serial` 跑串行全量,先上传 JSON / XML 工件,再只读检查 Application 与 Domain 的 Ubuntu/Python 3.14 canonical 低水位。覆盖率下降会阻塞,提升或等比例快照变化也必须显式刷新 fixture;macOS 本地报告只用于诊断,不直接作为可提交基线。 +- **覆盖率门禁**:`Coverage Shard` jobs 会在 `v3` 的 PR、push 和手工触发中通过 `tests/run.py --shard N/8` 并行采集覆盖率数据,`Coverage Report` 再合并全部分片并只读检查 Application 与 Domain 的 Ubuntu/Python 3.14 canonical 低水位,同时上传 JSON / XML 工件。覆盖率下降会阻塞,提升或等比例快照变化也必须显式刷新 fixture;macOS 本地报告只用于诊断,不直接作为可提交基线。 - 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`,pytest 与覆盖率工具位于默认 `dev` 依赖组。 diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md index 2113e3bc1..9790c8037 100644 --- a/docs/v3t-runtime-governance.md +++ b/docs/v3t-runtime-governance.md @@ -13,8 +13,9 @@ MoviePilot 同一版本提供两套镜像: 两套镜像必须来自相同源码 revision、使用相同产品版本和同一份 `uv.lock`。V3t 不替换默认镜像, 也不在业务代码中维护一套平行实现;解释器、原生 ABI 或插件不兼容时,应切回同版本标准 V3。 -正式发布按同版本制品对验收和晋升 `latest`;任一变体未通过构建、扫描或发布时,本次版本不移动 -两边的 `latest`。这不会回退已经发布的标准 V3,且避免两个 `latest` 指向不同源码版本。 +正式发布为两个变体分别生成同一版本号和 `latest` 标签;两个发布步骤都必须在各自构建、扫描通过后 +执行。版本号标签保留用于回溯和固定部署,`latest` 直接由对应的多架构发布步骤写入,不再额外运行 +标签晋升步骤。两套镜像仍必须使用同一源码 revision,避免同一发布批次出现来源不一致。 V3t 的目标不是让所有请求都更快。它主要改善可并行的 Python CPU 热点,同时验证主程序、原生扩展 和插件生态在 free-threaded 解释器下的正确性。启动、内存、普通 API 和数据库路径不得为获得局部 diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 2d095f580..94c3fff8b 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 12969, - "percent": 81.8, - "statements": 15854 + "covered_lines": 12996, + "percent": 81.86, + "statements": 15875 }, "domain": { "covered_lines": 3686, diff --git a/tests/test_architecture_ci.py b/tests/test_architecture_ci.py index 9e51f661c..7421ec815 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -83,52 +83,86 @@ def test_official_plugin_observation_is_scheduled_and_never_writes_fixture(): ) -def test_coverage_job_runs_full_suite_and_read_only_ratchet() -> None: - """PR 与推送的 Coverage job 必须串行采集全量报告并只读检查低水位。""" +def test_coverage_jobs_parallelize_data_and_keep_one_global_ratchet() -> None: + """Coverage 分片并行采集数据,再由单一报告 job 合并并检查全局低水位。""" workflow = _load_workflow("test.yml") - coverage_job = workflow["jobs"]["coverage"] - steps = coverage_job["steps"] + shard_job = workflow["jobs"]["coverage-shard"] + shard_steps = shard_job["steps"] + report_job = workflow["jobs"]["coverage-report"] + report_steps = report_job["steps"] assert workflow["on"]["pull_request"]["branches"] == ["v3"] assert workflow["on"]["push"]["branches"] == ["v3"] assert "workflow_dispatch" in workflow["on"] - assert coverage_job["runs-on"] == "ubuntu-latest" - assert coverage_job["timeout-minutes"] == 20 - assert "if" not in coverage_job + assert shard_job["runs-on"] == "ubuntu-latest" + assert shard_job["timeout-minutes"] == 15 + shard_matrix = shard_job["strategy"]["matrix"]["include"] + assert [item["shard"] for item in shard_matrix] == [ + "1/8", + "2/8", + "3/8", + "4/8", + "5/8", + "6/8", + "7/8", + "8/8", + ] + assert [item["index"] for item in shard_matrix] == [str(index) for index in range(1, 9)] + assert report_job["needs"] == "coverage-shard" + assert report_job["runs-on"] == "ubuntu-latest" + assert report_job["timeout-minutes"] == 10 - setup_step = next(step for step in steps if step.get("name") == "Set up uv") + setup_step = next(step for step in shard_steps if step.get("name") == "Set up uv") install_step = next( - step for step in steps if step.get("name") == "Install dependencies" + step for step in shard_steps if step.get("name") == "Install dependencies" ) generate_step = next( - step for step in steps if step.get("name") == "Generate coverage reports" + step for step in shard_steps if step.get("name") == "Generate coverage data" ) - assert generate_step["timeout-minutes"] == 15 - upload_step = next( - step for step in steps if step.get("name") == "Upload coverage report" + assert generate_step["timeout-minutes"] == 10 + assert "coverage run --parallel-mode tests/run.py --shard" in generate_step["run"] + upload_data_step = next( + step for step in shard_steps if step.get("name") == "Upload coverage data" ) - ratchet_step = next( - step for step in steps if step.get("name") == "Check coverage ratchet" + assert upload_data_step["with"]["path"] == ".coverage.*" + assert upload_data_step["with"]["name"] == "coverage-data-${{ matrix.index }}" + assert upload_data_step["with"]["include-hidden-files"] is True + + download_step = next( + step for step in report_steps if step.get("name") == "Download coverage data" + ) + assert download_step["uses"] == "actions/download-artifact@v8" + assert download_step["with"]["merge-multiple"] is True + report_step = next( + step + for step in report_steps + if step.get("name") == "Combine coverage data and check ratchet" ) assert setup_step["with"]["python-version"] == "3.14" assert install_step["run"] == "uv sync --locked" - commands = generate_step["run"] + commands = report_step["run"] expected_commands = [ - "python -m coverage erase", - "python -m coverage run tests/run.py --serial", + "python -m coverage combine coverage-data", "python -m coverage report", "python -m coverage json", "python -m coverage xml", + "scripts/architecture/coverage_ratchet.py", ] positions = [commands.index(command) for command in expected_commands] assert positions == sorted(positions) - assert ratchet_step["run"].endswith("scripts/architecture/coverage_ratchet.py") - assert steps.index(generate_step) < steps.index(upload_step) < steps.index(ratchet_step) - all_commands = _step_commands(workflow, "coverage") + all_commands = _step_commands(workflow, "coverage-shard") + _step_commands( + workflow, "coverage-report" + ) assert "--write" not in all_commands assert "|| true" not in all_commands - assert all(step.get("continue-on-error") is not True for step in steps) - assert all("always()" not in str(step.get("if", "")) for step in steps) + assert all( + step.get("continue-on-error") is not True + for step in shard_steps + report_steps + ) + assert all( + "always()" not in str(step.get("if", "")) + for step in shard_steps + report_steps + ) coverage_config = configparser.ConfigParser() coverage_config.read(PROJECT_ROOT / ".coveragerc", encoding="utf-8") @@ -175,7 +209,10 @@ def test_upload_artifact_actions_share_node24_major(): "native-dependency-update.yml": ["actions/upload-artifact@v7"], "pylint.yml": ["actions/upload-artifact@v7"], "site-adapter-collector.yml": ["actions/upload-artifact@v7"], - "test.yml": ["actions/upload-artifact@v7"], + "test.yml": [ + "actions/upload-artifact@v7", + "actions/upload-artifact@v7", + ], } diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index b0f2324e6..fc6262d33 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -440,27 +440,28 @@ def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache( assert "scope=moviepilot-v3t-docker-arm64" in publish["with"]["cache-from"] -def test_release_promotes_latest_only_after_both_versioned_images() -> None: - """只有两个版本制品都发布成功后才可移动 latest 标签。""" +def test_release_publishes_version_and_latest_tags_for_both_image_variants() -> None: + """正式版元数据同时发布版本号与 latest,两个变体直接复用各自发布结果。""" workflow = _load_workflow() steps = workflow["jobs"]["Docker-build"]["steps"] names = [step.get("name") for step in steps] indexed = _steps_by_name(workflow) - assert "value=latest" not in indexed["Docker Meta"]["with"]["tags"] - assert "value=latest" not in indexed["Docker Meta free-threaded"]["with"]["tags"] - assert names.index("Publish multi-architecture image") < names.index("Promote latest image pair") - assert names.index("Publish free-threaded multi-architecture image") < names.index( - "Promote latest image pair" + for name in ("Docker Meta", "Docker Meta free-threaded"): + tags = indexed[name]["with"]["tags"] + assert "type=raw,value=${{ env.app_version }}" in tags + assert "type=raw,value=latest" in tags + + assert "Promote latest image pair" not in names + assert names.index("Docker Meta") < names.index("Publish multi-architecture image") + assert names.index("Docker Meta free-threaded") < names.index( + "Publish free-threaded multi-architecture image" ) - promote = indexed["Promote latest image pair"]["run"] - assert "moviepilot-v3:latest" not in promote - assert 'ghcr_repository="${GITHUB_REPOSITORY,,}"' in promote - assert "ghcr.io/${GITHUB_REPOSITORY}" not in promote - assert "ghcr.io/${ghcr_repository}-v3" in promote - assert "ghcr.io/${ghcr_repository}-v3t" in promote - assert '"${image}:latest"' in promote - assert '"${image}:${app_version}"' in promote + + standard_images = indexed["Docker Meta"]["with"]["images"] + assert "${{ secrets.DOCKER_USERNAME }}/moviepilot" in standard_images + assert "${{ secrets.DOCKER_USERNAME }}/moviepilot-v3" in standard_images + assert "ghcr.io/${{ github.repository }}" in standard_images def test_beta_applies_the_same_variant_scan_and_publish_contract() -> None: diff --git a/tests/test_resource_v3.py b/tests/test_resource_v3.py index beba8e76f..eb795a593 100644 --- a/tests/test_resource_v3.py +++ b/tests/test_resource_v3.py @@ -146,10 +146,10 @@ def test_v3_release_workflows_use_main_wiki_and_isolated_images(): assert "name: MoviePilot Builder v3" in build_workflow assert " - v3" in build_workflow assert " repository: jxxghp/MoviePilot-Wiki\n ref: main" in build_workflow + assert "${{ secrets.DOCKER_USERNAME }}/moviepilot\n" in build_workflow assert "${{ secrets.DOCKER_USERNAME }}/moviepilot-v3" in build_workflow assert "ghcr.io/${{ github.repository }}-v3" in build_workflow assert "${{ secrets.DOCKER_USERNAME }}/moviepilot-v2" not in build_workflow - assert "${{ secrets.DOCKER_USERNAME }}/moviepilot\n" not in build_workflow assert "git tag -l 'v3.*'" in build_workflow assert " repository: jxxghp/MoviePilot-Wiki\n ref: main" in beta_workflow diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py index f59cc9394..e998b4941 100644 --- a/tests/test_test_runner.py +++ b/tests/test_test_runner.py @@ -86,10 +86,10 @@ def test_invalid_shard_values_are_rejected(value: str) -> None: def test_workflow_uses_the_shared_runner_contract() -> None: - """CI 不得另行维护 shell 分片算法,覆盖率必须显式使用串行模式。""" + """CI 不得另行维护 shell 分片算法,Coverage 必须复用同一分片入口。""" workflow = WORKFLOW.read_text(encoding="utf-8") assert 'python tests/run.py --shard "${{ matrix.shard }}"' in workflow - assert "python -m coverage run tests/run.py --serial" in workflow + assert "python -m coverage run --parallel-mode tests/run.py --shard" in workflow assert "mapfile" not in workflow assert "SHARD_INDEX" not in workflow