feat: 独立发布站点采集器并按输入变化递增版本

This commit is contained in:
jxxghp
2026-10-11 12:18:45 +08:00
parent 5dd55eb49d
commit 54b6368340
13 changed files with 407 additions and 24 deletions

View File

@@ -8,7 +8,7 @@ body:
value: |
请说明你希望添加的功能。
站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
站点适配请求请从 [采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 下载最新采集器,再按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。
- type: input
id: version
attributes:

View File

@@ -1,22 +1,73 @@
name: Site Adapter Collector
on:
push:
branches:
- v3
paths:
- scripts/site_adapter_collector.py
- scripts/site_adapter_collector.spec
- scripts/site_adapter_collector_requirements.txt
- scripts/start-site-adapter-collector.command
- scripts/collect-site-adapter.sh
- scripts/collector/**
- .github/workflows/site-adapter-collector.yml
workflow_dispatch:
inputs:
release_tag:
description: Existing release tag to receive collector assets; leave empty for artifacts only
required: false
type: string
release:
types:
- published
publish:
description: Publish an independent release only when collector inputs changed
required: true
default: true
type: boolean
# 串行选择版本和发布,避免多个采集器提交争抢同一个版本。
concurrency:
group: site-adapter-collector-release
cancel-in-progress: false
permissions:
contents: read
jobs:
prepare:
name: Check collector changes and select independent version
if: github.ref == 'refs/heads/v3'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.version.outputs.changed }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
source_sha: ${{ steps.version.outputs.source_sha }}
steps:
- name: Checkout current collector source
uses: actions/checkout@v7
with:
# 排队期间可能已有新提交;统一构建当前 v3,避免旧任务回退采集器。
ref: v3
fetch-depth: 0
fetch-tags: false
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Select collector release version
id: version
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --paginate \
--jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \
> collector-release-tags.txt
python scripts/collector/release.py plan \
--tags-file collector-release-tags.txt --output "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.platform_name }} collector
needs: prepare
if: needs.prepare.outputs.changed == 'true' || (github.event_name == 'workflow_dispatch' && !inputs.publish)
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
@@ -45,6 +96,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.source_sha }}
- name: Set up Python
uses: actions/setup-python@v7
@@ -61,14 +114,20 @@ jobs:
run: uv pip install --system --requirement scripts/site_adapter_collector_requirements.txt
- name: Build single-file collector
shell: bash
env:
COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }}
run: |
python scripts/collector/release.py stamp --version "$COLLECTOR_VERSION"
pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec
- name: Smoke-test collector
env:
SOURCE_NAME: ${{ matrix.source_name }}
COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }}
run: |
python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)"
python -c "import os, subprocess; from pathlib import Path; result = subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--version'], check=True, capture_output=True, text=True); assert result.stdout.strip() == os.environ['COLLECTOR_VERSION'], result.stdout"
- name: Package macOS double-click archive
if: matrix.platform_id == 'macos'
@@ -98,7 +157,7 @@ jobs:
env:
ASSET_NAME: ${{ matrix.asset_name }}
run: |
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')"
python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8', newline='\n')"
- name: Upload collector artifact
uses: actions/upload-artifact@v7
@@ -111,15 +170,23 @@ jobs:
retention-days: 3
publish:
name: Upload collectors to release
if: github.event_name == 'release' || inputs.release_tag != ''
name: Publish independent collector release
if: needs.prepare.outputs.changed == 'true' && (github.event_name == 'push' || inputs.publish)
needs:
- prepare
- build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout release source
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.source_sha }}
fetch-depth: 0
fetch-tags: false
- name: Download collector artifacts
uses: actions/download-artifact@v8
with:
@@ -127,10 +194,60 @@ jobs:
path: release-assets
merge-multiple: true
- name: Upload assets to published release
- name: Generate collector release notes
env:
COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }}
PREVIOUS_TAG: ${{ needs.prepare.outputs.previous_tag }}
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
run: |
if [[ -n "$PREVIOUS_TAG" ]]; then
git fetch --no-tags origin "refs/tags/$PREVIOUS_TAG:refs/tags/$PREVIOUS_TAG"
fi
python - <<'PY'
import os
import subprocess
from pathlib import Path
from scripts.collector.release import RELEASE_PATHS
version = os.environ['COLLECTOR_VERSION']
previous = os.environ['PREVIOUS_TAG']
revision = f"{previous}..HEAD" if previous else "HEAD"
records = subprocess.check_output(
['git', 'log', revision, '--no-merges', '--format=- %s (%h) — %an', '--', *RELEASE_PATHS],
text=True,
).strip()
notes = (
f"MoviePilot 站点适配采集器 {version},独立于后端版本发布。\n\n"
"下载与系统对应的程序及同名 `.sha256` 校验文件。\n\n"
"[使用说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-collector-release.md)\n\n"
f"源码提交:`{os.environ['SOURCE_SHA']}`\n\n## 变更记录\n\n{records}\n"
)
Path('collector-release-notes.md').write_text(notes, encoding='utf-8')
PY
- name: Create and publish independent collector release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }}
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
run: |
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json isDraft --jq '.isDraft' > collector-is-draft.txt; then
[[ "$(cat collector-is-draft.txt)" == "true" ]]
git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
[[ "$(git rev-parse "$RELEASE_TAG^{commit}")" == "$SOURCE_SHA" ]]
gh release upload "$RELEASE_TAG" release-assets/* \
--clobber --repo "$GITHUB_REPOSITORY"
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--title "Site Adapter Collector v$COLLECTOR_VERSION" \
--notes-file collector-release-notes.md
else
gh release create "$RELEASE_TAG" release-assets/* \
--repo "$GITHUB_REPOSITORY" --target "$SOURCE_SHA" --draft \
--title "Site Adapter Collector v$COLLECTOR_VERSION" \
--notes-file collector-release-notes.md --latest=false
fi
# 全部附件上传完成才转正式发布,上传失败不会消耗正式版本。
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--draft=false --latest=false

View File

@@ -62,6 +62,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
- 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md)
- 测试说明:[docs/testing.md](docs/testing.md)
- 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md)
- 站点采集器下载:[独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),仅在采集器变化时递增版本并发布。
- REST API 文档:https://api.movie-pilot.org
- 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev

View File

@@ -234,6 +234,8 @@ Docker 镜像直接基于官方基础镜像构建,只安装应用运行所需
报告,不自动更新基线。普通主仓改动不要求额外检出插件仓或运行观察任务。宿主架构门禁和 changed-file
Pylint 由 `v3` PR/push 的 GitHub Actions 执行,`app/` 全量 Pylint 是建议性报告。
站点采集器使用独立的 `site-adapter-collector-v1.0.x` Release,仅在采集器发布输入变化时递增版本并构建,不跟随后端 Release。下载入口为 [采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),自动版本和手动测试构建规则见 [采集器发布说明](site-adapter-collector-release.md#维护者发布流程)。
Ruff/Mypy 基线只允许收紧,不接受新增诊断或类型错误增长;受治零错误文件由 `mypy.ini` 的
`files=` 维护。Mypy 完整 ratchet 固定按 Linux/Python 3.14 分析。Coverage job 在 `v3` PR/push
运行,按 `tests/run.py` 的分片合同执行并合并报告,Application 与 Domain 固定不低于 80%。

View File

@@ -6,7 +6,7 @@
普通用户只需准备两样东西:目标站点账号,以及已安装的 Chrome、Edge 或 Chromium。无需安装 Python、Git、MoviePilot、Docker,也不需要查看 HTML、复制 Cookie 或填写 User-Agent。
1. 从 MoviePilot 官方 Release 下载与 Windows、macOS 或 Linux 对应的 `moviepilot-site-collector-*` 单文件采集器。
1. 从官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 选择最新采集器版本,下载与 Windows、macOS 或 Linux 对应的程序;各系统文件名见下方下载说明。
2. 运行采集器,只输入站点首页地址,例如 `https://tracker.example.com`。
3. 程序会打开一个临时浏览器窗口。在这个窗口里正常登录站点,搜索一个能返回至少 3 条结果的常见关键词,并保持搜索结果页打开。
4. 回到采集器按回车。程序会自动识别搜索地址、关键词、Cookie 和 User-Agent,完成本地裁剪与脱敏后生成 ZIP。

View File

@@ -1,10 +1,10 @@
# 站点适配采集器下载说明
普通用户优先使用 MoviePilot 正式 Release 提供的单文件采集器。单文件已经包含 Python 和采集器依赖,不需要安装 Python、pip、Git、MoviePilot 后端,也不需要下载源码。电脑只需已安装 Chrome、Edge 或 Chromium 浏览器。
普通用户优先使用 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 提供的单文件采集器,选择列表中最新版本。采集器使用 `site-adapter-collector-v1.0.x` 独立标签,与 MoviePilot 后端版本分开发布。单文件已经包含 Python 和采集器依赖,不需要安装 Python、pip、Git、MoviePilot 后端,也不需要下载源码。电脑只需已安装 Chrome、Edge 或 Chromium 浏览器。
## 选择下载文件
请只从 MoviePilot 官方 GitHub Release 下载与系统匹配的文件:
请只从官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 下载与系统匹配的文件;MoviePilot 主程序 Release 不再提供采集器附件:
| 系统 | 下载文件 | 用户侧运行环境 |
|---|---|---|
@@ -12,7 +12,7 @@
| macOS | `MoviePilot-Site-Collector-macOS.zip` | Chrome、Edge 或 Chromium |
| Linux | `moviepilot-site-collector-linux` | Chrome、Edge 或 Chromium |
每个程序旁边还有同名的 `.sha256` 文件,可用于核对下载文件是否完整。GitHub Actions 的手动构建产物主要用于维护者测试;普通用户应使用正式 Release 资产。
每个程序旁边还有同名的 `.sha256` 文件,可用于核对下载文件是否完整。程序启动时显示版本,也可执行 `--version` 查看。GitHub Actions 的手动构建产物主要用于维护者测试;普通用户应使用采集器独立 Release 资产。
## 运行采集器
@@ -27,10 +27,16 @@ chmod +x moviepilot-site-collector-linux
## 系统安全提示
当前自动构建产物尚未接入 Windows 或 Apple 代码签名。Windows SmartScreen 或 macOS Gatekeeper 可能因此显示安全提示。仅在文件来自 MoviePilot 官方 GitHub Release,且校验摘要一致时运行;不要从聊天、网盘或第三方站点接收采集器。
当前自动构建产物尚未接入 Windows 或 Apple 代码签名。Windows SmartScreen 或 macOS Gatekeeper 可能因此显示安全提示。仅在文件来自官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),且校验摘要一致时运行;不要从聊天、网盘或第三方站点接收采集器。
如果系统阻止运行,可改用随 MoviePilot 源码提供的本地采集脚本;该方式需要 Python 3.14+ 及完整后端依赖,不适合作为普通用户的首选路径。
## 维护者发布流程
`.github/workflows/site-adapter-collector.yml` 支持手动触发,也会在 Release 发布后自动构建 Windows、macOS 和 Linux 单文件程序。每个平台先执行 `--help` 启动检查,再上传程序及 SHA-256 摘要为 Workflow Artifact。Release 事件会在三个平台全部成功后,把文件附加到触发本次任务的 Release;手动触发时填写已有的 `release_tag` 也会上传到该 Release,留空则只生成 3 天的测试 Artifact。
`.github/workflows/site-adapter-collector.yml` 在 `v3` 分支的采集器源码、依赖、启动脚本或打包工作流变化时触发,支持手动运行。后端 Release 发布及普通后端代码、文档、测试改动不会触发采集器构建。
构建前会与上一个已正式发布的采集器标签比较输入文件。没有变化就跳过构建和发版;首次发布沿用源码中的 `COLLECTOR_VERSION`(当前为 `1.0.1`),后续有变化时自动递增补丁版本。工作流只在 Runner 构建副本中写入版本,使程序、`--version` 和脱敏包中的 `collector_version` 一致,不向 `v3` 分支回写版本提交。采集包的 `format_version` 协议版本保持独立。
每个平台先执行 `--help` 和 `--version` 启动检查,再上传程序及 SHA-256 摘要。三个平台全部成功后,工作流创建独立版本 Release,自动生成仅包含采集器相关提交的变更记录,并设置 `latest=false`,使主程序的最新 Release 入口继续指向后端版本。
手动触发时 `publish` 默认为 `true`,仍遵守“输入变化才发版”。设为 `false` 时可重复构建测试,只生成保留 3 天的 Artifact,不创建 Release、不消耗版本号。历史主程序 Release 中的采集器附件会迁移移除,下载请始终使用上面的独立 Release 入口。

View File

@@ -399,7 +399,7 @@ MoviePilot/app/application/site
- 后端用户数据解析器可以自动探测 Gazelle、NexusPHP 等站点变种。
- 解析模板支持更完整的标题、文件名、标签和可选字段回退。
- 构建产物覆盖 Linux、Windows、macOS、x86_64、Arm64,以及 V3t 所需的 Python 3.14t ABI。
- 站点采集器可以生成脱敏样本,维护者可通过自动化流程匹配模板并创建待审核的适配 PR。
- [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 提供生成脱敏样本的程序,维护者可通过自动化流程匹配模板并创建待审核的适配 PR。
### 7.4 用户遇到站点问题时应如何判断

View File

@@ -86,7 +86,7 @@ main() {
fi
if ! project_runtime_ready; then
echo "本地 Python 环境缺少采集器依赖,请优先下载官方 Release 的独立采集器。" >&2
echo "本地 Python 环境缺少采集器依赖,请下载采集器独立 Release:https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true" >&2
exit 1
fi
COLLECTOR_PATH="$PROJECT_ROOT/scripts/site_adapter_collector.py"

View File

@@ -0,0 +1 @@
"""站点采集器的独立发布工具。"""

View File

@@ -0,0 +1,116 @@
"""比较采集器发布输入,按独立 Release 递增版本并写入构建副本。"""
import argparse
import re
import subprocess
from pathlib import Path
from typing import Optional
TAG_PREFIX = "site-adapter-collector-v"
RELEASE_PATHS = (
"scripts/site_adapter_collector.py",
"scripts/site_adapter_collector.spec",
"scripts/site_adapter_collector_requirements.txt",
"scripts/start-site-adapter-collector.command",
"scripts/collect-site-adapter.sh",
"scripts/collector",
".github/workflows/site-adapter-collector.yml",
)
VERSION_PATTERN = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
SOURCE_VERSION_PATTERN = re.compile(r'^COLLECTOR_VERSION = "([\d.]+)"$', re.MULTILINE)
def _version_parts(version: str) -> tuple[int, int, int]:
"""校验独立版本,避免外部标签进入 Git 参数或构建源码。"""
match = VERSION_PATTERN.fullmatch(version)
if not match:
raise ValueError(f"无效采集器版本:{version}")
return tuple(int(part) for part in match.groups())
def latest_release_tag(tags: list[str]) -> Optional[str]:
"""从已正式发布的标签中选取采集器最高版本,忽略后端版本。"""
collector_tags = [
tag for tag in tags
if tag.startswith(TAG_PREFIX) and VERSION_PATTERN.fullmatch(tag[len(TAG_PREFIX):])
]
return max(collector_tags, key=lambda tag: _version_parts(tag[len(TAG_PREFIX):]), default=None)
def release_plan(repository: Path, previous_tag: Optional[str]) -> dict[str, str]:
"""仅比较采集器输入;未变化时跳过发布,首次沿用源码版本。"""
source = repository / "scripts/site_adapter_collector.py"
source_match = SOURCE_VERSION_PATTERN.search(source.read_text(encoding="utf-8"))
if not source_match:
raise ValueError("采集器源码缺少唯一版本声明")
source_version = source_match.group(1)
source_parts = _version_parts(source_version)
source_sha = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=repository, text=True,
).strip()
changed = True
version = source_version
if previous_tag:
previous_parts = _version_parts(previous_tag.removeprefix(TAG_PREFIX))
result = subprocess.run(
["git", "diff", "--quiet", previous_tag, "HEAD", "--", *RELEASE_PATHS],
cwd=repository, check=False,
)
if result.returncode not in (0, 1):
raise RuntimeError(f"无法比较采集器 Release:{previous_tag}")
changed = result.returncode == 1
if changed:
next_parts = max(source_parts, (*previous_parts[:2], previous_parts[2] + 1))
version = ".".join(str(part) for part in next_parts)
else:
version = previous_tag[len(TAG_PREFIX):]
return {
"changed": str(changed).lower(),
"version": version,
"tag": f"{TAG_PREFIX}{version}",
"previous_tag": previous_tag or "",
"source_sha": source_sha,
}
def stamp_version(source: Path, version: str) -> None:
"""只修改 Runner 构建副本,使程序及脱敏包版本与独立 Release 一致。"""
_version_parts(version)
content, count = SOURCE_VERSION_PATTERN.subn(
f'COLLECTOR_VERSION = "{version}"', source.read_text(encoding="utf-8"),
)
if count != 1:
raise ValueError("采集器源码缺少唯一版本声明")
source.write_text(content, encoding="utf-8")
def main() -> None:
"""供工作流选择独立发布版本或给构建副本写入版本。"""
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
plan_parser = commands.add_parser("plan")
plan_parser.add_argument("--tags-file", type=Path, required=True)
plan_parser.add_argument("--output", type=Path, required=True)
stamp_parser = commands.add_parser("stamp")
stamp_parser.add_argument("--version", required=True)
args = parser.parse_args()
repository = Path(__file__).resolve().parents[2]
if args.command == "stamp":
stamp_version(repository / "scripts/site_adapter_collector.py", args.version)
return
tags = args.tags_file.read_text(encoding="utf-8").splitlines()
previous_tag = latest_release_tag(tags)
if previous_tag:
subprocess.run(
["git", "fetch", "--no-tags", "origin", f"refs/tags/{previous_tag}:refs/tags/{previous_tag}"],
cwd=repository, check=True,
)
plan = release_plan(repository, previous_tag)
with args.output.open("a", encoding="utf-8") as output:
for name, value in plan.items():
output.write(f"{name}={value}\n")
print(f"采集器版本:{plan['version']};输入变化:{plan['changed']}")
if __name__ == "__main__":
main()

View File

@@ -1394,6 +1394,7 @@ def collect_site_capture_with_browser(start_url: str, output_dir: Path) -> Path:
def _parse_args() -> argparse.Namespace:
"""解析普通浏览器模式和高级手动 Cookie 模式的命令行参数。"""
parser = argparse.ArgumentParser(description="采集并脱敏 MoviePilot 站点适配所需的搜索页结构")
parser.add_argument("--version", action="version", version=COLLECTOR_VERSION)
parser.add_argument(
"--output-dir",
type=Path,
@@ -1424,7 +1425,7 @@ def main() -> int:
"""交互式读取采集参数并生成站点适配 ZIP。"""
_configure_standard_streams()
args = _parse_args()
print("MoviePilot 站点适配采集器")
print(f"MoviePilot 站点适配采集器 {COLLECTOR_VERSION}")
print("原始页面不会落盘;临时浏览器关闭后会清理登录状态,输出前会裁剪并脱敏。")
try:
if args.manual_cookie:

View File

@@ -0,0 +1,137 @@
"""验证采集器独立版本及发布边界,所有 Git 操作仅使用临时仓库。"""
import subprocess
from pathlib import Path
import pytest
from ruamel.yaml import YAML
from scripts.collector import release
PROJECT_ROOT = Path(__file__).parents[1]
def _git(repository: Path, *arguments: str) -> str:
"""在临时仓库运行 Git,隔离真实仓库和用户提交身份。"""
return subprocess.check_output(
["git", "-c", "user.name=Collector Test", "-c", "user.email=collector@example.invalid",
*arguments], cwd=repository, text=True,
).strip()
@pytest.fixture
def collector_repository(tmp_path: Path) -> Path:
"""创建已发布 1.0.1 的临时采集器仓库。"""
_git(tmp_path, "init", "-b", "v3")
for relative in release.RELEASE_PATHS:
path = tmp_path / relative
if relative == "scripts/collector":
path = path / "release.py"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("initial\n", encoding="utf-8")
(tmp_path / "scripts/site_adapter_collector.py").write_text(
'COLLECTOR_VERSION = "1.0.1"\n', encoding="utf-8",
)
_git(tmp_path, "add", ".")
_git(tmp_path, "commit", "-m", "initial collector")
_git(tmp_path, "tag", "site-adapter-collector-v1.0.1")
return tmp_path
def test_latest_release_ignores_backend_and_orders_versions_numerically():
"""后端版本和非法标签不得参与采集器版本排序。"""
assert release.latest_release_tag([
"v3.1.4", "site-adapter-collector-v1.0.9", "site-adapter-collector-v1.0.10",
"site-adapter-collector-v1.0.11-rc1", "site-adapter-collector-v1.01.1",
]) == "site-adapter-collector-v1.0.10"
assert release.latest_release_tag(["v3.1.4"]) is None
def test_first_release_uses_existing_collector_version(collector_repository: Path):
"""首次独立发布沿用采集器版本,不能使用后端版本号。"""
plan = release.release_plan(collector_repository, None)
assert plan["tag"] == "site-adapter-collector-v1.0.1"
assert plan["changed"] == "true"
assert plan["source_sha"] == _git(collector_repository, "rev-parse", "HEAD")
def test_backend_and_document_changes_do_not_release(collector_repository: Path):
"""后端和文档提交、手动重复执行都不能消耗采集器版本。"""
for name in ("version.py", "README.md", "tests/test_collector.py"):
path = collector_repository / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("unrelated\n", encoding="utf-8")
_git(collector_repository, "add", ".")
_git(collector_repository, "commit", "-m", "backend and docs")
for _ in range(2):
plan = release.release_plan(collector_repository, "site-adapter-collector-v1.0.1")
assert plan["changed"] == "false"
assert plan["version"] == "1.0.1"
@pytest.mark.parametrize("relative", release.RELEASE_PATHS)
def test_collector_input_change_increments_patch(collector_repository: Path, relative: str):
"""采集器任意发布输入改变都必须生成下一个补丁版本。"""
path = collector_repository / relative
if relative == "scripts/collector":
path = path / "release.py"
with path.open("a", encoding="utf-8") as output:
output.write("# collector change\n")
_git(collector_repository, "add", ".")
_git(collector_repository, "commit", "-m", "change collector input")
plan = release.release_plan(collector_repository, "site-adapter-collector-v1.0.1")
assert plan["changed"] == "true"
assert plan["version"] == "1.0.2"
def test_subsequent_release_uses_published_version(collector_repository: Path):
"""源码版本无需回写,下次补丁必须以正式 Release 版本为准。"""
_git(collector_repository, "tag", "site-adapter-collector-v1.0.9")
(collector_repository / "scripts/site_adapter_collector.spec").unlink()
_git(collector_repository, "add", ".")
_git(collector_repository, "commit", "-m", "remove collector build input")
assert release.release_plan(
collector_repository, "site-adapter-collector-v1.0.9",
)["version"] == "1.0.10"
def test_missing_previous_tag_fails_instead_of_publishing(collector_repository: Path):
"""比较失败不能误判为采集器改变并发版。"""
with pytest.raises(RuntimeError, match="无法比较"):
release.release_plan(collector_repository, "site-adapter-collector-v1.0.8")
def test_stamp_sets_runtime_and_manifest_version(tmp_path: Path):
"""构建版本写入现有声明,其他源码和采集包协议版本保持一致。"""
source = tmp_path / "collector.py"
source.write_text('COLLECTOR_VERSION = "1.0.1"\nFORMAT_VERSION = 1\n', encoding="utf-8")
release.stamp_version(source, "1.0.10")
assert source.read_text(encoding="utf-8") == 'COLLECTOR_VERSION = "1.0.10"\nFORMAT_VERSION = 1\n'
with pytest.raises(ValueError, match="无效采集器版本"):
release.stamp_version(source, "1.0.10\ninvalid")
def test_workflow_separates_releases_and_pins_all_build_sources():
"""工作流只跟随采集器输入,三平台成功才发布且保留主程序 latest。"""
workflow = YAML(typ="safe").load(
(PROJECT_ROOT / ".github/workflows/site-adapter-collector.yml").read_text(encoding="utf-8"),
)
assert "release" not in workflow["on"]
assert workflow["on"]["push"]["branches"] == ["v3"]
paths = workflow["on"]["push"]["paths"]
assert set(paths) == set(release.RELEASE_PATHS) - {"scripts/collector"} | {"scripts/collector/**"}
assert workflow["concurrency"]["cancel-in-progress"] is False
jobs = workflow["jobs"]
assert "needs.prepare.outputs.changed == 'true'" in jobs["build"]["if"]
assert "!inputs.publish" in jobs["build"]["if"]
assert jobs["publish"]["needs"] == ["prepare", "build"]
for job in ("build", "publish"):
checkout = jobs[job]["steps"][0]
assert checkout["with"]["ref"] == "${{ needs.prepare.outputs.source_sha }}"
build_step = next(step for step in jobs["build"]["steps"] if step["name"] == "Build single-file collector")
assert build_step["shell"] == "bash"
assert "release.py stamp" in build_step["run"]
publish = jobs["publish"]["steps"][-1]["run"]
assert "--target \"$SOURCE_SHA\" --draft" in publish
assert "--draft=false --latest=false" in publish
assert '"$RELEASE_TAG^{commit}"' in publish

View File

@@ -142,9 +142,11 @@ def test_scheduled_check_respects_independent_switches(
def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
"""主程序更新应跳过最新采集器独立 Release 和后端预发布版本。"""
manager = _manager(monkeypatch, tmp_path)
logs = []
releases = [
{"tag_name": "site-adapter-collector-v1.0.1", "prerelease": False, "draft": False},
{"tag_name": "v3.2.0-beta", "prerelease": True, "draft": False},
{
"tag_name": "v3.1.0",