fix(ci): align release image tags and coverage gates

* ci: defer coverage from pull requests

* ci: parallelize coverage shards

* fix(ci): restore atomic latest image promotion

* docs: align coverage gate description

* fix(ci): align release image tags with upstream

* fix(ci): sanitize coverage artifact names

* fix(ci): refresh coverage ratchet baseline
This commit is contained in:
InfinityPacer
2026-08-31 13:21:08 +08:00
committed by GitHub
parent 6ee4779562
commit 45d069d9ec
10 changed files with 169 additions and 69 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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。

View File

@@ -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` 依赖组。

View File

@@ -13,8 +13,9 @@ MoviePilot 同一版本提供两套镜像:
两套镜像必须来自相同源码 revision、使用相同产品版本和同一份 `uv.lock`。V3t 不替换默认镜像,
也不在业务代码中维护一套平行实现;解释器、原生 ABI 或插件不兼容时,应切回同版本标准 V3。
正式发布按同版本制品对验收和晋升 `latest`;任一变体未通过构建、扫描或发布时,本次版本不移动
两边的 `latest`。这不会回退已经发布的标准 V3,且避免两个 `latest` 指向不同源码版本。
正式发布为两个变体分别生成同一版本号和 `latest` 标签;两个发布步骤都必须在各自构建、扫描通过后
执行。版本号标签保留用于回溯和固定部署,`latest` 直接由对应的多架构发布步骤写入,不再额外运行
标签晋升步骤。两套镜像仍必须使用同一源码 revision,避免同一发布批次出现来源不一致。
V3t 的目标不是让所有请求都更快。它主要改善可并行的 Python CPU 热点,同时验证主程序、原生扩展
和插件生态在 free-threaded 解释器下的正确性。启动、内存、普通 API 和数据库路径不得为获得局部

View File

@@ -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,

View File

@@ -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",
],
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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