From 18a0fd3e1cc81b0856ac71722b87939a34822de0 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 28 Aug 2026 02:00:39 +0800 Subject: [PATCH 1/2] chore(workflow): follow stable toolchain channels --- .github/workflows/architecture-observe.yml | 7 +- .github/workflows/beta.yml | 52 ++-- .github/workflows/build-v3.yml | 141 ++++++----- .github/workflows/dependency-compat.yml | 21 +- .github/workflows/issues.yml | 6 +- .github/workflows/pylint.yml | 5 +- .github/workflows/site-adapter-collector.yml | 5 +- .github/workflows/test.yml | 15 +- docker/Dockerfile | 2 +- docs/cli.md | 6 +- docs/development-setup.md | 8 +- docs/rules/02-tech-stack.md | 4 +- docs/rules/03-commands.md | 4 +- docs/rules/11-quality-and-security.md | 2 +- pyproject.toml | 2 +- scripts/bootstrap-local.sh | 35 ++- scripts/local_setup.py | 22 +- .../fixtures/architecture/ruff-baseline.json | 3 - tests/test_docker_bootstrap.py | 6 +- tests/test_local_setup_config_dir.py | 24 +- tests/test_release_supply_chain.py | 236 +++++++++++++++++- 21 files changed, 446 insertions(+), 160 deletions(-) diff --git a/.github/workflows/architecture-observe.yml b/.github/workflows/architecture-observe.yml index 0b0e3a369..f3d09f880 100644 --- a/.github/workflows/architecture-observe.yml +++ b/.github/workflows/architecture-observe.yml @@ -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: | diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index d6dca1fc9..01d2b517a 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -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 diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index 58f45cc81..db8f7dc92 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -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,75 @@ 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<> $GITHUB_ENV - echo "$release_body" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV + { + echo "RELEASE_BODY<> "$GITHUB_ENV" else - echo "RELEASE_BODY<> $GITHUB_ENV - echo "$CHANGELOG" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV + { + echo "RELEASE_BODY<> "$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" \ + --latest + else + gh release create "$tag_name" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$tag_name" \ + --notes-file "$notes_file" \ + --latest + fi diff --git a/.github/workflows/dependency-compat.yml b/.github/workflows/dependency-compat.yml index e043863dc..8c6b47501 100644 --- a/.github/workflows/dependency-compat.yml +++ b/.github/workflows/dependency-compat.yml @@ -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)" diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index c289cfcec..162bc3920 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -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 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 6ad4f97a9..fb77b623d 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -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: | diff --git a/.github/workflows/site-adapter-collector.yml b/.github/workflows/site-adapter-collector.yml index f6f3c3119..3f2f069f9 100644 --- a/.github/workflows/site-adapter-collector.yml +++ b/.github/workflows/site-adapter-collector.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4ca55f10..5294a5fcb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: | diff --git a/docker/Dockerfile b/docker/Dockerfile index 096c7b55b..b399f2168 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 9e4da50a4..cb3acd8e9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 加速;如果后端未加载扩展,该开关会保持关闭且不可操作 diff --git a/docs/development-setup.md b/docs/development-setup.md index 9b9e2a567..9df862f54 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -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 ``` diff --git a/docs/rules/02-tech-stack.md b/docs/rules/02-tech-stack.md index 3363219c4..2451adbe2 100644 --- a/docs/rules/02-tech-stack.md +++ b/docs/rules/02-tech-stack.md @@ -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` | --- diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 053e989d2..eed92f610 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -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 ``` diff --git a/docs/rules/11-quality-and-security.md b/docs/rules/11-quality-and-security.md index 3c833ad1c..abdae30a2 100644 --- a/docs/rules/11-quality-and-security.md +++ b/docs/rules/11-quality-and-security.md @@ -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 ``` diff --git a/pyproject.toml b/pyproject.toml index f223287e7..d094b3803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ [ diff --git a/scripts/bootstrap-local.sh b/scripts/bootstrap-local.sh index ad7253862..baaf56f25 100755 --- a/scripts/bootstrap-local.sh +++ b/scripts/bootstrap-local.sh @@ -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 } diff --git a/scripts/local_setup.py b/scripts/local_setup.py index df21e0320..a4a090812 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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 diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 5e6c9b1c0..f14ec17dc 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -1549,9 +1549,6 @@ "tests/test_release_group.py": { "I001": 1 }, - "tests/test_release_supply_chain.py": { - "I001": 1 - }, "tests/test_reliability_adr.py": { "I001": 1 }, diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index dba10dbf0..d52f2aefd 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -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 diff --git a/tests/test_local_setup_config_dir.py b/tests/test_local_setup_config_dir.py index b14446c67..f2ff53bd6 100644 --- a/tests/test_local_setup_config_dir.py +++ b/tests/test_local_setup_config_dir.py @@ -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() diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index 2b5db8fb3..585d08577 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -1,5 +1,7 @@ """正式镜像发布的供应链门禁合同。""" +import os +import subprocess from datetime import date from pathlib import Path @@ -8,12 +10,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 +51,63 @@ 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 脚本。""" + 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( + ["/bin/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 +139,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 +220,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 +245,153 @@ 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', '')}: {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 "--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 + + +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") From 48af80c4ef199c82c239fa8100eb997c27948a31 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Fri, 28 Aug 2026 10:42:26 +0800 Subject: [PATCH 2/2] fix(workflow): preserve formal release state --- .github/workflows/build-v3.yml | 2 ++ docs/architecture-optimization-checklist.md | 2 +- docs/architecture-refactor-roadmap.md | 2 +- tests/test_release_supply_chain.py | 11 ++++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index db8f7dc92..d68dc963d 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -543,6 +543,8 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --title "$tag_name" \ --notes-file "$notes_file" \ + --draft=false \ + --prerelease=false \ --latest else gh release create "$tag_name" \ diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index bfccb5daa..d4e75c6c1 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -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 历史诊断 | 869 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 868 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 79.02%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index b3468884c..502618119 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -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 | 当前受控 869 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 868 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index 585d08577..b0f2324e6 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -1,6 +1,7 @@ """正式镜像发布的供应链门禁合同。""" import os +import shutil import subprocess from datetime import date from pathlib import Path @@ -79,6 +80,9 @@ def _run_release_script( 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") @@ -99,7 +103,7 @@ def _run_release_script( ) env.update(extra_env or {}) return subprocess.run( - ["/bin/bash", "-euo", "pipefail", "-c", script], + [bash, "-euo", "pipefail", "-c", script], cwd=tmp_path, env=env, capture_output=True, @@ -305,6 +309,8 @@ def test_release_uses_github_cli_for_tag_and_release_lifecycle() -> None: 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") @@ -378,6 +384,9 @@ def test_release_publish_selects_edit_or_create( 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: