From 794bcfdfebe5bd18b1a1a6b8e12d811df1d94534 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 14 Aug 2026 21:29:15 +0800 Subject: [PATCH] fix(ci): isolate plugin market snapshot generator --- .github/workflows/beta.yml | 2 +- .github/workflows/build-v3.yml | 4 +- scripts/generate_plugin_market_default.py | 56 ++++++++++++++++- tests/test_plugin_market_default.py | 74 ++++++++++++++++++++--- 4 files changed, 122 insertions(+), 14 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 67d32edfc..ba6d814bc 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -35,7 +35,7 @@ jobs: run: | python3 -m scripts.generate_plugin_market_default \ --wiki-file .build/moviepilot-wiki/plugin.md \ - --config-file app/core/config.py + --config-file app/runtime/config.py wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD) echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index b11b256f5..d27a91631 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -44,7 +44,7 @@ jobs: run: | python3 -m scripts.generate_plugin_market_default \ --wiki-file .build/moviepilot-wiki/plugin.md \ - --config-file app/core/config.py + --config-file app/runtime/config.py wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD) echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT" @@ -55,7 +55,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add app/core/config.py + git add app/runtime/config.py if ! git diff --cached --quiet; then git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}" fi diff --git a/scripts/generate_plugin_market_default.py b/scripts/generate_plugin_market_default.py index bdd7ab8a3..a79fe594b 100644 --- a/scripts/generate_plugin_market_default.py +++ b/scripts/generate_plugin_market_default.py @@ -4,23 +4,71 @@ import argparse import ast +import re from pathlib import Path from typing import Optional - -from app.adapters.external.market import extract_plugin_market_repos_from_wiki +from urllib.parse import urlparse OFFICIAL_PLUGIN_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins" +PLUGIN_MARKET_WIKI_START = "" +PLUGIN_MARKET_WIKI_END = "" +PLUGIN_MARKET_REPO_PATTERN = re.compile( + r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?", + re.IGNORECASE, +) def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace: + """解析独立构建脚本需要的 Wiki 和配置文件路径。""" parser = argparse.ArgumentParser(description="生成插件市场发版默认值") parser.add_argument("--wiki-file", type=Path, required=True) parser.add_argument("--config-file", type=Path, required=True) return parser.parse_args(args) +def _normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]: + """规范化 GitHub 插件仓库地址,供清单解析阶段去重。""" + repo_url = (repo_url or "").strip().rstrip("/").removesuffix(".git") + if not repo_url: + return None + parsed_url = urlparse(repo_url) + if parsed_url.scheme not in {"http", "https"}: + return None + if (parsed_url.hostname or "").lower() != "github.com": + return None + paths = [item for item in parsed_url.path.split("/") if item] + if len(paths) < 2: + return None + return f"https://github.com/{paths[0]}/{paths[1]}" + + +def _extract_plugin_market_repos_from_wiki(markdown: str) -> list[str]: + """从唯一且有序的 Wiki 标记区域读取插件仓库清单。""" + start_count = markdown.count(PLUGIN_MARKET_WIKI_START) + end_count = markdown.count(PLUGIN_MARKET_WIKI_END) + start_index = markdown.find(PLUGIN_MARKET_WIKI_START) + end_index = markdown.find(PLUGIN_MARKET_WIKI_END) + if start_count != 1 or end_count != 1 or start_index >= end_index: + raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记") + + content = markdown[ + start_index + len(PLUGIN_MARKET_WIKI_START):end_index + ] + repos: list[str] = [] + seen_repos: set[str] = set() + for item in PLUGIN_MARKET_REPO_PATTERN.findall(content): + normalized_repo = _normalize_plugin_market_repo_url(item) + identity = normalized_repo.lower() if normalized_repo else "" + if not normalized_repo or identity in seen_repos: + continue + repos.append(normalized_repo) + seen_repos.add(identity) + return repos + + def _find_plugin_market_assignment(source: str) -> tuple[int, int, str]: + """定位 ConfigModel 中插件市场默认值对应的源码范围。""" tree = ast.parse(source) source_lines = source.splitlines(keepends=True) for node in tree.body: @@ -41,6 +89,7 @@ def _find_plugin_market_assignment(source: str) -> tuple[int, int, str]: def _format_plugin_market_assignment(repos: list[str], indent: str) -> str: + """按配置文件现有缩进格式生成插件市场默认值源码。""" lines = [f"{indent}PLUGIN_MARKET: str = (\n"] for index, repo in enumerate(repos): suffix = "," if index < len(repos) - 1 else "" @@ -50,8 +99,9 @@ def _format_plugin_market_assignment(repos: list[str], indent: str) -> str: def _generate_plugin_market_default(wiki_file: Path, config_file: Path) -> list[str]: + """读取 Wiki 清单并原地更新配置文件中的插件市场默认值。""" markdown = wiki_file.read_text(encoding="utf-8") - repos = extract_plugin_market_repos_from_wiki(markdown, require_markers=True) + repos = _extract_plugin_market_repos_from_wiki(markdown) if not repos: raise ValueError("Wiki 插件仓库清单为空") if OFFICIAL_PLUGIN_MARKET not in repos: diff --git a/tests/test_plugin_market_default.py b/tests/test_plugin_market_default.py index 79090b76f..4d35f9f96 100644 --- a/tests/test_plugin_market_default.py +++ b/tests/test_plugin_market_default.py @@ -1,15 +1,20 @@ import ast +import subprocess +import sys from pathlib import Path import pytest -from app.adapters.external.market import extract_plugin_market_repos_from_wiki from scripts.generate_plugin_market_default import ( OFFICIAL_PLUGIN_MARKET, + _extract_plugin_market_repos_from_wiki, _generate_plugin_market_default, ) +ROOT_DIR = Path(__file__).resolve().parents[1] + + def _read_plugin_market_default(config_file: Path) -> str: tree = ast.parse(config_file.read_text(encoding="utf-8")) for node in tree.body: @@ -37,9 +42,7 @@ def test_extract_plugin_market_repos_uses_marked_section_and_deduplicates() -> N - https://github.com/outside/ignored-again """ - assert extract_plugin_market_repos_from_wiki( - markdown, require_markers=True - ) == [ + assert _extract_plugin_market_repos_from_wiki(markdown) == [ OFFICIAL_PLUGIN_MARKET, "https://github.com/demo/Market", ] @@ -50,9 +53,7 @@ def test_extract_plugin_market_repos_requires_unique_markers_for_build() -> None 构建模式拒绝缺失或重复边界标记的 Wiki 文档。 """ with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"): - extract_plugin_market_repos_from_wiki( - f"- {OFFICIAL_PLUGIN_MARKET}", require_markers=True - ) + _extract_plugin_market_repos_from_wiki(f"- {OFFICIAL_PLUGIN_MARKET}") markdown = f""" @@ -61,7 +62,7 @@ def test_extract_plugin_market_repos_requires_unique_markers_for_build() -> None """ with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"): - extract_plugin_market_repos_from_wiki(markdown, require_markers=True) + _extract_plugin_market_repos_from_wiki(markdown) def test_generate_plugin_market_default_updates_assignment_idempotently( @@ -127,3 +128,60 @@ def test_generate_plugin_market_default_requires_official_repo( with pytest.raises(ValueError, match="缺少 MoviePilot 官方插件仓库"): _generate_plugin_market_default(wiki_file, config_file) + + +def test_generate_plugin_market_default_runs_without_project_dependencies( + tmp_path: Path, +) -> None: + """发版脚本在禁用 site-packages 时仍应能独立生成默认配置。""" + wiki_file = tmp_path / "plugin.md" + wiki_file.write_text( + f""" + +- {OFFICIAL_PLUGIN_MARKET} + +""", + encoding="utf-8", + ) + config_file = tmp_path / "config.py" + config_file.write_text( + 'class ConfigModel:\n PLUGIN_MARKET: str = "old"\n', + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + "-S", + "-m", + "scripts.generate_plugin_market_default", + "--wiki-file", + str(wiki_file), + "--config-file", + str(config_file), + ], + cwd=ROOT_DIR, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert _read_plugin_market_default(config_file) == OFFICIAL_PLUGIN_MARKET + + +def test_release_workflows_use_canonical_config_path() -> None: + """正式版和 Beta 发版流程必须修改并暂存 canonical 配置文件。""" + build_workflow = ( + ROOT_DIR / ".github" / "workflows" / "build-v3.yml" + ).read_text(encoding="utf-8") + beta_workflow = ( + ROOT_DIR / ".github" / "workflows" / "beta.yml" + ).read_text(encoding="utf-8") + + assert "--config-file app/runtime/config.py" in build_workflow + assert "git add app/runtime/config.py" in build_workflow + assert "--config-file app/runtime/config.py" in beta_workflow + assert "app/core/config.py" not in build_workflow + assert "app/core/config.py" not in beta_workflow