mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
545 lines
21 KiB
Python
545 lines
21 KiB
Python
"""
|
||
正式镜像发布的供应链门禁合同。
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from ruamel.yaml import YAML
|
||
|
||
from scripts.normalize_audit_requirements import normalize_requirements
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DOCKERFILE = ROOT / "docker" / "Dockerfile"
|
||
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
|
||
BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml"
|
||
PR_AGENT_WORKFLOW = ROOT / ".github" / "workflows" / "pr-agent.yml"
|
||
CODEX_EVENT_WORKFLOW = ROOT / ".github" / "workflows" / "moviepilot-codex-events.yml"
|
||
WORKFLOW_ROOT = ROOT / ".github" / "workflows"
|
||
|
||
ALLOWED_ACTION_REFS = {
|
||
"actions/checkout@v7",
|
||
"actions/setup-python@v7",
|
||
"actions/github-script@v9",
|
||
"actions/stale@v11",
|
||
"astral-sh/setup-uv@v10.0.1",
|
||
"docker/metadata-action@v6",
|
||
"docker/setup-qemu-action@v4",
|
||
"docker/setup-buildx-action@v4",
|
||
"docker/build-push-action@v7",
|
||
"docker/login-action@v4",
|
||
"actions/upload-artifact@v7",
|
||
"actions/download-artifact@v8",
|
||
"docker://ghcr.io/infinitypacer/pr-review-runner:latest",
|
||
}
|
||
|
||
|
||
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
|
||
"""
|
||
以 YAML 1.2 解析镜像发布工作流。
|
||
"""
|
||
yaml = YAML(typ="safe")
|
||
return yaml.load(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _steps_by_name(workflow: dict) -> dict[str, dict]:
|
||
"""
|
||
按名称索引发布步骤,顺序仍由原列表校验。
|
||
"""
|
||
return {
|
||
step["name"]: step
|
||
for step in workflow["jobs"]["Docker-build"]["steps"]
|
||
if "name" in step
|
||
}
|
||
|
||
|
||
def _write_fake_gh(tmp_path: Path) -> Path:
|
||
"""
|
||
创建可控制响应和退出状态的 gh 测试替身。
|
||
"""
|
||
bin_dir = tmp_path / "bin"
|
||
bin_dir.mkdir()
|
||
gh = bin_dir / "gh"
|
||
gh.write_text(
|
||
"""#!/usr/bin/env bash
|
||
printf '%s\n' "$*" >> "$GH_LOG"
|
||
cat "$GH_RESPONSE_FILE"
|
||
cat "$GH_ERROR_FILE" >&2
|
||
exit "$GH_EXIT_CODE"
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
gh.chmod(0o755)
|
||
return bin_dir
|
||
|
||
|
||
def _run_release_script(
|
||
script: str,
|
||
tmp_path: Path,
|
||
*,
|
||
response: str = "",
|
||
error: str = "",
|
||
exit_code: int = 0,
|
||
extra_env: dict[str, str] | None = None,
|
||
) -> subprocess.CompletedProcess[str]:
|
||
"""
|
||
在隔离的 gh 替身环境中执行发布 workflow 脚本。
|
||
"""
|
||
bash = shutil.which("bash")
|
||
if bash is None:
|
||
pytest.skip("release workflow contract requires Bash")
|
||
response_file = tmp_path / "response.txt"
|
||
error_file = tmp_path / "error.txt"
|
||
response_file.write_text(response, encoding="utf-8")
|
||
error_file.write_text(error, encoding="utf-8")
|
||
env = os.environ.copy()
|
||
env.update(
|
||
{
|
||
"PATH": f"{_write_fake_gh(tmp_path)}:{env['PATH']}",
|
||
"GH_RESPONSE_FILE": str(response_file),
|
||
"GH_ERROR_FILE": str(error_file),
|
||
"GH_EXIT_CODE": str(exit_code),
|
||
"GH_LOG": str(tmp_path / "gh.log"),
|
||
"GITHUB_REPOSITORY": "jxxghp/MoviePilot",
|
||
"GITHUB_ENV": str(tmp_path / "github.env"),
|
||
"GITHUB_OUTPUT": str(tmp_path / "github.output"),
|
||
}
|
||
)
|
||
env.update(extra_env or {})
|
||
return subprocess.run(
|
||
[bash, "-euo", "pipefail", "-c", script],
|
||
cwd=tmp_path,
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
|
||
|
||
def test_base_image_uses_refreshable_tag_and_upgrades_only_in_refresh_layer() -> None:
|
||
"""
|
||
使用官方基础镜像及所需运行依赖,不追加针对扫描结果的逐包补丁;
|
||
系统包只在 final 末尾由刷新键控制的系统安全更新层统一升级。
|
||
"""
|
||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||
assert 'ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"' in dockerfile
|
||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||
assert 'uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t"' in dockerfile
|
||
assert "apt-get upgrade" not in dockerfile
|
||
assert dockerfile.count("upgrade;") == 1
|
||
assert "dist-upgrade" not in dockerfile
|
||
refresh_layer = dockerfile.split('ARG MOVIEPILOT_SYSTEM_REFRESH_ID=""', maxsplit=1)[1]
|
||
assert "upgrade;" in refresh_layer
|
||
assert "apt-get install" not in refresh_layer
|
||
assert "pip uninstall" not in dockerfile
|
||
assert "break-system-packages" not in dockerfile
|
||
browser_install = dockerfile.split("python -m playwright install-deps chromium", maxsplit=1)[1]
|
||
browser_install = browser_install.split("\nFROM ", maxsplit=1)[0]
|
||
assert "apt-get install" not in browser_install
|
||
|
||
|
||
def test_rclone_image_uses_official_stable_channel() -> None:
|
||
"""
|
||
rclone 使用官方稳定镜像,不固定用于应对扫描的临时 Beta。
|
||
"""
|
||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||
assert "FROM rclone/rclone:latest AS rclone" in dockerfile
|
||
|
||
|
||
def test_release_audits_locked_runtime_dependencies_before_building() -> None:
|
||
"""
|
||
正式版和 Beta 构建前必须分别审计两套锁定运行依赖。
|
||
"""
|
||
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
|
||
workflow = _load_workflow(workflow_path)
|
||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||
names = [step.get("name") for step in steps]
|
||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||
|
||
first_publish = next(name for name in names if name and name.startswith("Publish "))
|
||
assert names.index("Audit locked Python dependencies") < names.index(first_publish)
|
||
assert "--group runtime-standard" in audit
|
||
assert "--group runtime-free-threaded" in audit
|
||
assert "scripts/normalize_audit_requirements.py" in audit
|
||
assert "uvx --from pip-audit pip-audit" in audit
|
||
assert "pip-audit==" not in audit
|
||
for option in ("--require-hashes", "--no-deps", "--disable-pip", "--strict"):
|
||
assert option in audit
|
||
# 两个运行时组都要再经 OSV 补查,PyPI 服务单独使用会漏报部分公告。
|
||
assert audit.count("--vulnerability-service osv") == 2
|
||
|
||
|
||
def test_direct_url_audit_requirement_uses_version_from_matching_lock_source(tmp_path: Path) -> None:
|
||
"""
|
||
URL 依赖的漏洞审计版本必须来自同名且同来源的锁文件条目。
|
||
"""
|
||
lock_file = tmp_path / "uv.lock"
|
||
lock_file.write_text(
|
||
"""
|
||
version = 1
|
||
|
||
[[package]]
|
||
name = "Brotli"
|
||
version = "1.2.0"
|
||
source = { url = "https://example.com/brotli.tar.gz" }
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
exported = (
|
||
"brotli @ https://example.com/brotli.tar.gz ; python_version >= '3.14' \\\n"
|
||
" # via httpx\n"
|
||
)
|
||
|
||
normalized = normalize_requirements(exported, lock_file)
|
||
|
||
assert "brotli==1.2.0 ; python_version >= '3.14' \\" in normalized
|
||
assert "@ https://example.com/brotli.tar.gz" not in normalized
|
||
|
||
|
||
def test_patched_free_threaded_brotli_uses_security_version_for_audit(tmp_path: Path) -> None:
|
||
"""
|
||
无 GIL Brotli 源码保留 1.2.0 元数据时,审计应按已包含修复的 1.2.1 匹配公告。
|
||
"""
|
||
source = (
|
||
"https://github.com/google/brotli/archive/"
|
||
"0ed4268a4401abcb4472096340477cf4af7d1a2b.tar.gz"
|
||
)
|
||
lock_file = tmp_path / "uv.lock"
|
||
lock_file.write_text(
|
||
f'version = 1\n\n[[package]]\nname = "brotli"\nversion = "1.2.0"\n'
|
||
f'source = {{ url = "{source}" }}\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
normalized = normalize_requirements(f"brotli @ {source}\n", lock_file)
|
||
|
||
assert normalized == "brotli==1.2.1\n"
|
||
|
||
|
||
def test_direct_url_audit_requirement_rejects_unlocked_source(tmp_path: Path) -> None:
|
||
"""
|
||
不能把未匹配锁文件来源的 URL 依赖伪装为已审计版本。
|
||
"""
|
||
lock_file = tmp_path / "uv.lock"
|
||
lock_file.write_text("version = 1\npackage = []\n", encoding="utf-8")
|
||
|
||
with pytest.raises(ValueError, match="无法在锁文件中定位精确版本"):
|
||
normalize_requirements("demo @ https://example.com/demo.tar.gz\n", lock_file)
|
||
|
||
|
||
@pytest.mark.parametrize("path", (RELEASE_WORKFLOW, BETA_WORKFLOW))
|
||
def test_release_builds_publish_both_architectures_directly(path: Path) -> None:
|
||
"""
|
||
正式版和 Beta 直接构建发布双架构产物,删除扫描专用候选流程。
|
||
"""
|
||
workflow = _load_workflow(path)
|
||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||
builds = [step for step in steps if step.get("uses") == "docker/build-push-action@v7"]
|
||
assert len(builds) == 2
|
||
for step in builds:
|
||
config = step["with"]
|
||
assert config["push"] is True
|
||
assert config["pull"] is True
|
||
assert set(config["platforms"].split()) == {"linux/amd64", "linux/arm64/v8"}
|
||
assert "cache-to" in config
|
||
assert all("trivy" not in step.get("uses", "") for step in steps)
|
||
|
||
|
||
def test_workflows_follow_maintained_action_channels() -> None:
|
||
"""
|
||
官方工具使用批准的稳定引用,不引入未知来源或手工 commit SHA。
|
||
"""
|
||
for workflow_path in sorted(WORKFLOW_ROOT.glob("*.yml")):
|
||
workflow = _load_workflow(workflow_path)
|
||
for job_name, job in workflow.get("jobs", {}).items():
|
||
for step in job.get("steps", []):
|
||
uses = step.get("uses")
|
||
if uses:
|
||
assert uses in ALLOWED_ACTION_REFS, (
|
||
f"{workflow_path}:{job_name}:{step.get('name', '<unnamed>')}: {uses}"
|
||
)
|
||
if uses == "astral-sh/setup-uv@v10.0.1":
|
||
assert "version" not in step.get("with", {})
|
||
|
||
|
||
def test_all_workflows_are_valid_yaml() -> None:
|
||
"""
|
||
所有 GitHub Actions 工作流都必须能被 YAML 1.2 解析。
|
||
"""
|
||
for workflow_path in sorted(WORKFLOW_ROOT.glob("*.yml")):
|
||
workflow = _load_workflow(workflow_path)
|
||
assert isinstance(workflow, dict), workflow_path
|
||
assert isinstance(workflow.get("jobs"), dict), workflow_path
|
||
|
||
|
||
def test_codex_event_intake_replaces_legacy_pr_agent_trigger() -> None:
|
||
"""
|
||
旧 PR-Agent 仅保留手工入口,Issue/PR 自动分析统一交给 Codex 事件桥。
|
||
"""
|
||
legacy_workflow = _load_workflow(PR_AGENT_WORKFLOW)
|
||
assert set(legacy_workflow["on"]) == {"workflow_dispatch"}
|
||
|
||
workflow = _load_workflow(CODEX_EVENT_WORKFLOW)
|
||
assert "issues" in workflow["on"]
|
||
assert "pull_request_target" in workflow["on"]
|
||
assert workflow["permissions"] == {"actions": "write", "contents": "read"}
|
||
steps = workflow["jobs"]["dispatch"]["steps"]
|
||
assert len(steps) == 1
|
||
dispatch_step = steps[0]
|
||
assert "gh workflow run" in dispatch_step["run"]
|
||
assert "OPENAI_API_KEY" not in dispatch_step["run"]
|
||
assert "TELEGRAM_BOT_TOKEN" not in dispatch_step["run"]
|
||
|
||
|
||
def test_release_uses_github_cli_for_tag_and_release_lifecycle() -> None:
|
||
"""
|
||
正式发布复用 GitHub CLI,并只把明确不存在识别为新 Release。
|
||
"""
|
||
workflow = _load_workflow()
|
||
indexed = _steps_by_name(workflow)
|
||
serialized = RELEASE_WORKFLOW.read_text(encoding="utf-8")
|
||
|
||
assert "dev-drprasad/delete-tag-and-release" not in serialized
|
||
assert "softprops/action-gh-release" not in serialized
|
||
release_query = indexed["Get existing release body"]["run"]
|
||
assert "gh api --include" in release_query
|
||
assert 'if [ "$status_code" = "404" ]' in release_query
|
||
assert "cat \"$error_file\" >&2\n exit 1" in release_query
|
||
assert "gh release delete" not in serialized
|
||
assert 'git tag -f "$tag_name" "$RELEASE_COMMIT"' in indexed["Publish Release Tag"]["run"]
|
||
assert 'git push --force origin "refs/tags/${tag_name}"' in indexed["Publish Release Tag"]["run"]
|
||
publish_release = indexed["Publish Release"]["run"]
|
||
assert 'if [ "$RELEASE_EXISTS" = "true" ]' in publish_release
|
||
assert "gh release edit" in publish_release
|
||
assert "gh release create" in publish_release
|
||
assert '--notes-file "$notes_file"' in publish_release
|
||
assert "--draft=false" in publish_release
|
||
assert "--prerelease=false" in publish_release
|
||
assert "--latest" in publish_release
|
||
names = [step.get("name") for step in workflow["jobs"]["Docker-build"]["steps"]]
|
||
assert names.index("Get existing release body") < names.index("Publish Release Tag")
|
||
assert names.index("Publish Release Tag") < names.index("Publish Release")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("response", "exit_code", "expected_exists", "expected_body"),
|
||
[
|
||
("HTTP/2.0 200 OK\nHeader: value\n\nmanual body\n", 0, "true", "manual body"),
|
||
("HTTP/2.0 200 OK\n\nmanual\nEOF\nMORE=value\n", 0, "true", "manual\nEOF\nMORE=value"),
|
||
("HTTP/2.0 404 Not Found\n\n", 1, "false", ""),
|
||
],
|
||
)
|
||
def test_release_query_preserves_existing_body_or_handles_explicit_404(
|
||
tmp_path: Path,
|
||
response: str,
|
||
exit_code: int,
|
||
expected_exists: str,
|
||
expected_body: str,
|
||
) -> None:
|
||
"""
|
||
已有 Release 保留正文,只有明确 404 才将正文初始化为空。
|
||
"""
|
||
script = _steps_by_name(_load_workflow())["Get existing release body"]["run"]
|
||
script = script.replace("v${{ env.app_version }}", "v3.0.0")
|
||
|
||
result = _run_release_script(script, tmp_path, response=response, exit_code=exit_code)
|
||
|
||
assert result.returncode == 0, result.stderr
|
||
output = (tmp_path / "github.output").read_text(encoding="utf-8")
|
||
environment = (tmp_path / "github.env").read_text(encoding="utf-8")
|
||
assert f"exists={expected_exists}" in output
|
||
assert expected_body in environment
|
||
delimiter = environment.splitlines()[0].split("<<")[1]
|
||
assert delimiter.startswith("RELEASE_NOTES_")
|
||
assert environment.splitlines()[-1] == delimiter
|
||
|
||
|
||
def test_release_query_fails_closed_on_non_404_error(tmp_path: Path) -> None:
|
||
"""
|
||
网络或服务端错误不得伪装成 Release 不存在。
|
||
"""
|
||
script = _steps_by_name(_load_workflow())["Get existing release body"]["run"]
|
||
script = script.replace("v${{ env.app_version }}", "v3.0.0")
|
||
|
||
result = _run_release_script(
|
||
script,
|
||
tmp_path,
|
||
response="HTTP/2.0 500 Internal Server Error\n\n",
|
||
error="GitHub API unavailable\n",
|
||
exit_code=1,
|
||
)
|
||
|
||
assert result.returncode != 0
|
||
assert "GitHub API unavailable" in result.stderr
|
||
assert not (tmp_path / "github.env").exists()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("release_exists", "expected_command"),
|
||
[("true", "release edit"), ("false", "release create")],
|
||
)
|
||
def test_release_publish_selects_edit_or_create(
|
||
tmp_path: Path,
|
||
release_exists: str,
|
||
expected_command: str,
|
||
) -> None:
|
||
"""
|
||
发布阶段按查询结果原位更新或创建 Release。
|
||
"""
|
||
script = _steps_by_name(_load_workflow())["Publish Release"]["run"]
|
||
script = script.replace("v${{ env.app_version }}", "v3.0.0")
|
||
|
||
result = _run_release_script(
|
||
script,
|
||
tmp_path,
|
||
extra_env={"RELEASE_EXISTS": release_exists, "RELEASE_BODY": "release notes"},
|
||
)
|
||
|
||
assert result.returncode == 0, result.stderr
|
||
log = (tmp_path / "gh.log").read_text(encoding="utf-8")
|
||
assert expected_command in log
|
||
if release_exists == "true":
|
||
assert "--draft=false" in log
|
||
assert "--prerelease=false" in log
|
||
|
||
|
||
def test_dependency_compat_checks_minimum_uv_version() -> None:
|
||
"""
|
||
依赖兼容 job 必须断言 uv 满足最低版本,而不是只打印版本。
|
||
"""
|
||
workflow = _load_workflow(ROOT / ".github" / "workflows" / "dependency-compat.yml")
|
||
steps = workflow["jobs"]["docker-dependencies"]["steps"]
|
||
verify = next(step for step in steps if step.get("name") == "Verify minimum uv version")
|
||
command = verify["run"]
|
||
|
||
assert "['uv', '--version']" in command
|
||
assert "Version(version) >= Version('0.12.5')" in command
|
||
assert "assert" in command
|
||
|
||
|
||
def test_publish_refreshes_base_and_preserves_build_cache() -> None:
|
||
"""
|
||
直接发布时拉取基础镜像,同时读写双架构构建缓存。
|
||
"""
|
||
workflow = _load_workflow()
|
||
publish = _steps_by_name(workflow)["Publish multi-architecture image"]["with"]
|
||
assert workflow["on"]["workflow_dispatch"] is None
|
||
assert publish["push"] is True
|
||
assert publish["pull"] is True
|
||
assert "scope=moviepilot-v3-standard-docker," in publish["cache-to"]
|
||
|
||
|
||
def test_release_gate_only_publishes_changed_versions(tmp_path: Path) -> None:
|
||
"""
|
||
自动发布只接受版本值变化,手动重建保留入口。
|
||
"""
|
||
workflow = _load_workflow()
|
||
gate = workflow["jobs"]["release-gate"]
|
||
build = workflow["jobs"]["Docker-build"]
|
||
assert build["needs"] == "release-gate"
|
||
assert build["if"] == "needs.release-gate.outputs.publish == 'true'"
|
||
|
||
repo = tmp_path / "repo"
|
||
repo.mkdir()
|
||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||
version_file = repo / "version.py"
|
||
version_file.write_text(
|
||
"APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n",
|
||
encoding="utf-8",
|
||
)
|
||
subprocess.run(["git", "-C", str(repo), "add", "version.py"], check=True)
|
||
subprocess.run(
|
||
["git", "-C", str(repo), "-c", "user.name=Test", "-c", "user.email=test@example.com",
|
||
"commit", "-qm", "base"],
|
||
check=True,
|
||
)
|
||
before_sha = subprocess.check_output(
|
||
["git", "-C", str(repo), "rev-parse", "HEAD"], text=True
|
||
).strip()
|
||
command = next(step for step in gate["steps"] if step.get("id") == "detect")["run"]
|
||
output_file = tmp_path / "output"
|
||
|
||
def detect(event_name: str, content: str) -> str:
|
||
version_file.write_text(content, encoding="utf-8")
|
||
output_file.write_text("", encoding="utf-8")
|
||
subprocess.run(
|
||
["bash", "-e", "-c", command], cwd=repo, check=True,
|
||
env={**os.environ, "EVENT_NAME": event_name, "BEFORE_SHA": before_sha,
|
||
"GITHUB_OUTPUT": str(output_file)},
|
||
)
|
||
return output_file.read_text(encoding="utf-8").strip()
|
||
|
||
assert detect("push", "\"\"\"说明文字\"\"\"\nAPP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=false"
|
||
assert detect("push", "APP_VERSION = 'v3.1.7'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=true"
|
||
assert detect("push", "APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.7'\n") == "publish=true"
|
||
assert detect("workflow_dispatch", "APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=true"
|
||
|
||
|
||
def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache() -> None:
|
||
"""
|
||
free-threaded 发布必须使用 v3t 命名、参数和独立缓存。
|
||
"""
|
||
workflow = _load_workflow()
|
||
indexed = _steps_by_name(workflow)
|
||
|
||
metadata = indexed["Docker Meta free-threaded"]
|
||
publish = indexed["Publish free-threaded multi-architecture image"]
|
||
|
||
assert "moviepilot-v3t" in metadata["with"]["images"]
|
||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in publish["with"]["build-args"]
|
||
assert "scope=moviepilot-v3t-docker-amd64" in publish["with"]["cache-from"]
|
||
assert "scope=moviepilot-v3t-docker-arm64" in publish["with"]["cache-from"]
|
||
|
||
|
||
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)
|
||
|
||
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"
|
||
)
|
||
|
||
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_publish_contract() -> None:
|
||
"""
|
||
Beta 保持两个变体的发布参数、标签和缓存隔离。
|
||
"""
|
||
workflow = _load_workflow(BETA_WORKFLOW)
|
||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||
names = [step.get("name") for step in steps]
|
||
indexed = _steps_by_name(workflow)
|
||
assert workflow["on"]["workflow_dispatch"] is None
|
||
publish_names = (
|
||
"Publish standard multi-architecture image",
|
||
"Publish free-threaded multi-architecture image",
|
||
)
|
||
assert "MOVIEPILOT_PYTHON_VARIANT=standard" in indexed[publish_names[0]]["with"]["build-args"]
|
||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in indexed[publish_names[1]]["with"]["build-args"]
|
||
assert "scope=moviepilot-v3-standard-docker-amd64" in indexed[publish_names[0]]["with"]["cache-from"]
|
||
assert "scope=moviepilot-v3t-docker-amd64" in indexed[publish_names[1]]["with"]["cache-from"]
|
||
assert "value=beta" in indexed["Docker Meta"]["with"]["tags"]
|
||
assert "value=beta" in indexed["Docker Meta free-threaded"]["with"]["tags"]
|
||
assert "github.run_id" not in indexed["Docker Meta"]["with"]["tags"]
|
||
assert "github.run_id" not in indexed["Docker Meta free-threaded"]["with"]["tags"]
|
||
assert "Promote beta image pair" not in names
|