diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index 0d7611e99..bede1330e 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -12,7 +12,49 @@ permissions: 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: diff --git a/docs/development-setup.md b/docs/development-setup.md index b9818837d..d8e44df23 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -215,6 +215,8 @@ uvx --from pip-audit pip-audit \ Docker 镜像直接基于官方基础镜像构建,只安装应用运行所需的系统依赖;不运行镜像漏洞扫描, 也不为扫描结果刷新系统包、删除基础镜像自带工具或维护漏洞忽略清单。正式版和 Beta 均直接构建 并发布标准版与 free-threaded 版的双架构镜像。应用锁定依赖仍按上述流程审计。 +`v3` 分支自动发布仅在 `version.py` 的 `APP_VERSION` 或 `FRONTEND_VERSION` 值发生变化时运行; +只修改该文件的说明文字不会重建正式镜像。维护者仍可通过手动触发正式发布工作流重建当前版本。 ### 6. Contributor 提交准备 diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index cc6035b1f..01f813505 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -403,6 +403,52 @@ def test_publish_refreshes_base_and_preserves_build_cache() -> None: assert "scope=moviepilot-v3-standard-docker," in publish["cache-to"] +def test_release_gate_only_publishes_changed_versions(tmp_path: Path) -> None: + """ + 自动发布只接受版本值变化,手动重建保留入口。 + """ + workflow = _load_workflow() + gate = workflow["jobs"]["release-gate"] + build = workflow["jobs"]["Docker-build"] + assert build["needs"] == "release-gate" + assert build["if"] == "needs.release-gate.outputs.publish == 'true'" + + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + version_file = repo / "version.py" + version_file.write_text( + "APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n", + encoding="utf-8", + ) + subprocess.run(["git", "-C", str(repo), "add", "version.py"], check=True) + subprocess.run( + ["git", "-C", str(repo), "-c", "user.name=Test", "-c", "user.email=test@example.com", + "commit", "-qm", "base"], + check=True, + ) + before_sha = subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "HEAD"], text=True + ).strip() + command = next(step for step in gate["steps"] if step.get("id") == "detect")["run"] + output_file = tmp_path / "output" + + def detect(event_name: str, content: str) -> str: + version_file.write_text(content, encoding="utf-8") + output_file.write_text("", encoding="utf-8") + subprocess.run( + ["bash", "-e", "-c", command], cwd=repo, check=True, + env={**os.environ, "EVENT_NAME": event_name, "BEFORE_SHA": before_sha, + "GITHUB_OUTPUT": str(output_file)}, + ) + return output_file.read_text(encoding="utf-8").strip() + + assert detect("push", "\"\"\"说明文字\"\"\"\nAPP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=false" + assert detect("push", "APP_VERSION = 'v3.1.7'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=true" + assert detect("push", "APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.7'\n") == "publish=true" + assert detect("workflow_dispatch", "APP_VERSION = 'v3.1.6'\nFRONTEND_VERSION = 'v3.1.6'\n") == "publish=true" + + def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache() -> None: """ free-threaded 发布必须使用 v3t 命名、参数和独立缓存。