perf(docker): isolate high-frequency image payloads (#6365)

This commit is contained in:
InfinityPacer
2026-08-20 14:47:06 +08:00
committed by GitHub
parent 23f5d59c74
commit 4e3a6c8032
9 changed files with 376 additions and 48 deletions
+26 -5
View File
@@ -10,14 +10,17 @@ LICENSE
# Development files
.pylintrc
*.pyc
__pycache__/
*.pyo
*.pyd
**/*.pyc
**/__pycache__/
**/*.pyo
**/*.pyd
.Python
*.so
**/*.so
.pytest_cache/
.ruff_cache/
.coverage
coverage.json
coverage.xml
htmlcov/
.tox/
.nox/
@@ -27,12 +30,30 @@ htmlcov/
dmypy.json
# Virtual environments
.venv/
.worktrees/
venv/
env/
ENV/
env.bak/
venv.bak/
# Runtime state and locally synchronized payloads
.build/
.agent-work/
.runtime/
.tmp/
node_modules/
public/
.moviepilot.env
.env
.env.*
config/*
!config/category.yaml
app/plugins/**
!app/plugins/__init__.py
app/application/site/*.bin
# IDE
.vscode/
.idea/
+41
View File
@@ -26,7 +26,34 @@ jobs:
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py)
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT"
- name: Resolve External Payload Revisions
id: payloads
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }}
run: |
plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}')
resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}')
frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \
--jq '.assets[] | select(.name == "dist.zip") | .digest')
[[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]]
[[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]]
case "$frontend_digest" in
sha256:*) frontend_sha256=${frontend_digest#sha256:} ;;
*) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;;
esac
[[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT"
echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT"
echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT"
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
@@ -48,6 +75,7 @@ jobs:
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Download models.dev catalog
id: models_catalog
run: |
temp_file=$(mktemp app/agent/llm/models.json.XXXXXX)
trap 'rm -f "$temp_file"' EXIT
@@ -58,6 +86,7 @@ jobs:
# Git keeps only a small placeholder; the beta image receives the current catalog.
chmod 644 "$temp_file"
mv "$temp_file" app/agent/llm/models.json
echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT"
echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)"
- name: Docker Meta
@@ -99,8 +128,20 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: type=gha,scope=moviepilot-v3-docker,version=2
cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2
+40
View File
@@ -34,8 +34,34 @@ jobs:
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py)
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT"
- name: Resolve External Payload Revisions
id: payloads
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }}
run: |
plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}')
resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}')
frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \
--jq '.assets[] | select(.name == "dist.zip") | .digest')
[[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]]
[[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]]
case "$frontend_digest" in
sha256:*) frontend_sha256=${frontend_digest#sha256:} ;;
*) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;;
esac
[[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT"
echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT"
echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT"
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
@@ -57,6 +83,7 @@ jobs:
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Download models.dev catalog
id: models_catalog
run: |
temp_file=$(mktemp app/agent/llm/models.json.XXXXXX)
trap 'rm -f "$temp_file"' EXIT
@@ -67,6 +94,7 @@ jobs:
# Git keeps only a small placeholder; the release image receives the current catalog.
chmod 644 "$temp_file"
mv "$temp_file" app/agent/llm/models.json
echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT"
echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)"
- name: Create Release Snapshot
@@ -122,10 +150,22 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.release-snapshot-revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: type=gha,scope=moviepilot-v3-docker,version=2
cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2
+3 -2
View File
@@ -55,5 +55,6 @@ pylint-report.json
# Superpowers 设计/计划文档(本地协作产物,不纳入仓库)
docs/superpowers/
# 本地前端构建产物docker build 用,不入库)
frontend-dist/
# 保留本地前端构建产物目录,目录内产物不纳入仓库
frontend-dist/*
!frontend-dist/.gitkeep
+116 -36
View File
@@ -1,9 +1,20 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
FROM python:3.12.13-slim-bookworm AS base
# 准备外部制品所需的最小工具集
FROM base AS prepare_payload
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash busybox ca-certificates curl jq \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 准备软件包
FROM base AS prepare_package
@@ -105,30 +116,100 @@ RUN python3 -m venv --without-pip ${VENV_PATH} \
--no-dev \
--no-install-project
# 下载准备代
FROM prepare_package AS prepare_code
# 准备后端源
FROM base AS prepare_backend
WORKDIR /app
COPY . .
# 构建上下文中存在 frontend-dist/ 时优先使用本地前端产物(fork 自有前端),
# 否则回退下载官方 release 的 dist.zip
RUN if [ -d /app/frontend-dist ]; then \
mv /app/frontend-dist /public; \
RUN rm -rf /app/frontend-dist
# 准备前端制品
FROM prepare_payload AS prepare_frontend
ARG MOVIEPILOT_FRONTEND_VERSION=""
ARG MOVIEPILOT_FRONTEND_SHA256=""
COPY version.py /tmp/moviepilot-version.py
COPY frontend-dist/ /tmp/frontend-dist/
RUN set -eu; \
mkdir -p /public; \
if find /tmp/frontend-dist -mindepth 1 -type f ! -name '.gitkeep' -print -quit | grep -q .; then \
rm -f /tmp/frontend-dist/.gitkeep; \
cp -a /tmp/frontend-dist/. /public/; \
else \
FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
&& mv /dist /public; \
fi \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
&& mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \
&& cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \
while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \
&& curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/application/site/user.sites.v3.bin \
&& python_ver=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")') \
&& ARCH=$(uname -m) \
&& if [ "$ARCH" = "aarch64" ]; then SUFFIX="aarch64-linux-gnu"; else SUFFIX="x86_64-linux-gnu"; fi \
&& curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/application/site/sites.${python_ver}-${SUFFIX}.so"
frontend_version="${MOVIEPILOT_FRONTEND_VERSION:-$(sed -n "s/^FRONTEND_VERSION[[:space:]]*=[[:space:]]*'\([^']*\)'/\1/p" /tmp/moviepilot-version.py)}"; \
test -n "${frontend_version}"; \
curl -fsSL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" -o /tmp/frontend.zip; \
if [ -n "${MOVIEPILOT_FRONTEND_SHA256}" ]; then \
printf '%s %s\n' "${MOVIEPILOT_FRONTEND_SHA256}" /tmp/frontend.zip | sha256sum -c -; \
fi; \
busybox unzip -q /tmp/frontend.zip -d /tmp/frontend-release; \
cp -a /tmp/frontend-release/dist/. /public/; \
fi; \
test -f /public/index.html
# 准备镜像内置插件
FROM prepare_payload AS prepare_plugins
ARG MOVIEPILOT_PLUGINS_REF="main"
RUN set -eu; \
test -n "${MOVIEPILOT_PLUGINS_REF}"; \
curl -fsSL "https://github.com/jxxghp/MoviePilot-Plugins/archive/${MOVIEPILOT_PLUGINS_REF}.zip" -o /tmp/plugins.zip; \
busybox unzip -q /tmp/plugins.zip -d /tmp/plugins-src; \
plugin_root="$(find /tmp/plugins-src -mindepth 1 -maxdepth 1 -type d -print -quit)"; \
test -n "${plugin_root}"; \
mkdir -p /plugins; \
cp -a "${plugin_root}/plugins.v2/." /plugins/; \
jq -r 'to_entries[] | select(.value.v2 == true) | .key' "${plugin_root}/package.json" | awk '{print tolower($0)}' | \
while read -r plugin_id; do \
if [ ! -d "/plugins/${plugin_id}" ]; then \
cp -a "${plugin_root}/plugins/${plugin_id}" /plugins/; \
fi; \
done
# 准备站点资源
FROM prepare_payload AS prepare_resources
ARG TARGETARCH
ARG MOVIEPILOT_RESOURCES_REF="main"
RUN set -eu; \
test -n "${MOVIEPILOT_RESOURCES_REF}"; \
python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')"; \
target_arch="${TARGETARCH:-$(uname -m)}"; \
case "${target_arch}" in \
arm64|aarch64) suffix="aarch64-linux-gnu" ;; \
amd64|x86_64) suffix="x86_64-linux-gnu" ;; \
*) printf 'Unsupported architecture: %s\n' "${target_arch}" >&2; exit 1 ;; \
esac; \
mkdir -p /resources; \
curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/user.sites.v3.bin" -o /resources/user.sites.v3.bin; \
curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/sites.${python_ver}-${suffix}.so" -o "/resources/sites.${python_ver}-${suffix}.so"
# 准备容器控制面
FROM prepare_payload AS prepare_control
WORKDIR /control
COPY docker/ ./
RUN mkdir -p /bundle/control /bundle/nginx /bundle/bin \
&& find . -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -exec cp -f -t /bundle/control {} + \
&& cp -f launcher.sh /bundle/entrypoint.sh \
&& cp -f nginx.common.conf /bundle/nginx/common.conf \
&& cp -f nginx.template.conf /bundle/nginx/nginx.template.conf \
&& cp -f docker_http_proxy.conf /bundle/nginx/docker_http_proxy.conf \
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /bundle/bin/moviepilot \
&& bash -n /bundle/entrypoint.sh \
&& for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done \
&& chmod 755 /bundle/entrypoint.sh /bundle/bin/moviepilot \
&& chmod 500 /bundle/control/*.sh
# final 阶段: 安装运行时依赖和配置最终镜像
FROM prepare_package AS final
@@ -153,24 +234,15 @@ RUN playwright install-deps chromium \
/var/lib/apt/lists/* \
/var/tmp/*
# 准备运行代码
WORKDIR /app
# 准备容器控制面和运行目录
COPY --from=prepare_control /bundle/entrypoint.sh /entrypoint.sh
COPY --from=prepare_control /bundle/control /usr/local/lib/moviepilot/control
COPY --from=prepare_control /bundle/nginx/common.conf /etc/nginx/common.conf
COPY --from=prepare_control /bundle/nginx/nginx.template.conf /etc/nginx/nginx.template.conf
COPY --from=prepare_control /bundle/nginx/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf
COPY --from=prepare_control /bundle/bin/moviepilot /usr/local/bin/moviepilot
COPY --from=prepare_code /app /app
COPY --from=prepare_code /public /public
RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
&& cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \
&& mkdir -p /usr/local/lib/moviepilot/control \
&& find /app/docker -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -exec cp -f -t /usr/local/lib/moviepilot/control {} + \
&& cp -f /app/docker/launcher.sh /entrypoint.sh \
&& cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /usr/local/bin/moviepilot \
&& bash -n /entrypoint.sh \
&& for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done \
&& chmod 755 /entrypoint.sh /usr/local/bin/moviepilot \
&& chmod 500 /usr/local/lib/moviepilot/control/*.sh \
&& mkdir -p ${HOME} \
RUN mkdir -p ${HOME} \
&& groupadd -r moviepilot -g 918 \
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
&& python_ver=$(python3 -V | awk '{print $2}') \
@@ -180,6 +252,14 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
&& echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \
&& locale-gen zh_CN.UTF-8
# 载荷按独立更新频率写入镜像,后端源码保持在最后一层。
WORKDIR /app
COPY --link --from=prepare_frontend /public /public
COPY --link --from=prepare_plugins /plugins /app/app/plugins
COPY --link --from=prepare_resources /resources /app/app/application/site
COPY --link --from=prepare_backend /app /app
EXPOSE 3000
VOLUME [ "${CONFIG_DIR}" ]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1
+1
View File
@@ -0,0 +1 @@
+6 -2
View File
@@ -44,7 +44,11 @@ def test_runtime_image_keeps_pgdg_setup_architecture_neutral_and_cleans_apt_cach
assert len(pgdg_sources) == 1
pgdg_source = pgdg_sources[0]
assert "arch=" not in pgdg_source
assert "/var/lib/apt/lists/*" in dockerfile
assert dockerfile.index("postgresql-client-18") < dockerfile.index(
runtime_packages = dockerfile[
dockerfile.index("FROM base AS prepare_package") :
dockerfile.index("FROM base AS prepare_venv")
]
assert "/var/lib/apt/lists/*" in runtime_packages
assert runtime_packages.index("postgresql-client-18") < runtime_packages.index(
"/var/lib/apt/lists/*"
)
+8 -3
View File
@@ -41,12 +41,17 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
assert "uv-pip-compat" not in dockerfile
assert "requirements.in" not in dockerfile
assert "${VENV_PATH}/bin/pip" not in dockerfile
assert "-exec cp -f -t /usr/local/lib/moviepilot/control {} +" in dockerfile
assert "bash -n /entrypoint.sh" in dockerfile
assert "FROM prepare_payload AS prepare_control" in dockerfile
assert "-exec cp -f -t /bundle/control {} +" in dockerfile
assert "bash -n /bundle/entrypoint.sh" in dockerfile
assert (
"COPY --from=prepare_control /bundle/control "
"/usr/local/lib/moviepilot/control" in dockerfile
)
assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile
assert "CMD /usr/bin/curl -fsS" in dockerfile
assert (
'for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done'
'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done'
in dockerfile
)
+135
View File
@@ -0,0 +1,135 @@
"""Docker 构建输入和镜像载荷分层合同。"""
from pathlib import Path
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"
def _read(path: Path) -> str:
"""读取构建合同文件。"""
return path.read_text(encoding="utf-8")
def test_build_context_excludes_runtime_state_and_keeps_release_inputs() -> None:
"""本地运行数据不得进入构建上下文,发布所需入口必须保留。"""
dockerignore = _read(ROOT / ".dockerignore")
for pattern in (
".venv/",
".worktrees/",
".build/",
".agent-work/",
".runtime/",
".tmp/",
"node_modules/",
"public/",
".moviepilot.env",
".env",
".env.*",
"**/*.pyc",
"**/__pycache__/",
"**/*.pyd",
"**/*.so",
"coverage.json",
"coverage.xml",
"config/*",
"app/plugins/**",
"app/application/site/*.bin",
):
assert pattern in dockerignore
assert "!config/category.yaml" in dockerignore
assert "!app/plugins/__init__.py" in dockerignore
assert "frontend-dist" not in dockerignore
def test_dockerfile_assigns_each_payload_to_an_independent_stage() -> None:
"""高频载荷必须由独立 stage 生成,并在 final 中分别写入目标目录。"""
dockerfile = _read(DOCKERFILE)
for stage in (
"prepare_backend",
"prepare_frontend",
"prepare_plugins",
"prepare_resources",
"prepare_control",
):
assert f" AS {stage}" in dockerfile
final_start = dockerfile.index("FROM prepare_package AS final")
final = dockerfile[final_start:]
copies = (
"COPY --link --from=prepare_frontend /public /public",
"COPY --link --from=prepare_plugins /plugins /app/app/plugins",
"COPY --link --from=prepare_resources /resources /app/app/application/site",
"COPY --link --from=prepare_backend /app /app",
)
positions = [final.index(copy) for copy in copies]
assert positions == sorted(positions)
for copy in copies:
assert final.count(copy) == 1
assert "COPY --from=prepare_control /bundle/entrypoint.sh /entrypoint.sh" in final
assert "RUN rm -rf /app/frontend-dist" in dockerfile
def test_release_workflows_pin_and_record_external_payload_identities() -> None:
"""正式与 Beta 构建都必须以真实制品身份驱动缓存并写入镜像标签。"""
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
workflow = _read(workflow_path)
for build_arg in (
"MOVIEPILOT_FRONTEND_VERSION=",
"MOVIEPILOT_FRONTEND_SHA256=",
"MOVIEPILOT_PLUGINS_REF=",
"MOVIEPILOT_RESOURCES_REF=",
):
assert build_arg in workflow
for label in (
"org.moviepilot.source-revision=",
"org.moviepilot.frontend-version=",
"org.moviepilot.frontend-digest=",
"org.moviepilot.plugins-revision=",
"org.moviepilot.resources-revision=",
"org.moviepilot.plugin-market-wiki-revision=",
"org.moviepilot.models-catalog-digest=",
):
assert label in workflow
assert "git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git" in workflow
assert "git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git" in workflow
assert "sha256:*) frontend_sha256=" in workflow
assert "^[0-9a-f]{40}$" in workflow
assert "^[0-9a-f]{64}$" in workflow
def test_same_version_rebuild_identity_is_not_derived_from_image_tag() -> None:
"""重复发布同一版本时,缓存身份必须来自源码和制品而不是镜像 Tag。"""
release_workflow = _read(RELEASE_WORKFLOW)
assert "SOURCE_COMMIT=$(git rev-parse HEAD)" in release_workflow
assert "org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}" in release_workflow
assert (
"org.moviepilot.release-snapshot-revision="
"${{ steps.release_snapshot.outputs.release_commit }}" in release_workflow
)
assert "type=raw,value=${{ env.app_version }}" in release_workflow
assert "MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}" in release_workflow
assert "MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}" in release_workflow
def test_custom_frontend_directory_is_stable_but_artifacts_remain_untracked() -> None:
"""干净 checkout 可直接构建,自定义前端产物仍保持本地状态。"""
gitignore = _read(ROOT / ".gitignore")
dockerfile = _read(DOCKERFILE)
assert (ROOT / "frontend-dist" / ".gitkeep").is_file()
assert "frontend-dist/*" in gitignore
assert "!frontend-dist/.gitkeep" in gitignore
assert "COPY frontend-dist/ /tmp/frontend-dist/" in dockerfile
assert "! -name '.gitkeep'" in dockerfile