mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat: 新增 Python 3.14t 自由线程镜像 (#6434)
* fix(resource): select free-threaded extension ABI * chore(deps): require moviepilot-rust 0.2.9 * perf: add free-threaded runtime comparison * feat: add free-threaded runtime profile * chore(deps): require moviepilot-rust 0.3.0 * test: isolate system endpoint import graph * fix(docker): preserve runtime profile during recovery * feat(runtime): expose active GIL state * feat(plugin): log GIL fallback attribution * test: refresh runtime observability dependency baseline * fix(plugin): validate the active uv runtime profile * test(runtime): expand free-threaded benchmark evidence * docs(runtime): define v3t governance gates * test(runtime): separate rust benchmark modes * docs: sync free-threaded architecture baseline * perf: add PostgreSQL driver comparison * docs(runtime): record final free-threaded evidence * fix(runtime): converge dual-profile dependency verification * fix(runtime): scope Python 3.14 warning filter * fix(runtime): match actual oss2 syntax warning * docs(runtime): refresh free-threaded benchmark evidence * test(architecture): refresh runtime dependency baseline * ci: skip unused Trivy Java database * build: exclude local verification artifacts * docs(runtime): refresh PostgreSQL driver benchmarks * docs(runtime): record plugin restore acceptance * docs(runtime): record amd64 candidate acceptance * ci: pin beta image publisher action * test(architecture): merge runtime dependency baseline * feat(runtime): expose Python GIL status * docs(runtime): document Python runtime status fields
This commit is contained in:
@@ -3,19 +3,23 @@
|
||||
from datetime import date
|
||||
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"
|
||||
TRIVY_IGNORE = ROOT / ".trivyignore.yaml"
|
||||
|
||||
|
||||
def _load_workflow() -> dict:
|
||||
"""以 YAML 1.2 解析正式发布工作流。"""
|
||||
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
|
||||
"""以 YAML 1.2 解析镜像发布工作流。"""
|
||||
yaml = YAML(typ="safe")
|
||||
return yaml.load(RELEASE_WORKFLOW.read_text(encoding="utf-8"))
|
||||
return yaml.load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _steps_by_name(workflow: dict) -> dict[str, dict]:
|
||||
@@ -31,29 +35,77 @@ def test_base_image_uses_refreshable_tag_and_apt_does_not_upgrade_in_place() ->
|
||||
"""基础镜像允许获得上游更新,构建阶段不得无边界升级整套 Debian。"""
|
||||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||
|
||||
assert "FROM python:3.14.7-slim-trixie AS base" in dockerfile
|
||||
assert "python:3.14.7-slim-trixie@sha256:" not in dockerfile
|
||||
assert 'ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"' in dockerfile
|
||||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||||
assert "python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie@sha256:" not in dockerfile
|
||||
free_threaded_stage = dockerfile.split(
|
||||
"FROM prepare_venv_common AS prepare_venv_free-threaded",
|
||||
maxsplit=1,
|
||||
)[1]
|
||||
assert "ARG MOVIEPILOT_PYTHON_VERSION" in free_threaded_stage
|
||||
assert 'uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t"' in free_threaded_stage
|
||||
assert "apt-get upgrade" not in dockerfile
|
||||
assert "\n util-linux \\\n" in dockerfile
|
||||
|
||||
|
||||
def test_release_audits_locked_runtime_dependencies_before_building() -> None:
|
||||
"""发布构建前必须审计带哈希的锁定运行时依赖。"""
|
||||
workflow = _load_workflow()
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||||
"""正式版和 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"]
|
||||
|
||||
assert names.index("Audit locked Python dependencies") < names.index("Build amd64 candidate")
|
||||
assert "uv export --quiet --locked --no-dev --no-emit-project" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
first_candidate = next(name for name in names if name and name.startswith("Build "))
|
||||
assert names.index("Audit locked Python dependencies") < names.index(first_candidate)
|
||||
assert "--group runtime-standard" in audit
|
||||
assert "--group runtime-free-threaded" in audit
|
||||
assert "scripts/normalize_audit_requirements.py" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--no-deps", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
|
||||
|
||||
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_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)
|
||||
|
||||
|
||||
def test_release_scans_both_architectures_before_registry_login_and_publish() -> None:
|
||||
"""任一架构的最终漏洞扫描失败时都不得登录仓库或发布镜像。"""
|
||||
"""两个 Python 变体的各架构扫描都必须在登录仓库和发布前完成。"""
|
||||
workflow = _load_workflow()
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
@@ -61,6 +113,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
expected_candidates = {
|
||||
"Build amd64 candidate": ("linux/amd64", "moviepilot-v3-candidate:linux-amd64"),
|
||||
"Build arm64 candidate": ("linux/arm64/v8", "moviepilot-v3-candidate:linux-arm64"),
|
||||
"Build free-threaded amd64 candidate": (
|
||||
"linux/amd64",
|
||||
"moviepilot-v3t-candidate:linux-amd64",
|
||||
),
|
||||
"Build free-threaded arm64 candidate": (
|
||||
"linux/arm64/v8",
|
||||
"moviepilot-v3t-candidate:linux-arm64",
|
||||
),
|
||||
}
|
||||
for name, (platform, tag) in expected_candidates.items():
|
||||
build = indexed[name]["with"]
|
||||
@@ -70,10 +130,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
assert build["tags"] == tag
|
||||
assert build["pull"] is True
|
||||
assert "no-cache-filters" not in build
|
||||
expected_variant = "free-threaded" if "free-threaded" in name else "standard"
|
||||
assert f"MOVIEPILOT_PYTHON_VARIANT={expected_variant}" in build["build-args"]
|
||||
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
):
|
||||
scan = indexed[name]
|
||||
assert scan["with"]["cache-dir"] == "${{ runner.temp }}/trivy"
|
||||
@@ -91,11 +155,19 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
"exit-code": 1,
|
||||
}.items()
|
||||
|
||||
last_scan = names.index("Scan arm64 candidate vulnerabilities")
|
||||
last_scan = max(
|
||||
names.index(name)
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
)
|
||||
assert last_scan < names.index("Login DockerHub")
|
||||
assert last_scan < names.index("Login GitHub Container Registry")
|
||||
assert last_scan < names.index("Publish multi-architecture image")
|
||||
|
||||
assert last_scan < names.index("Publish free-threaded multi-architecture image")
|
||||
|
||||
def test_vulnerability_ignores_are_scoped_justified_and_time_bounded() -> None:
|
||||
"""漏洞豁免必须限定制品范围,并保留复查期限和接受理由。"""
|
||||
@@ -118,5 +190,84 @@ def test_publish_reuses_scanned_architecture_caches_without_refreshing_base() ->
|
||||
assert publish["platforms"] == "linux/amd64\nlinux/arm64/v8\n"
|
||||
assert publish["push"] is True
|
||||
assert publish["pull"] is False
|
||||
assert "scope=moviepilot-v3-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-docker-arm64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-arm64" in publish["cache-from"]
|
||||
|
||||
|
||||
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_promotes_latest_only_after_both_versioned_images() -> 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"
|
||||
)
|
||||
promote = indexed["Promote latest image pair"]["run"]
|
||||
assert "moviepilot-v3:latest" not in promote
|
||||
assert '"${image}:latest"' in promote
|
||||
assert '"${image}:${app_version}"' in promote
|
||||
|
||||
|
||||
def test_beta_applies_the_same_variant_scan_and_publish_contract() -> None:
|
||||
"""Beta 也必须在发布两个变体前完成各架构漏洞扫描。"""
|
||||
workflow = _load_workflow(BETA_WORKFLOW)
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
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
|
||||
for name in (
|
||||
"Build standard amd64 candidate",
|
||||
"Build standard arm64 candidate",
|
||||
"Build free-threaded amd64 candidate",
|
||||
"Build free-threaded arm64 candidate",
|
||||
):
|
||||
assert indexed[name]["with"]["load"] is True
|
||||
assert indexed[name]["with"]["push"] is False
|
||||
|
||||
scan_names = (
|
||||
"Scan standard amd64 candidate vulnerabilities",
|
||||
"Scan standard arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
publish_names = (
|
||||
"Publish standard multi-architecture image",
|
||||
"Publish free-threaded multi-architecture image",
|
||||
)
|
||||
last_scan = max(names.index(name) for name in scan_names)
|
||||
assert all(last_scan < names.index(name) for name in publish_names)
|
||||
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-${{ github.run_id }}-${{ github.run_attempt }}" in indexed["Docker Meta"]["with"]["tags"]
|
||||
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed[
|
||||
"Docker Meta free-threaded"
|
||||
]["with"]["tags"]
|
||||
assert all(names.index(name) < names.index("Promote beta image pair") for name in publish_names)
|
||||
promote = indexed["Promote beta image pair"]["run"]
|
||||
assert '"${image}:beta"' in promote
|
||||
assert '"${image}:${candidate}"' in promote
|
||||
|
||||
Reference in New Issue
Block a user