Merge pull request #6486 from InfinityPacer/codex/chore/floating-build-toolchain

This commit is contained in:
jxxghp
2026-08-28 11:01:15 +08:00
committed by GitHub
23 changed files with 459 additions and 162 deletions
+3 -4
View File
@@ -20,21 +20,20 @@ jobs:
steps:
- name: Checkout MoviePilot
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
path: MoviePilot
- name: Checkout MoviePilot-Plugins
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
repository: jxxghp/MoviePilot-Plugins
ref: main
path: MoviePilot-Plugins
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
+25 -27
View File
@@ -15,12 +15,10 @@ jobs:
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
uses: astral-sh/setup-uv@v10.0.1
- name: Verify dependency lock
run: uv lock --check
@@ -29,7 +27,7 @@ jobs:
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==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
@@ -40,7 +38,7 @@ jobs:
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
@@ -78,7 +76,7 @@ jobs:
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
@@ -113,7 +111,7 @@ jobs:
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
@@ -123,7 +121,7 @@ jobs:
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
@@ -132,13 +130,13 @@ jobs:
type=raw,value=beta
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build standard amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -157,10 +155,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan standard amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-amd64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -170,7 +168,7 @@ jobs:
exit-code: 1
- name: Build standard arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -189,10 +187,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan standard arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-arm64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -202,7 +200,7 @@ jobs:
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -221,10 +219,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -234,7 +232,7 @@ jobs:
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -253,10 +251,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -266,20 +264,20 @@ jobs:
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish standard multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -309,7 +307,7 @@ jobs:
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
+83 -60
View File
@@ -20,15 +20,13 @@ jobs:
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
uses: astral-sh/setup-uv@v10.0.1
- name: Verify dependency lock
run: uv lock --check
@@ -37,7 +35,7 @@ jobs:
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==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
@@ -48,7 +46,7 @@ jobs:
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
@@ -86,7 +84,7 @@ jobs:
echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT"
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
@@ -134,7 +132,7 @@ jobs:
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
@@ -144,7 +142,7 @@ jobs:
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
@@ -153,13 +151,13 @@ jobs:
type=raw,value=${{ env.app_version }}
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -178,10 +176,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-amd64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -191,7 +189,7 @@ jobs:
exit-code: 1
- name: Build arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -210,10 +208,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-arm64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -223,7 +221,7 @@ jobs:
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -242,10 +240,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -255,7 +253,7 @@ jobs:
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -274,10 +272,10 @@ jobs:
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
version: latest
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
@@ -287,20 +285,20 @@ jobs:
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login GitHub Container Registry
uses: docker/login-action@v3
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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -332,7 +330,7 @@ jobs:
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -481,52 +479,77 @@ jobs:
- name: Get existing release body
id: get_release_body
continue-on-error: true
env:
CHANGELOG: ${{ env.CHANGELOG }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
jq -r '.body // ""')
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"
# 如果已有手动编写的 release body,则保留;否则使用自动生成的 changelog
if [ -n "$release_body" ] && [ "$release_body" != "null" ] && [ "$release_body" != "" ]; then
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$release_body" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
{
echo "RELEASE_BODY<<EOF"
echo "$release_body"
echo "EOF"
} >> "$GITHUB_ENV"
else
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
echo "$CHANGELOG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
{
echo "RELEASE_BODY<<EOF"
echo "$CHANGELOG"
echo "EOF"
} >> "$GITHUB_ENV"
fi
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: v${{ env.app_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release Tag
env:
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
run: |
tag_name="v${{ env.app_version }}"
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
git tag -d "$tag_name"
fi
git tag "$tag_name" "$RELEASE_COMMIT"
git push origin "refs/tags/${tag_name}"
git tag -f "$tag_name" "$RELEASE_COMMIT"
git push --force origin "refs/tags/${tag_name}"
- name: Generate Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.app_version }}
name: v${{ env.app_version }}
body: ${{ env.RELEASE_BODY }}
draft: false
prerelease: false
make_latest: true
- name: Publish Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
+13 -8
View File
@@ -71,12 +71,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: |
@@ -135,13 +134,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@v4
- name: Build locked dependency stage
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
@@ -177,7 +176,13 @@ jobs:
assert not expected_free_threaded or callable(moviepilot_rust.zhconv_fast);
assert sys._is_gil_enabled() == (not expected_free_threaded)"
- name: Verify pinned uv version
- name: Verify minimum uv version
env:
IMAGE_TAG: ${{ matrix.image-tag }}
run: docker run --rm "${IMAGE_TAG}" uv --version | grep -F 'uv 0.12.5'
run: >-
docker run --rm "${IMAGE_TAG}" /opt/venv/bin/python -c
"from packaging.version import Version;
import subprocess;
version = subprocess.check_output(['uv', '--version'], text=True).split()[1];
assert Version(version) >= Version('0.12.5'), version;
print(version)"
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
@@ -74,7 +74,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const labelRules = [
@@ -139,7 +139,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
- uses: actions/stale@v11
with:
# 标记 stale 标签时间
days-before-issue-stale: 30
+2 -3
View File
@@ -23,14 +23,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
+2 -3
View File
@@ -47,14 +47,13 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
enable-cache: true
cache-dependency-glob: scripts/site_adapter_collector_requirements.txt
+6 -9
View File
@@ -26,12 +26,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
@@ -101,12 +100,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
@@ -127,12 +125,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@v10.0.1
with:
version: '0.12.5'
python-version: '3.14'
enable-cache: true
cache-dependency-glob: |
+1 -1
View File
@@ -3,7 +3,7 @@
ARG MOVIEPILOT_PYTHON_VARIANT="standard"
ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
FROM ghcr.io/astral-sh/uv:latest AS uv
FROM rclone/rclone:1.75.0@sha256:b06aed988cf5967de7c25be5925240983981c757f4ed1ac9d2fa659d51d60548 AS rclone
+1 -1
View File
@@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 841 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| Ruff 历史诊断 | 840 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.39%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
+1 -1
View File
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 841 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 840 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+3 -3
View File
@@ -11,7 +11,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
脚本会自动:
- 检测操作系统
- 自动检查并尽量安装 `git``curl``uv 0.12.5``Python 3.14+`
- 自动检查并尽量安装 `git``curl``uv 0.12.5+``Python 3.14+`
- 克隆 `MoviePilot`
- 安装后端依赖
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
@@ -25,7 +25,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
说明:
- 如果系统里已经有可用的 `Python 3.14+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用解释器,脚本会通过固定版本的 uv 安装 Python 3.14
- 如果系统里没有可用解释器,脚本会通过最新稳定版 uv 安装 Python 3.14
- Linux 下安装系统依赖时通常需要 `sudo`
- 复用已有仓库时,脚本现在只会因为已跟踪源码改动而阻止自动更新,不会再被 `.DS_Store` 之类未跟踪文件卡住
@@ -165,7 +165,7 @@ moviepilot install deps --config-dir /path/to/moviepilot-config
说明:
- 默认会自动选择本地已安装的 `Python 3.14+` 解释器
- 安装器要求 `uv 0.12.5`,并按仓库提交的 `uv.lock` 同步依赖;不会在本地重新解析一套未锁定结果
- 安装器要求 `uv 0.12.5+`,并按仓库提交的 `uv.lock` 同步依赖;不会在本地重新解析一套未锁定结果
- `moviepilot_rust` 加速扩展通过 `moviepilot-rust` PyPI 依赖安装,主项目本地安装不需要 Rust toolchain
- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
+4 -4
View File
@@ -7,7 +7,7 @@
在开始之前,请确保您的系统已安装以下软件:
- **Python 3.14+**
- **uv 0.12.5**Python 版本、虚拟环境和依赖锁定工具)
- **uv 0.12.5+**(Python 版本、虚拟环境和依赖锁定工具,推荐使用最新稳定版
- **Git** (用于版本控制)
- **RAR 解压工具**:本地开发如需测试或使用 `.rar` 字幕包解压,请安装 `unar``unrar``7z``bsdtar` 之一;Docker 镜像会内置 `unar`
@@ -90,7 +90,7 @@ chmod +x scripts/start-local.sh
1. **共享运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`
2. **ABI 敏感运行依赖**:标准与 free-threaded 解释器必须选择不同制品或版本时,分别写入 `runtime-standard``runtime-free-threaded`;两组保持互斥并由运行时统一选择。
3. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`
4. **工具依赖**:仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖。
4. **工具依赖**:仓库要求使用 `uv 0.12.5+`,推荐使用最新稳定版;不应为了安装工具而把它加入主程序运行依赖。
5. **插件依赖**:由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖。
修改后更新并校验锁文件:
@@ -169,13 +169,13 @@ python -m scripts.generate_plugin_market_default \
### 5. 运行依赖漏洞检查
正式发布会使用固定版本的 `pip-audit` 检查 `uv.lock` 锁定的运行时依赖。依赖变更后也可以在
正式发布会使用最新稳定版 `pip-audit` 检查 `uv.lock` 锁定的运行时依赖。依赖变更后也可以在
本地执行同一检查:
```bash
uv export --quiet --locked --no-dev --no-emit-project \
--output-file /tmp/moviepilot-audit-requirements.txt
uvx --from pip-audit==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-requirements.txt
```
+2 -2
View File
@@ -107,7 +107,7 @@
|---|---|
| Project metadata | `pyproject.toml` — runtime dependencies in `[project].dependencies`, development tooling in `[dependency-groups].dev` |
| Lock | `uv.lock` — committed resolution for Python 3.14+ and supported platforms |
| Package manager | uv 0.12.5 |
| Package manager | uv 0.12.5+(推荐最新稳定版) |
| Runtime install | `uv sync --locked --no-dev --no-install-project` |
| Dev/test/lint/build install | `uv sync --locked` |
| Supported platforms | Linux x86_64/arm64, macOS x86_64/arm64, Windows x64 |
@@ -132,7 +132,7 @@
| pytest | Test runner | `uv run --locked --no-sync pytest tests/test_xxx.py` |
| pylint | Static analysis | `uv run --locked --no-sync pylint app/` |
| uv | Lock and environment consistency | `uv lock --check && uv sync --locked --offline --inexact --no-dev --check` |
| pip-audit | Locked dependency vulnerability scan | `uv export --quiet --locked --no-dev --no-emit-project -o /tmp/moviepilot-audit-requirements.txt && uvx --from pip-audit==2.10.1 pip-audit --require-hashes --disable-pip --strict --progress-spinner off -r /tmp/moviepilot-audit-requirements.txt` |
| pip-audit | Locked dependency vulnerability scan | `uv export --quiet --locked --no-dev --no-emit-project -o /tmp/moviepilot-audit-requirements.txt && uvx --from pip-audit pip-audit --require-hashes --disable-pip --strict --progress-spinner off -r /tmp/moviepilot-audit-requirements.txt` |
---
+2 -2
View File
@@ -34,7 +34,7 @@ uv sync --locked --offline --inexact --no-dev --check
- Test, coverage, lint, and explicit build tooling belong in `[dependency-groups].dev`.
- Commit the updated `uv.lock`; do not maintain or generate main-program requirements files.
- `uv pip check` is diagnostic only because unmaintained third-party metadata may name a compatible superseded distribution.
- Use uv 0.12.5 and Python 3.14+.
- Use uv 0.12.5+ and Python 3.14+; prefer the latest stable uv release.
---
@@ -82,7 +82,7 @@ uv run --locked --no-sync pylint app/chain/download.py
```bash
uv export --quiet --locked --no-dev --no-emit-project \
--output-file /tmp/moviepilot-audit-requirements.txt
uvx --from pip-audit==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-requirements.txt
```
+1 -1
View File
@@ -56,7 +56,7 @@ uv run --locked --no-sync pylint app/
```bash
uv export --quiet --locked --no-dev --no-emit-project \
--output-file /tmp/moviepilot-audit-requirements.txt
uvx --from pip-audit==2.10.1 pip-audit \
uvx --from pip-audit pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-requirements.txt
```
+1 -1
View File
@@ -135,7 +135,7 @@ select = ["E4", "E7", "E9", "F", "I"]
[tool.uv]
package = false
required-version = "==0.12.5"
required-version = ">=0.12.5"
default-groups = ["dev", "runtime-standard"]
conflicts = [
[
+29 -6
View File
@@ -16,7 +16,7 @@ SUPERUSER=""
SUPERUSER_PASSWORD=""
OS_NAME="Unknown"
PYTHON_BIN=""
UV_VERSION="0.12.5"
MIN_UV_VERSION="0.12.5"
BREW_BIN=""
PACKAGE_MANAGER=""
PACKAGE_INDEX_UPDATED="false"
@@ -384,20 +384,43 @@ ensure_base_tools() {
fi
}
uv_version_supported() {
local actual="$1"
local minimum="$2"
local -a actual_parts minimum_parts
local index actual_part minimum_part
IFS=. read -r -a actual_parts <<< "$actual"
IFS=. read -r -a minimum_parts <<< "$minimum"
for index in 0 1 2; do
actual_part="${actual_parts[$index]:-0}"
minimum_part="${minimum_parts[$index]:-0}"
[[ "$actual_part" =~ ^[0-9]+$ ]] || return 1
[[ "$minimum_part" =~ ^[0-9]+$ ]] || return 1
if (( 10#$actual_part > 10#$minimum_part )); then
return 0
fi
if (( 10#$actual_part < 10#$minimum_part )); then
return 1
fi
done
return 0
}
ensure_uv() {
if command -v uv >/dev/null 2>&1 \
&& [[ "$(uv --version 2>/dev/null | awk '{print $2}')" == "${UV_VERSION}" ]]; then
&& uv_version_supported "$(uv --version 2>/dev/null | awk '{print $2}')" "${MIN_UV_VERSION}"; then
return 0
fi
echo "==> 自动安装 uv ${UV_VERSION}"
env UV_INSTALL_DIR="$HOME/.local/bin" sh -c "$(curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh")"
echo "==> 自动安装最新稳定版 uv"
env UV_INSTALL_DIR="$HOME/.local/bin" sh -c "$(curl -LsSf https://astral.sh/uv/install.sh)"
export PATH="$HOME/.local/bin:$PATH"
hash -r
if ! command -v uv >/dev/null 2>&1 \
|| [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then
echo "uv ${UV_VERSION} 安装失败,无法继续自动安装 Python。" >&2
|| ! uv_version_supported "$(uv --version 2>/dev/null | awk '{print $2}')" "${MIN_UV_VERSION}"; then
echo "uv ${MIN_UV_VERSION}+ 安装失败,无法继续自动安装 Python。" >&2
return 1
fi
}
+16 -6
View File
@@ -9,8 +9,8 @@ import importlib.util
import json
import os
import platform
import secrets
import re
import secrets
import shlex
import shutil
import subprocess
@@ -36,7 +36,8 @@ NODE_DIR = RUNTIME_DIR / "node"
INSTALL_ENV_FILE = ROOT / ".moviepilot.env"
MIN_PYTHON_VERSION = (3, 14)
SUPPORTED_PYTHON_TEXT = "Python 3.14+"
UV_VERSION = "0.12.5"
MIN_UV_VERSION = (0, 12, 5)
MIN_UV_VERSION_TEXT = ".".join(str(part) for part in MIN_UV_VERSION)
CONFIG_DIR = LEGACY_CONFIG_DIR
LOG_DIR = CONFIG_DIR / "logs"
@@ -618,18 +619,27 @@ def get_venv_bin_dir(venv_dir: Path) -> Path:
return venv_dir / "bin"
def parse_uv_version(output: str) -> tuple[int, int, int] | None:
"""从 uv 版本输出中提取稳定版三段版本号。"""
match = re.match(r"^uv\s+(\d+)\.(\d+)\.(\d+)(?:\s|$)", output.strip())
if not match:
return None
return tuple(int(part) for part in match.groups())
def require_uv() -> Path:
"""返回仓库要求版本的 uv,避免不同安装入口使用不同解析器"""
"""返回满足仓库最低版本要求的 uv"""
uv_command = shutil.which("uv")
if not uv_command:
raise RuntimeError(
f"未找到 uv {UV_VERSION},请先安装后重新执行。"
f"未找到 uv {MIN_UV_VERSION_TEXT}+,请先安装后重新执行。"
)
uv_bin = Path(uv_command).expanduser().resolve()
version = capture([str(uv_bin), "--version"])
if version.split()[:2] != ["uv", UV_VERSION]:
parsed_version = parse_uv_version(version)
if parsed_version is None or parsed_version < MIN_UV_VERSION:
raise RuntimeError(
f"MoviePilot 需要 uv {UV_VERSION},当前为 {version or '未知版本'}"
f"MoviePilot 需要 uv {MIN_UV_VERSION_TEXT}+,当前为 {version or '未知版本'}"
)
return uv_bin
-3
View File
@@ -1484,9 +1484,6 @@
"tests/test_release_group.py": {
"I001": 1
},
"tests/test_release_supply_chain.py": {
"I001": 1
},
"tests/test_reliability_adr.py": {
"I001": 1
},
+1 -5
View File
@@ -30,11 +30,7 @@ def _write_bundle(path: Path, label: str, *, extra_files: tuple[str, ...] = ())
def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
assert (
"FROM ghcr.io/astral-sh/uv:0.12.5@sha256:"
"e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv"
in dockerfile
)
assert "FROM ghcr.io/astral-sh/uv:latest AS uv" in dockerfile
assert "COPY --from=uv /uv /usr/local/bin/uv" in dockerfile
assert "COPY pyproject.toml uv.lock ./" in dockerfile
assert "python3 -m venv --without-pip ${VENV_PATH}" in dockerfile
+22 -2
View File
@@ -217,17 +217,37 @@ class LocalSetupConfigDirTests(unittest.TestCase):
self.assertIn("https://mirror.example/simple?token=abc", redacted)
self.assertNotIn("user:pass", " ".join(redacted))
def test_require_uv_accepts_repository_version(self):
def test_require_uv_accepts_minimum_version(self):
module = load_local_setup_module()
uv_bin = Path("/opt/moviepilot/bin/uv")
with patch.object(module.shutil, "which", return_value=str(uv_bin)), patch.object(
module, "capture", return_value=f"uv {module.UV_VERSION} (test-target)"
module, "capture", return_value=f"uv {module.MIN_UV_VERSION_TEXT} (test-target)"
):
result = module.require_uv()
self.assertEqual(result, uv_bin.resolve())
def test_require_uv_accepts_newer_version(self):
module = load_local_setup_module()
uv_bin = Path("/opt/moviepilot/bin/uv")
with patch.object(module.shutil, "which", return_value=str(uv_bin)), patch.object(
module, "capture", return_value="uv 1.0.0 (test-target)"
):
result = module.require_uv()
self.assertEqual(result, uv_bin.resolve())
def test_require_uv_rejects_older_version(self):
module = load_local_setup_module()
uv_bin = Path("/opt/moviepilot/bin/uv")
with patch.object(module.shutil, "which", return_value=str(uv_bin)), patch.object(
module, "capture", return_value="uv 0.12.4 (test-target)"
), self.assertRaisesRegex(RuntimeError, "需要 uv 0.12.5\\+"):
module.require_uv()
def test_windows_expose_uv_keeps_existing_source_when_target_is_same(self):
module = load_local_setup_module()
+238 -7
View File
@@ -1,5 +1,8 @@
"""正式镜像发布的供应链门禁合同。"""
import os
import shutil
import subprocess
from datetime import date
from pathlib import Path
@@ -8,12 +11,30 @@ 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"
TRIVY_IGNORE = ROOT / ".trivyignore.yaml"
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",
"aquasecurity/trivy-action@v0.36.0",
"actions/upload-artifact@v7",
"actions/download-artifact@v8",
"docker://ghcr.io/infinitypacer/pr-review-runner:latest",
}
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
@@ -31,6 +52,66 @@ def _steps_by_name(workflow: dict) -> dict[str, dict]:
}
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"),
"CHANGELOG": "generated changelog",
}
)
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_apt_does_not_upgrade_in_place() -> None:
"""基础镜像允许更新,并仅显式刷新运行时安全包而非整套 Debian。"""
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
@@ -62,7 +143,8 @@ def test_release_audits_locked_runtime_dependencies_before_building() -> None:
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
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
@@ -142,12 +224,9 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
):
scan = indexed[name]
assert scan["with"]["cache-dir"] == "${{ runner.temp }}/trivy"
assert scan["uses"] == (
"aquasecurity/trivy-action@"
"a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8"
)
assert scan["uses"] == "aquasecurity/trivy-action@v0.36.0"
assert scan["with"].items() >= {
"version": "v0.70.0",
"version": "latest",
"scanners": "vuln",
"vuln-type": "os,library",
"severity": "HIGH,CRITICAL",
@@ -170,6 +249,158 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
assert last_scan < names.index("Publish multi-architecture image")
assert last_scan < names.index("Publish free-threaded multi-architecture image")
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_pr_agent_keeps_pull_request_target_api_only_boundary() -> None:
"""带凭据的 PR 审查只读 GitHub API,不 checkout 或执行 PR 分支代码。"""
workflow = _load_workflow(PR_AGENT_WORKFLOW)
assert "pull_request_target" in workflow["on"]
assert workflow["permissions"] == {
"contents": "read",
"pull-requests": "write",
"issues": "write",
}
steps = workflow["jobs"]["pr-agent"]["steps"]
assert len(steps) == 1
review_step = steps[0]
assert review_step["uses"] == "docker://ghcr.io/infinitypacer/pr-review-runner:latest"
assert "run" not in review_step
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 404 Not Found\n\n", 1, "false", "generated changelog"),
],
)
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
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_vulnerability_ignores_are_scoped_justified_and_time_bounded() -> None:
"""漏洞豁免必须限定制品范围,并保留复查期限和接受理由。"""
yaml = YAML(typ="safe")