Files
MoviePilot/.github/workflows/build-v3.yml
InfinityPacer 9e24ea0c68 ci(release): add OSV pass to dependency audit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:52:54 +08:00

397 lines
17 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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