Skip to content
Open
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
59 changes: 59 additions & 0 deletions .github/workflows/plugin-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,62 @@ jobs:
MOVIEPILOT_BACKEND_PATH: ${{ github.workspace }}/MoviePilot
working-directory: MoviePilot-Plugins-Official
run: ../MoviePilot/.venv/bin/python tests/run.py

plugin-dependency-install-gate:
name: V3 dependency install (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
os: ubuntu-latest
platform: linux-x64
- name: Linux arm64
os: ubuntu-24.04-arm
platform: linux-arm64
- name: Windows x64
os: windows-latest
platform: windows-x64
- name: macOS Intel
os: macos-15-intel
platform: macos-x64
- name: macOS arm64
os: macos-15
platform: macos-arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Checkout plugin repository
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Determine whether dependency gate is required
id: dependency-scope
shell: bash
run: |
set -euo pipefail
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

触发漏检

依赖门禁的变更检测路径没有包含 package.v3.json。当 PR 只修改发布范围(例如将一个已有 pyproject.toml 的插件加入 V3 发布包)时,git diff --quiet 会判定无需运行,所有矩阵任务都跳过真实安装,从而绕过新增发布依赖的安装验证,违反发布范围变化必须触发依赖门禁的契约。变更检测应覆盖该发布范围文件。

'plugins.v3/*/pyproject.toml' \
'scripts/check_v3_dependency_install.py' \
'tests/ci/test_v3_dependency_install_gate.py' \
'.github/workflows/plugin-gate.yml'; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi

- name: Set up uv
if: steps.dependency-scope.outputs.run == 'true'
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: plugins.v3/*/pyproject.toml

- name: Install V3 plugin dependencies
if: steps.dependency-scope.outputs.run == 'true'
env:
PYTHONUTF8: '1'
run: uv run --no-project --python 3.14 python scripts/check_v3_dependency_install.py --python 3.14 --platform "${{ matrix.platform }}"
3 changes: 3 additions & 0 deletions plugins.v3/animeupscale/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ explicit = true

[tool.uv.sources]
torch = { index = "pytorch-cu126" }

[tool.moviepilot.dependency-gate]
platforms = ["linux-x64"]
167 changes: 167 additions & 0 deletions scripts/check_v3_dependency_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""在隔离环境中按声明平台真实安装 V3 插件依赖清单。"""

from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import tempfile
from pathlib import Path

import tomllib

REPO_ROOT = Path(__file__).resolve().parents[1]
V3_ROOT = REPO_ROOT / "plugins.v3"
SUPPORTED_PLATFORMS = frozenset(
{
"linux-x64",
"linux-arm64",
"windows-x64",
"macos-x64",
"macos-arm64",
}
)


def discover_manifests(root: Path = V3_ROOT) -> list[Path]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

越界发现

discover_manifests() 直接扫描所有 plugins.v3/*/pyproject.toml,完全不读取 package.v3.json。当仓库中存在未列入 V3 发布范围的开发中或停发插件时,矩阵任务仍会选择并安装其依赖;该插件的平台不兼容或依赖安装失败会阻断本不应覆盖的发布门禁,违反“仅验证发布范围内 manifest”的契约。发现逻辑应以 package.v3.json 的发布条目为筛选边界。

"""返回仓库中全部 V3 modern manifest。"""
return sorted(root.glob("*/pyproject.toml"))


def manifest_platforms(manifest: Path) -> frozenset[str]:
"""返回清单声明的安装门禁平台,未声明时覆盖标准五平台。"""
with manifest.open("rb") as file_obj:
document = tomllib.load(file_obj)
configured = (
document.get("tool", {})
.get("moviepilot", {})
.get("dependency-gate", {})
.get("platforms")
)
if configured is None:
return SUPPORTED_PLATFORMS
if (
not isinstance(configured, list)
or not configured
or not all(isinstance(item, str) for item in configured)
):
raise ValueError(f"{manifest} 的 dependency-gate.platforms 必须是非空字符串列表")
platforms = frozenset(configured)
unknown = platforms - SUPPORTED_PLATFORMS
if unknown:
raise ValueError(f"{manifest} 声明了未知安装平台:{sorted(unknown)}")
return platforms


def venv_python(environment: Path, *, windows: bool | None = None) -> Path:
"""返回目标虚拟环境的解释器路径。"""
is_windows = os.name == "nt" if windows is None else windows
if is_windows:
return environment / "Scripts" / "python.exe"
return environment / "bin" / "python"


def installation_commands(
*,
uv_bin: str,
python_spec: str,
environment: Path,
manifest: Path,
windows: bool | None = None,
) -> tuple[list[str], list[str], list[str]]:
"""构造与宿主插件安装语义一致的隔离安装和健康检查命令。"""
python_bin = venv_python(environment, windows=windows)
return (
[uv_bin, "venv", "--python", python_spec, str(environment)],
[
uv_bin,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

CUDA 源未应用

这里使用 uv pip install -r pyproject.toml 的 pip 兼容模式安装依赖,但该模式不会应用清单中的 [tool.uv.sources] 配置,包括 AnimeUpscale 声明的 pytorch-cu126 显式索引。触发 Linux x64 的 AnimeUpscale 门禁时,uv 可能从默认 PyPI 安装另一个满足版本约束的 CPU 或非 cu126 版 torch,随后 uv pip check 仍会通过,因而违反了必须验证 CUDA 索引的安装门禁契约。安装步骤需要改为使用能读取项目 uv source 配置的方式,或显式把清单声明的索引配置传递给安装命令。

"pip",
"install",
"--python",
str(python_bin),
"-r",
str(manifest),
],
[uv_bin, "pip", "check", "--python", str(python_bin)],
)


def verify_manifest(*, uv_bin: str, python_spec: str, manifest: Path) -> None:
"""在一次性虚拟环境中安装并检查指定清单。"""
prefix = f"moviepilot-{manifest.parent.name}-"
with tempfile.TemporaryDirectory(prefix=prefix) as temp_dir:
environment = Path(temp_dir) / ".venv"
for command in installation_commands(
uv_bin=uv_bin,
python_spec=python_spec,
environment=environment,
manifest=manifest,
):
subprocess.run(command, cwd=REPO_ROOT, check=True)


def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="Install V3 plugin manifests supported by a CI platform.",
)
parser.add_argument("--python", default="3.14", help="目标 Python 解释器")
parser.add_argument("--uv", default="uv", help="uv 可执行文件")
parser.add_argument(
"--platform",
required=True,
choices=sorted(SUPPORTED_PLATFORMS),
help="当前依赖安装门禁平台",
)
return parser.parse_args()


def main() -> int:
"""执行当前平台全部适用 V3 插件依赖的真实安装门禁。"""
args = parse_args()
uv_bin = shutil.which(args.uv)
if not uv_bin:
print(f"未找到 uv 可执行文件:{args.uv}")
return 1

manifests = discover_manifests()
if not manifests:
print("未发现 V3 插件 pyproject.toml,拒绝空门禁")
return 1

try:
selected = [
manifest
for manifest in manifests
if args.platform in manifest_platforms(manifest)
]
except ValueError as err:
print(err)
return 1
if not selected:
print(f"{args.platform} 没有适用的 V3 依赖清单,拒绝空平台门禁")
return 1

for manifest in manifests:
relative_manifest = manifest.relative_to(REPO_ROOT)
if manifest not in selected:
print(f"跳过不支持 {args.platform} 的 {relative_manifest}")
continue
print(f"真实安装 {relative_manifest}", flush=True)
try:
verify_manifest(
uv_bin=uv_bin,
python_spec=args.python,
manifest=manifest,
)
except subprocess.CalledProcessError as err:
print(f"{relative_manifest} 安装门禁失败,退出码:{err.returncode}")
return err.returncode or 1

print(f"{args.platform} V3 插件依赖真实安装门禁通过:{len(selected)} 份清单")
return 0


if __name__ == "__main__":
raise SystemExit(main())
21 changes: 21 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ MoviePilot 环境;默认 V3 回归只承诺覆盖仍声明兼容 V3 的 V2 测
新增 V3 插件必须同时增加 `tests/v3/<plugin_id>/test_*.py`;V1/V2 历史实现仍可维护和
发版,不受这个新增插件测试门禁约束。

## 依赖清单

V3 插件有额外依赖时使用 `pyproject.toml` 的 `[project].dependencies`,不提交插件级锁文件。
PR 修改 V3 依赖清单或依赖门禁本身时,CI 会在 Python 3.14 的 Linux x64/arm64、Windows x64、
macOS Intel/ARM runner 中创建隔离环境,按宿主的 `uv pip install -r pyproject.toml` 语义真实安装
并执行 `uv pip check`。

普通清单默认覆盖五个平台。插件仅支持其中一部分平台时,在清单中声明安装门禁范围:

```toml
[tool.moviepilot.dependency-gate]
platforms = ["linux-x64"]
```

本地可按目标平台执行同一入口:

```bash
uv run --no-project --python 3.14 python scripts/check_v3_dependency_install.py \
--python 3.14 --platform macos-arm64
```

## 新增用例

1. 放到对应代际的插件独立目录:`tests/<v1|v2|v3>/<plugin_id>/`,例如
Expand Down
122 changes: 122 additions & 0 deletions tests/ci/test_v3_dependency_install_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""V3 插件依赖真实安装门禁测试。"""

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SCRIPT = REPO_ROOT / "scripts/check_v3_dependency_install.py"
PR_WORKFLOW = REPO_ROOT / ".github/workflows/plugin-gate.yml"


def _load_install_module():
"""按文件路径导入安装门禁脚本。"""
spec = importlib.util.spec_from_file_location(
"check_v3_dependency_install",
INSTALL_SCRIPT,
)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


def test_manifest_discovery_covers_every_v3_pyproject() -> None:
"""门禁必须自动覆盖全部 V3 modern manifest。"""
module = _load_install_module()
expected = sorted((REPO_ROOT / "plugins.v3").glob("*/pyproject.toml"))

assert expected
assert module.discover_manifests() == expected


def test_manifest_platforms_default_to_product_matrix() -> None:
"""未声明窄平台的普通插件必须覆盖 V3 标准五平台。"""
module = _load_install_module()
manifest = REPO_ROOT / "plugins.v3/agentresourceofficer/pyproject.toml"

assert module.manifest_platforms(manifest) == module.SUPPORTED_PLATFORMS


def test_animeupscale_dependency_gate_matches_linux_cuda_contract() -> None:
"""AnimeUpscale 的大体积 CUDA 依赖只在真实支持的 Linux x64 安装。"""
module = _load_install_module()
manifest = REPO_ROOT / "plugins.v3/animeupscale/pyproject.toml"

assert module.manifest_platforms(manifest) == frozenset({"linux-x64"})


def test_installation_uses_fresh_environment_and_host_manifest_semantics(
tmp_path: Path,
) -> None:
"""安装命令必须面向隔离解释器并通过 -r 消费原始 pyproject。"""
module = _load_install_module()
environment = tmp_path / ".venv"
manifest = REPO_ROOT / "plugins.v3/agentresourceofficer/pyproject.toml"

create, install, healthcheck = module.installation_commands(
uv_bin="uv",
python_spec="3.14",
environment=environment,
manifest=manifest,
windows=False,
)

python_bin = environment / "bin/python"
assert create == ["uv", "venv", "--python", "3.14", str(environment)]
assert install == [
"uv",
"pip",
"install",
"--python",
str(python_bin),
"-r",
str(manifest),
]
assert healthcheck == ["uv", "pip", "check", "--python", str(python_bin)]


def test_windows_environment_uses_scripts_python(tmp_path: Path) -> None:
"""Windows runner 必须把依赖安装到目标 venv,而不是 runner 全局环境。"""
module = _load_install_module()

assert module.venv_python(tmp_path / ".venv", windows=True) == (
tmp_path / ".venv/Scripts/python.exe"
)


def test_workflow_runs_scoped_five_platform_install_matrix() -> None:
"""PR 门禁应在相关变更时按平台执行真实安装脚本。"""
workflow = PR_WORKFLOW.read_text(encoding="utf-8")
job_start = workflow.index(" plugin-dependency-install-gate:")
job_end = len(workflow)
install_job = workflow[job_start:job_end]

expected_targets = {
"ubuntu-latest": "linux-x64",
"ubuntu-24.04-arm": "linux-arm64",
"windows-latest": "windows-x64",
"macos-15-intel": "macos-x64",
"macos-15": "macos-arm64",
}
for runner, platform in expected_targets.items():
assert f"os: {runner}" in install_job
assert f"platform: {platform}" in install_job
assert "runs-on: ${{ matrix.os }}" in install_job
assert "fetch-depth: 0" in install_job
assert (
'git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD'
in install_job
)
for pathspec in (
"plugins.v3/*/pyproject.toml",
"scripts/check_v3_dependency_install.py",
"tests/ci/test_v3_dependency_install_gate.py",
".github/workflows/plugin-gate.yml",
):
assert pathspec in install_job
assert "steps.dependency-scope.outputs.run == 'true'" in install_job
assert "--platform \"${{ matrix.platform }}\"" in install_job