mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
397 lines
17 KiB
YAML
397 lines
17 KiB
YAML
name: MoviePilot Builder v3
|
||
on:
|
||
workflow_dispatch:
|
||
push:
|
||
branches:
|
||
- v3
|
||
paths:
|
||
- 'version.py'
|
||
|
||
permissions:
|
||
contents: write
|
||
packages: write
|
||
|
||
jobs:
|
||
release-gate:
|
||
runs-on: ubuntu-latest
|
||
outputs:
|
||
publish: ${{ steps.detect.outputs.publish }}
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v7
|
||
with:
|
||
fetch-depth: 0
|
||
|
||
- name: Detect version change
|
||
id: detect
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ github.event.before }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
|
||
versions() {
|
||
sed -nE "s/^(APP_VERSION|FRONTEND_VERSION)[[:space:]]*=[[:space:]]*'([^']+)'[[:space:]]*$/\1=\2/p" | sort
|
||
}
|
||
previous=$(git show "$BEFORE_SHA:version.py" | versions)
|
||
current=$(versions < version.py)
|
||
for value in APP_VERSION FRONTEND_VERSION; do
|
||
if ! printf '%s\n' "$previous" | grep -q "^${value}=" ||
|
||
! printf '%s\n' "$current" | grep -q "^${value}="; then
|
||
echo "版本声明缺失: $value" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
if [ "$previous" = "$current" ]; then
|
||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||
else
|
||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||
fi
|
||
|
||
Docker-build:
|
||
needs: release-gate
|
||
if: needs.release-gate.outputs.publish == 'true'
|
||
runs-on: ubuntu-latest
|
||
name: Build Docker Image
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v7
|
||
with:
|
||
fetch-depth: 0
|
||
fetch-tags: true
|
||
|
||
- name: Set up uv
|
||
uses: astral-sh/setup-uv@v10.0.1
|
||
|
||
- name: Verify dependency lock
|
||
run: uv lock --check
|
||
|
||
- name: Audit locked Python dependencies
|
||
run: |
|
||
uv export --quiet --locked --no-default-groups --group runtime-standard \
|
||
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
|
||
uvx --from pip-audit pip-audit \
|
||
--require-hashes --disable-pip --strict --progress-spinner off \
|
||
--requirement /tmp/moviepilot-audit-standard.txt
|
||
# PyPI 服务校验哈希但公告覆盖不全(曾漏报 Mako PYSEC-2026-4243),再用 OSV 补查一遍。
|
||
uvx --from pip-audit pip-audit \
|
||
--vulnerability-service osv --require-hashes --disable-pip --strict --progress-spinner off \
|
||
--requirement /tmp/moviepilot-audit-standard.txt
|
||
|
||
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
|
||
--no-emit-project --no-hashes \
|
||
--output-file /tmp/moviepilot-audit-free-threaded.txt
|
||
python3 scripts/normalize_audit_requirements.py \
|
||
--lock uv.lock \
|
||
--input /tmp/moviepilot-audit-free-threaded.txt \
|
||
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
|
||
uvx --from pip-audit pip-audit \
|
||
--no-deps --disable-pip --strict --progress-spinner off \
|
||
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
|
||
uvx --from pip-audit pip-audit \
|
||
--vulnerability-service osv --no-deps --disable-pip --strict --progress-spinner off \
|
||
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
|
||
|
||
- name: Release version
|
||
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_release_id=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \
|
||
--jq '.id')
|
||
[[ "$frontend_release_id" =~ ^[0-9]+$ ]]
|
||
frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/${frontend_release_id}/assets" \
|
||
--jq '.[] | 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@v7
|
||
with:
|
||
repository: jxxghp/MoviePilot-Wiki
|
||
ref: main
|
||
path: .build/moviepilot-wiki
|
||
sparse-checkout: plugin.md
|
||
sparse-checkout-cone-mode: false
|
||
persist-credentials: false
|
||
|
||
- name: Generate Plugin Market Default
|
||
id: plugin_market
|
||
run: |
|
||
python3 -m scripts.generate_plugin_market_default \
|
||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||
--config-file app/runtime/config.py
|
||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Download models.dev catalog
|
||
id: models_catalog
|
||
run: |
|
||
temp_file=$(mktemp app/agent/llm/resources/models.json.XXXXXX)
|
||
trap 'rm -f "$temp_file"' EXIT
|
||
curl --fail --show-error --silent --location --retry 3 \
|
||
--connect-timeout 10 --max-time 120 \
|
||
"https://models.dev/api.json" -o "$temp_file"
|
||
jq -e 'type == "object"' "$temp_file" >/dev/null
|
||
# 仓库仅保留少量占位数据,发布镜像构建时下载最新模型清单。
|
||
chmod 644 "$temp_file"
|
||
mv "$temp_file" app/agent/llm/resources/models.json
|
||
echo "digest=sha256:$(sha256sum app/agent/llm/resources/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||
echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/resources/models.json) bytes)"
|
||
|
||
- name: Create Release Snapshot
|
||
id: release_snapshot
|
||
env:
|
||
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
|
||
run: |
|
||
git config user.name "github-actions[bot]"
|
||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||
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
|
||
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Resolve System Refresh Key
|
||
id: system_refresh
|
||
run: |
|
||
# 刷新键决定镜像末尾系统安全更新层何时重建:每 ISO 周最多一次,
|
||
# docker/system-baseline 变化时立即重建。计算失败只降级为基线值,不阻断发布。
|
||
baseline=$(tr -d '[:space:]' < docker/system-baseline 2>/dev/null || true)
|
||
if ! [[ "$baseline" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||
echo "::warning::docker/system-baseline 缺失或格式无效,系统基线记为 unknown"
|
||
baseline=unknown
|
||
fi
|
||
week=$(date -u +%G-W%V 2>/dev/null || true)
|
||
if [[ "$week" =~ ^[0-9]{4}-W[0-9]{2}$ ]]; then
|
||
key="${week}-${baseline}"
|
||
else
|
||
echo "::warning::无法计算 ISO 周,系统刷新键仅使用系统基线"
|
||
key="${baseline}"
|
||
fi
|
||
echo "System refresh key: ${key}"
|
||
echo "baseline=${baseline}" >> "$GITHUB_OUTPUT"
|
||
echo "key=${key}" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Docker Meta
|
||
id: meta
|
||
uses: docker/metadata-action@v6
|
||
with:
|
||
images: |
|
||
${{ secrets.DOCKER_USERNAME }}/moviepilot
|
||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
|
||
ghcr.io/${{ github.repository }}
|
||
tags: |
|
||
type=raw,value=${{ env.app_version }}
|
||
type=raw,value=latest
|
||
|
||
- name: Docker Meta free-threaded
|
||
id: meta_ft
|
||
uses: docker/metadata-action@v6
|
||
with:
|
||
images: |
|
||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
|
||
ghcr.io/${{ github.repository }}-v3t
|
||
tags: |
|
||
type=raw,value=${{ env.app_version }}
|
||
type=raw,value=latest
|
||
|
||
- name: Set Up QEMU
|
||
uses: docker/setup-qemu-action@v4
|
||
|
||
- name: Set Up Buildx
|
||
uses: docker/setup-buildx-action@v4
|
||
|
||
- name: Login DockerHub
|
||
uses: docker/login-action@v4
|
||
with:
|
||
username: ${{ secrets.DOCKER_USERNAME }}
|
||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||
|
||
- name: Login GitHub Container Registry
|
||
uses: docker/login-action@v4
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Publish multi-architecture image
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: .
|
||
file: docker/Dockerfile
|
||
platforms: |
|
||
linux/amd64
|
||
linux/arm64/v8
|
||
push: true
|
||
pull: 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 }}
|
||
MOVIEPILOT_PYTHON_VARIANT=standard
|
||
MOVIEPILOT_SYSTEM_REFRESH_ID=${{ steps.system_refresh.outputs.key }}
|
||
labels: |
|
||
${{ steps.meta.outputs.labels }}
|
||
org.moviepilot.system-baseline=${{ steps.system_refresh.outputs.baseline }}
|
||
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-standard-docker,version=2
|
||
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
|
||
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
|
||
cache-to: type=gha,scope=moviepilot-v3-standard-docker,mode=max,version=2
|
||
|
||
- name: Publish free-threaded multi-architecture image
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: .
|
||
file: docker/Dockerfile
|
||
platforms: |
|
||
linux/amd64
|
||
linux/arm64/v8
|
||
push: true
|
||
pull: true
|
||
tags: ${{ steps.meta_ft.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 }}
|
||
MOVIEPILOT_PYTHON_VARIANT=free-threaded
|
||
MOVIEPILOT_SYSTEM_REFRESH_ID=${{ steps.system_refresh.outputs.key }}
|
||
labels: |
|
||
${{ steps.meta_ft.outputs.labels }}
|
||
org.moviepilot.system-baseline=${{ steps.system_refresh.outputs.baseline }}
|
||
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-v3t-docker,version=2
|
||
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
|
||
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
|
||
cache-to: type=gha,scope=moviepilot-v3t-docker,mode=max,version=2
|
||
|
||
- name: Get existing release body
|
||
id: get_release_body
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
tag_name="v${{ env.app_version }}"
|
||
response_file=$(mktemp)
|
||
error_file=$(mktemp)
|
||
trap 'rm -f "$response_file" "$error_file"' EXIT
|
||
|
||
if gh api --include \
|
||
"repos/${GITHUB_REPOSITORY}/releases/tags/${tag_name}" \
|
||
--jq '.body' > "$response_file" 2> "$error_file"; then
|
||
release_exists=true
|
||
release_body=$(awk 'body { sub(/\r$/, ""); print } /^[[:space:]]*$/ { body=1 }' "$response_file")
|
||
else
|
||
status_code=$(awk 'NR == 1 { print $2 }' "$response_file")
|
||
if [ "$status_code" = "404" ]; then
|
||
release_exists=false
|
||
release_body=""
|
||
else
|
||
cat "$error_file" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
echo "exists=$release_exists" >> "$GITHUB_OUTPUT"
|
||
|
||
# 随机分隔符避免手工正文中的 EOF 等独立行截断工作流环境变量。
|
||
delimiter="RELEASE_NOTES_$(python3 -c 'import uuid; print(uuid.uuid4().hex)')"
|
||
if [ "$release_body" = "null" ]; then
|
||
release_body=""
|
||
fi
|
||
{
|
||
printf 'RELEASE_BODY<<%s\n' "$delimiter"
|
||
printf '%s\n' "$release_body"
|
||
printf '%s\n' "$delimiter"
|
||
} >> "$GITHUB_ENV"
|
||
|
||
- name: Generate Release Notes
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
python3 scripts/release_notes.py \
|
||
--repository "$GITHUB_REPOSITORY" \
|
||
--tag "v${{ env.app_version }}" \
|
||
--source "$SOURCE_COMMIT"
|
||
|
||
- name: Publish Release Tag
|
||
env:
|
||
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
|
||
run: |
|
||
tag_name="v${{ env.app_version }}"
|
||
git tag -f "$tag_name" "$RELEASE_COMMIT"
|
||
git push --force origin "refs/tags/${tag_name}"
|
||
|
||
- name: Publish Release
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
RELEASE_BODY: ${{ env.RELEASE_BODY }}
|
||
RELEASE_EXISTS: ${{ steps.get_release_body.outputs.exists }}
|
||
run: |
|
||
tag_name="v${{ env.app_version }}"
|
||
notes_file=$(mktemp)
|
||
trap 'rm -f "$notes_file"' EXIT
|
||
printf '%s\n' "$RELEASE_BODY" > "$notes_file"
|
||
if [ "$RELEASE_EXISTS" = "true" ]; then
|
||
gh release edit "$tag_name" \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--title "$tag_name" \
|
||
--notes-file "$notes_file" \
|
||
--draft=false \
|
||
--prerelease=false \
|
||
--latest
|
||
else
|
||
gh release create "$tag_name" \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--title "$tag_name" \
|
||
--notes-file "$notes_file" \
|
||
--latest
|
||
fi
|