From 3f24a33e3dc85558309e8d2c002da51bbd84a94f Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 16:43:44 +0800 Subject: [PATCH 01/12] =?UTF-8?q?ci(release):=20=E5=BB=BA=E8=AE=BE=20SDK?= =?UTF-8?q?=20=E8=87=AA=E6=89=98=E7=AE=A1=E8=87=AA=E5=8A=A8=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交建立由自有 Runner 执行、GitHub Environment 人工批准、PyPI OIDC Trusted Publishing 发布的 SDK CI/CD 基础能力。 主要改动: - 拆分 sdk-ci 与 release 工作流,覆盖 Python 3.10-3.12、最低/最新依赖、BLE、pytest、mypy 与包安装验证 - wheel 和 sdist 仅构建一次,生成 SHA-256 清单,并以同一 Artifact 依次发布 TestPyPI 与 PyPI - 增加版本 PR 准备、PEP 440 版本计算、索引冲突检查、main 祖先和版本 PR 标签门禁 - 正式发布必须进入 pypi Environment 等待负责人审批,不保存长期 PyPI Token - 将陆骁 PR Review 切换到隔离的 sdk-ci Runner,并移除旧 publish.yml 重复发布入口 - 更新中文发布说明和 CHANGELOG,补充自动发布回归测试 验证结果: - 全量 pytest 通过,2 项按预期跳过 - mypy 检查 86 个源码文件通过 - 发布工具与工作流契约测试通过 - git diff --check 通过 --- .github/workflows/pr-review.yml | 11 +- .github/workflows/prepare-release.yml | 165 ++++++++++++ .github/workflows/publish.yml | 188 -------------- .github/workflows/release.yml | 239 ++++++++++++++++++ .github/workflows/sdk-ci.yml | 105 ++++++++ CHANGELOG.md | 11 + docs/releasing.md | 153 +++++------ tests/test_release.py | 70 +++-- .../tools/test_check_release_availability.py | 48 ++++ tests/tools/test_check_release_gate.py | 105 ++++++++ tests/tools/test_check_release_version.py | 44 ++++ tests/tools/test_prepare_release.py | 86 +++++++ tools/check_release_availability.py | 63 +++++ tools/check_release_gate.py | 175 +++++++++++++ tools/check_release_version.py | 61 ++++- tools/prepare_release.py | 91 +++++++ 16 files changed, 1337 insertions(+), 278 deletions(-) create mode 100644 .github/workflows/prepare-release.yml delete mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/sdk-ci.yml create mode 100644 CHANGELOG.md create mode 100644 tests/tools/test_check_release_availability.py create mode 100644 tests/tools/test_check_release_gate.py create mode 100644 tests/tools/test_prepare_release.py create mode 100644 tools/check_release_availability.py create mode 100644 tools/check_release_gate.py create mode 100644 tools/prepare_release.py diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index f063400..7cea8eb 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -10,7 +10,7 @@ permissions: jobs: review: - runs-on: [self-hosted, Linux, X64, pr-review] + runs-on: [self-hosted, Linux, X64, sdk-ci] if: github.event.pull_request.draft == false steps: - name: Get PR Diff via API @@ -23,7 +23,14 @@ jobs: - name: Run Luxiao Review run: | - python3 /home/opsadmin/scripts/luxiao-review.py "${{ github.event.pull_request.title }}" "${{ github.event.pull_request.body }}" "${{ github.event.pull_request.additions }}" "${{ github.event.pull_request.deletions }}" "${{ github.event.pull_request.changed_files }}" /tmp/pr.diff /tmp/review_result.md + python3 /home/runner-ci/scripts/luxiao-review.py \ + "${{ github.event.pull_request.title }}" \ + "${{ github.event.pull_request.body }}" \ + "${{ github.event.pull_request.additions }}" \ + "${{ github.event.pull_request.deletions }}" \ + "${{ github.event.pull_request.changed_files }}" \ + /tmp/pr.diff \ + /tmp/review_result.md - name: Post Review Comment if: always() diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..9e91677 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,165 @@ +name: Prepare SDK release + +on: + pull_request: + types: [closed] + workflow_dispatch: + inputs: + target_version: + description: "显式 PEP 440 版本;飞书指令使用此字段" + required: false + type: string + release_type: + description: "未指定显式版本时使用的版本增量" + required: false + type: choice + options: [none, prerelease, stable, minor, major] + default: none + source: + description: "发布请求来源" + required: true + type: string + +permissions: + contents: read + +jobs: + prepare: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') + runs-on: [self-hosted, Linux, X64, sdk-release] + steps: + - name: Resolve one explicit release request + id: release + env: + EVENT_NAME: ${{ github.event_name }} + LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} + INPUT_TARGET: ${{ inputs.target_version }} + INPUT_TYPE: ${{ inputs.release_type }} + INPUT_SOURCE: ${{ inputs.source }} + SOURCE_PR: ${{ github.event.pull_request.number }} + SOURCE_TITLE: ${{ github.event.pull_request.title }} + shell: bash + run: | + python - <<'PY' >> "${GITHUB_OUTPUT}" + import json + import os + + if os.environ["EVENT_NAME"] == "workflow_dispatch": + target = os.environ.get("INPUT_TARGET", "").strip() + release_type = os.environ.get("INPUT_TYPE", "").strip() + if release_type == "none": + release_type = "" + if bool(target) == bool(release_type): + raise SystemExit("Exactly one of target_version or release_type is required") + print(f"target={target}") + print(f"release_type={release_type}") + print(f"source={os.environ['INPUT_SOURCE'].strip()}") + print("skip=false") + else: + mapping = { + "release:prerelease": "prerelease", + "release:stable": "stable", + "release:minor": "minor", + "release:major": "major", + } + labels = json.loads(os.environ.get("LABELS_JSON") or "[]") + selected = [mapping[label] for label in labels if label in mapping] + if len(selected) > 1: + raise SystemExit("A merged PR may have only one release:* label") + if not selected: + print("skip=true") + else: + print("target=") + print(f"release_type={selected[0]}") + print( + "source=PR #{}:{}".format( + os.environ["SOURCE_PR"], os.environ["SOURCE_TITLE"].strip() + ) + ) + print("skip=false") + PY + + - name: Mint repository-scoped GitHub App token + if: steps.release.outputs.skip == 'false' + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.ORULINK_RELEASE_APP_ID }} + private-key: ${{ secrets.ORULINK_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: WatcheRobot_python_sdk + + - name: Check out main + if: steps.release.outputs.skip == 'false' + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + persist-credentials: true + token: ${{ steps.app-token.outputs.token }} + + - uses: actions/setup-python@v6 + if: steps.release.outputs.skip == 'false' + with: + python-version: "3.12" + + - name: Prepare version branch and pull request + if: steps.release.outputs.skip == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_TYPE: ${{ steps.release.outputs.release_type }} + TARGET_VERSION: ${{ steps.release.outputs.target }} + RELEASE_SOURCE: ${{ steps.release.outputs.source }} + shell: bash + run: | + python -m pip install --upgrade packaging + arguments=(--source "${RELEASE_SOURCE}") + if [[ -n "${TARGET_VERSION}" ]]; then + arguments+=(--target "${TARGET_VERSION}") + else + arguments+=(--release-type "${RELEASE_TYPE}") + fi + version=$(python tools/prepare_release.py "${arguments[@]}") + python tools/check_release_availability.py "${version}" --repository "${GITHUB_REPOSITORY}" + branch="release/watcherobot-${version}" + existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state all --json number --jq 'length') + if [[ "${existing}" != "0" ]]; then + echo "Release PR already exists for ${version}; nothing to do." + exit 0 + fi + git switch -c "${branch}" + git config user.name "orulink-release-bot" + git config user.email "release-bot@users.noreply.github.com" + git add src/watcherobot/__init__.py CHANGELOG.md + git commit -m "chore(release): 准备发布 watcherobot ${version}" \ + -m "由 ${RELEASE_SOURCE} 触发。此提交仅更新 SDK 唯一版本源和中文更新日志,正式发布仍需版本 PR 审查、标签门禁、TestPyPI 验证与 GitHub Environment 人工批准。" + git push origin "${branch}" + body=$(cat <=0.129,<1" \ - "starlette>=0.51,<1" \ - "websockets>=14,<16" - - - name: Check dependency consistency - run: python -m pip check - - - name: Run tests - run: python -m pytest - - - name: Check package typing - run: python -m mypy src/watcherobot - - ble-provisioning: - name: BLE Fake Backend / ${{ matrix.os }} / Python 3.12 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - steps: - - name: Check out repository - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: pip - - - name: Install SDK and test dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[test]" - python -m pip check - - - name: Verify Bleak backend import - run: python -c "from watcherobot.provisioning.bleak_backend import BleakBackend; print(BleakBackend)" - - - name: Run Fake Backend tests - run: python -m pytest tests/provisioning - - build: - name: Build distributions - needs: [test, ble-provisioning] - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: pip - - - name: Install build tools - run: python -m pip install --upgrade pip build twine - - - name: Verify release tag matches package version - if: github.event_name == 'release' - run: python tools/check_release_version.py "${{ github.event.release.tag_name }}" - - - name: Verify release commit belongs to main - if: github.event_name == 'release' - run: | - git fetch origin main:refs/remotes/origin/main --no-tags - git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main - - - name: Build wheel and source distribution - run: python -m build - - - name: Check distribution metadata - run: python -m twine check dist/* - - - name: Verify wheel installation - run: | - python -m pip install --force-reinstall dist/*.whl - python -m pip check - python -c "import watcherobot; print(watcherobot.__version__)" - - - name: Store distributions - uses: actions/upload-artifact@v7 - with: - name: python-package-distributions - path: dist/ - if-no-files-found: error - retention-days: 7 - - publish-testpypi: - name: Publish to TestPyPI - if: github.event_name == 'workflow_dispatch' - needs: build - runs-on: ubuntu-latest - environment: testpypi - permissions: - id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v8 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distributions - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - - publish-pypi: - name: Publish to PyPI - if: github.event_name == 'release' - needs: build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/watcherobot - permissions: - id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v8 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distributions - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0b13743 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,239 @@ +name: Release Python package + +on: + push: + tags: ["v*"] + +concurrency: + group: sdk-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + start-clean: + name: Clean release workspace before use + runs-on: [self-hosted, Linux, X64, sdk-release] + steps: + - name: Remove files left by a previous release job + shell: bash + run: | + workspace=$(realpath -m "${GITHUB_WORKSPACE}") + [[ "${workspace}" == /opt/actions-runner-release/_work/* ]] || { + echo "Refusing to clean unexpected workspace: ${workspace}" >&2 + exit 1 + } + shopt -s dotglob nullglob + rm -rf -- "${workspace}"/* + + gate: + name: Validate release request + needs: start-clean + runs-on: [self-hosted, Linux, X64, sdk-release] + outputs: + version: ${{ steps.gate.outputs.version }} + prerelease: ${{ steps.gate.outputs.prerelease }} + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade packaging + - id: gate + name: Validate tag, main ancestry, version PR and registries + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(python tools/check_release_gate.py \ + --repository "${GITHUB_REPOSITORY}" \ + --tag "${GITHUB_REF_NAME}" \ + --sha "${GITHUB_SHA}") + echo "version=${version}" >> "${GITHUB_OUTPUT}" + python tools/check_release_version.py "${GITHUB_REF_NAME}" + if [[ "${version}" =~ (a|b|rc)[0-9]+$ ]]; then + echo "prerelease=true" >> "${GITHUB_OUTPUT}" + else + echo "prerelease=false" >> "${GITHUB_OUTPUT}" + fi + + build: + name: Build immutable distributions + needs: gate + runs-on: [self-hosted, Linux, X64, sdk-release] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip build twine + - run: python -m pip install -e ".[test]" + - run: python -m pytest + - run: python -m mypy src/watcherobot + - run: python -m build + - run: python -m twine check dist/* + - name: Verify wheel installation + run: | + python -m venv .venv-wheel-check + .venv-wheel-check/bin/python -m pip install --force-reinstall dist/*.whl + .venv-wheel-check/bin/python -m pip check + .venv-wheel-check/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'" + .venv-wheel-check/bin/watcherobot --help + - name: Record distribution hashes + run: sha256sum dist/* > SHA256SUMS + - uses: actions/upload-artifact@v7 + with: + name: watcherobot-${{ needs.gate.outputs.version }} + path: | + dist/ + SHA256SUMS + if-no-files-found: error + retention-days: 30 + + draft-release: + name: Create draft GitHub Release + needs: [gate, build] + runs-on: [self-hosted, Linux, X64, sdk-release] + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: watcherobot-${{ needs.gate.outputs.version }} + path: artifact/ + - name: Create immutable draft Release + env: + GH_TOKEN: ${{ github.token }} + run: | + prerelease_flag=() + if [[ "${{ needs.gate.outputs.prerelease }}" == "true" ]]; then prerelease_flag=(--prerelease); fi + if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "Matching draft Release already exists; reusing it." + else + gh release create "${GITHUB_REF_NAME}" artifact/dist/* artifact/SHA256SUMS \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${GITHUB_SHA}" \ + --title "watcherobot ${{ needs.gate.outputs.version }}" \ + --generate-notes \ + --draft \ + "${prerelease_flag[@]}" + fi + + publish-testpypi: + name: Publish to TestPyPI + needs: [gate, build, draft-release] + runs-on: [self-hosted, Linux, X64, sdk-release] + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: watcherobot-${{ needs.gate.outputs.version }} + path: artifact/ + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - name: Publish immutable distributions to TestPyPI with OIDC + run: >- + uv publish + --trusted-publishing always + --publish-url https://test.pypi.org/legacy/ + artifact/dist/* + + verify-testpypi: + name: Verify TestPyPI installation + needs: [gate, publish-testpypi] + runs-on: [self-hosted, Linux, X64, sdk-release] + steps: + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install exact release from TestPyPI + run: | + python -m venv .venv-testpypi + for attempt in {1..12}; do + if .venv-testpypi/bin/python -m pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "watcherobot==${{ needs.gate.outputs.version }}"; then + break + fi + if [[ "${attempt}" == "12" ]]; then exit 1; fi + sleep 10 + done + .venv-testpypi/bin/python -m pip check + .venv-testpypi/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'" + .venv-testpypi/bin/watcherobot --help + + publish-pypi: + name: Publish to PyPI + needs: [gate, build, verify-testpypi, draft-release] + runs-on: [self-hosted, Linux, X64, sdk-release] + environment: + name: pypi + url: https://pypi.org/p/watcherobot + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: watcherobot-${{ needs.gate.outputs.version }} + path: artifact/ + - name: Verify artifact hashes before production upload + run: (cd artifact && sha256sum --check SHA256SUMS) + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - name: Publish the original distributions to PyPI with OIDC + run: uv publish --trusted-publishing always artifact/dist/* + + verify-pypi: + name: Verify PyPI and publish GitHub Release + needs: [gate, publish-pypi] + runs-on: [self-hosted, Linux, X64, sdk-release] + permissions: + contents: write + steps: + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install exact release from PyPI + run: | + python -m venv .venv-pypi + for attempt in {1..12}; do + if .venv-pypi/bin/python -m pip install "watcherobot==${{ needs.gate.outputs.version }}"; then break; fi + if [[ "${attempt}" == "12" ]]; then exit 1; fi + sleep 10 + done + .venv-pypi/bin/python -m pip check + .venv-pypi/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'" + .venv-pypi/bin/watcherobot --help + - name: Publish verified GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false + + finish-clean: + name: Clean release workspace after use + if: always() + needs: [gate, build, draft-release, publish-testpypi, verify-testpypi, publish-pypi, verify-pypi] + runs-on: [self-hosted, Linux, X64, sdk-release] + steps: + - name: Remove checked-out code, environments and downloaded artifacts + shell: bash + run: | + workspace=$(realpath -m "${GITHUB_WORKSPACE}") + [[ "${workspace}" == /opt/actions-runner-release/_work/* ]] || { + echo "Refusing to clean unexpected workspace: ${workspace}" >&2 + exit 1 + } + shopt -s dotglob nullglob + rm -rf -- "${workspace}"/* diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml new file mode 100644 index 0000000..2994b6c --- /dev/null +++ b/.github/workflows/sdk-ci.yml @@ -0,0 +1,105 @@ +name: SDK CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: sdk-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} / ${{ matrix.dependency-profile }} + runs-on: [self-hosted, Linux, X64, sdk-ci] + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + dependency-profile: ["lowest", "latest"] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install SDK and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[test]" + + - name: Select minimum supported runtime dependencies + if: matrix.dependency-profile == 'lowest' + run: | + python -m pip install \ + "av==16.*" \ + "bleak==3.*" \ + "esptool==4.8.*" \ + "fastapi==0.129.*" \ + "huggingface-hub==1.26.*" \ + "keyring==25.7.*" \ + "packaging==24.*" \ + "Pillow==11.*" \ + "psutil==5.9.*" \ + "pyserial==3.5.*" \ + "starlette==0.51.*" \ + "uvicorn==0.30.*" \ + "websockets==14.*" + + - name: Select latest supported runtime dependencies + if: matrix.dependency-profile == 'latest' + run: | + python -m pip install \ + "fastapi>=0.129,<1" \ + "starlette>=0.51,<1" \ + "websockets>=14,<16" + + - run: python -m pip check + - run: python -m pytest + - run: python -m mypy src/watcherobot + + ble-provisioning: + name: BLE fake backend + runs-on: [self-hosted, Linux, X64, sdk-ci] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install -e ".[test]" + - run: python -m pip check + - run: python -c "from watcherobot.provisioning.bleak_backend import BleakBackend; print(BleakBackend)" + - run: python -m pytest tests/provisioning + + build: + name: Build distributions + needs: [test, ble-provisioning] + runs-on: [self-hosted, Linux, X64, sdk-ci] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade build twine + - run: python -m build + - run: python -m twine check dist/* + - name: Verify wheel installation + run: | + python -m pip install --force-reinstall dist/*.whl + python -m pip check + python -c "import watcherobot; print(watcherobot.__version__)" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c785b11 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# 更新日志 + +本文件记录 `watcherobot` 面向 SDK 使用者的重要变更。版本发布条目由发布准备工具创建,版本 PR 负责补充和审查具体内容。 + +## [0.1.1a2] - 2026-08-07 + +- 完善 Runtime、Application 分发与 BLE 配网能力的预发布验证。 + +## [0.1.0] - 2026-07-30 + +- 发布首个可安装的 WatcheRobot Python SDK 版本。 diff --git a/docs/releasing.md b/docs/releasing.md index 6007bd4..8fb6194 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,106 +1,117 @@ -# 发布 watcherobot 到 PyPI +# watcherobot 自动发布说明 -项目通过 GitHub Actions 和 PyPI Trusted Publishing 发布,不在 GitHub Secrets 或开发者电脑中保存长期 -PyPI Token。 +`watcherobot` 由陆骁编排、自有 Runner 构建、飞书通知,并通过 GitHub Environment 人工批准后发布。 +PyPI 与 TestPyPI 均使用 OIDC Trusted Publishing,不在仓库、Runner 或个人电脑保存长期 PyPI Token。 -## 发布质量门 +## 不会直接发布的事件 -每一个不可覆盖的 PyPI/TestPyPI 版本在发布前必须同时满足: +- 普通 PR 或草稿 PR; +- 没有发布标签的合并 PR; +- 未关联合法版本 PR 的普通 `v*` Tag; +- 任意非 `v*` Tag。 -1. PR 与 `main` 的 CI 全绿,包括 Python 3.10/3.11/3.12、`websockets 14.x` 与当前允许最新版、 - pytest、mypy、wheel/sdist 构建、重装和 `pip check`。 -2. 记录用于验收的 ESP32 完整 commit、固件版本、协议版本和 SDK 版本,不能只写浮动分支名。 -3. 按[硬件测试说明](hardware-testing.md)完成 Runtime 配对,并通过 - `watcherobot app run` 运行受管 Application,验证行为、灯光、Job、相机和麦克风。 -4. 从目标索引在全新虚拟环境安装并验证版本;TestPyPI 的依赖仍从正式 PyPI 安装。 +发布入口只能是飞书显式指令,或已合并 PR 上恰好一个发布标签: -自动化测试中的内联 Transport、Runtime 和协议替身不会进入 wheel,也不能替代发布前真机验收。 +- `release:prerelease` +- `release:stable` +- `release:minor` +- `release:major` -## 一次性配置 +多个标签、非法 PEP 440 版本、版本倒退以及 PyPI 已存在的版本都会失败关闭,不会猜测或覆盖。 -在 GitHub 仓库中创建两个 Environment: +## 版本 PR 与 Tag -- `testpypi`:供手动测试发布使用。 -- `pypi`:供 GitHub Release 正式发布使用,必须配置人工审批。 +发布请求会创建 `release/watcherobot-` 分支和带 `release:version` 标签的版本 PR。版本 PR 只更新: -分别在 [PyPI](https://pypi.org/manage/account/publishing/) 和 -[TestPyPI](https://test.pypi.org/manage/account/publishing/) 注册 Pending Trusted Publisher: +- `src/watcherobot/__init__.py` 中的唯一版本源; +- 中文 `CHANGELOG.md`。 -| 字段 | PyPI | TestPyPI | -|---|---|---| -| PyPI Project Name | `watcherobot` | `watcherobot` | -| Owner | `orulink-ai` | `orulink-ai` | -| Repository | `WatcheRobot_python_sdk` | `WatcheRobot_python_sdk` | -| Workflow | `publish.yml` | `publish.yml` | -| Environment | `pypi` | `testpypi` | +版本 PR 必须人工审查和合并。合并后,陆骁才能在该 merge commit 上创建 annotated tag `v`。 +Tag 门禁还会验证该 commit 属于 `main`、关联恰好一个合法版本 PR,且 PyPI、TestPyPI 和 GitHub Release +不存在冲突版本。 -PyPI 与 TestPyPI 是两个独立账号系统,需要分别完成配置。首次成功发布会创建对应项目。 +当前源码版本是 `watcherobot==0.1.1a3`,对应既有 Tag 是 `v0.1.1a3`。自动发布启用后的下一次请求必须先 +查询 PyPI、TestPyPI、Tag 与 GitHub Release,以最新外部状态计算或校验目标版本。 -## TestPyPI 验证 +## CI 与不可变制品 -发布 workflow 合入 `main` 后,手动触发测试发布: +PR 和 `main` push 由 `.github/workflows/sdk-ci.yml` 在自托管 `sdk-ci` Runner 上执行: -```powershell -gh workflow run publish.yml --ref main -gh run list --workflow publish.yml --limit 1 -gh run watch -``` +- Python 3.10、3.11、3.12; +- 最低和最新受支持依赖; +- pytest、BLE fake backend、mypy; +- wheel/sdist 构建、`twine check`、安装和 `pip check`。 -由于 PyPI 不允许覆盖同名版本,每次重复测试前都必须先递增 `src/watcherobot/__init__.py` 中的版本。 +合法 Tag 由 `.github/workflows/release.yml` 在隔离的 `sdk-release` Runner 上执行。wheel 与 sdist 只构建一次, +随后生成 `SHA256SUMS` 并上传为 GitHub Actions Artifact。TestPyPI 与 PyPI 下载并使用同一份 Artifact,正式发布前 +再次验证哈希,不重新构建。 -使用全新虚拟环境验证 TestPyPI 产物: +## 发布顺序 -```powershell -python -m venv .venv-release-test -.venv-release-test\Scripts\python -m pip install ` - --index-url https://test.pypi.org/simple/ ` - --extra-index-url https://pypi.org/simple/ ` - watcherobot==0.1.1a3 -.venv-release-test\Scripts\python -m pip check -.venv-release-test\Scripts\python -c "import watcherobot; print(watcherobot.__version__)" -.venv-release-test\Scripts\python -m watcherobot.runtime.daemon --help -``` +1. 发布并验证 TestPyPI; +2. 创建 Draft GitHub Release; +3. 飞书群收到待审批提醒; +4. 负责人在 GitHub `pypi` Environment 批准; +5. 使用原始 Artifact 发布 PyPI; +6. 从正式 PyPI 安装验证; +7. 将 Draft GitHub Release 转为已发布状态。 -安装后还需按照[硬件测试说明](hardware-testing.md),使用验收记录中的同一固件完成 -Runtime 配对并运行受管 Application。若版本已经上传但验收失败,不得覆盖上传, -必须递增 Alpha 版本后重新发布。 +正式审批默认等待七天。陆骁监视器会在超时后取消运行并标记为 `CANCELLED`,不会自动恢复。陆骁无权合并版本 +PR,也无权批准 `pypi` Environment。 -## 正式发布 +## 一次性平台配置 -1. 修改 `src/watcherobot/__init__.py` 中的版本并通过 PR 合入 `main`。 -2. 确认测试、构建、TestPyPI 安装和必要的真机验收全部通过。 -3. 创建 Draft GitHub Release,标签必须严格等于 `v` 加包版本: +GitHub App 仅安装到 `orulink-ai/WatcheRobot_python_sdk`,向仓库提供版本 PR、Tag 和 Actions 监视能力。 +工作流使用以下仓库 Secret 获取短期安装令牌: -```powershell -gh release create v0.1.1a3 --target main --draft --prerelease --generate-notes -``` +- `ORULINK_RELEASE_APP_ID` +- `ORULINK_RELEASE_APP_PRIVATE_KEY` -4. 核对 Release 内容后发布: +PyPI Pending Trusted Publisher: -```powershell -gh release edit v0.1.1a3 --draft=false -``` +| 字段 | 值 | +|---|---| +| Project | `watcherobot` | +| Owner | `orulink-ai` | +| Repository | `WatcheRobot_python_sdk` | +| Workflow | `release.yml` | +| Environment | `pypi` | -`release.published` 事件会启动正式发布任务。流水线会再次运行测试,并检查: +TestPyPI 使用相同仓库与 Workflow,Environment 为 `testpypi`。GitHub `pypi` Environment 只允许 `v*` Tag, +并配置负责人为 Required Reviewer。 -- Release 标签与包版本完全一致。 -- Release 对应 commit 已经属于 `main`。 -- wheel 和 sdist 均能通过 `twine check`。 -- `pypi` Environment 已完成人工审批。 +## 首次自动演练与验证 -发布完成后验证: +`0.1.1a3` 已由人工流程发布,不再作为自动演练目标。RTC 实机验证通过后可请求稳定版 `0.1.1`;若仍需 +预发布修复,必须递增到新的 PEP 440 版本。实际目标以启用自动发布时重新读取的索引状态为准。 + +TestPyPI 安装验证需要让 SDK 来自 TestPyPI,同时从正式 PyPI 解析依赖: + +```powershell +python -m pip install ` + --index-url https://test.pypi.org/simple/ ` + --extra-index-url https://pypi.org/simple/ ` + watcherobot==0.1.1a3 +``` + +正式发布完成后验证: ```powershell python -m venv .venv-pypi-test .venv-pypi-test\Scripts\python -m pip install watcherobot==0.1.1a3 .venv-pypi-test\Scripts\python -c "import watcherobot; print(watcherobot.__version__)" +.venv-pypi-test\Scripts\watcherobot --help ``` -## 版本规则 +PyPI 版本一旦发布不得覆盖。问题版本只能 yank,然后递增版本重新修复和发布。 + +## 实机发布门禁 + +稳定版发布前必须按[硬件测试说明](hardware-testing.md)记录 ESP32 完整 commit、固件版本、协议版本和 SDK +版本,并完成 Runtime 配对、受管 Application、行为、灯光、Job、相机、麦克风及 RTC 实时视频验收。自动化 +Transport/Runtime 替身不能替代实机验收。 -- Alpha:正式版 `0.1.0` 之后从 `0.1.1a1` 开始递增 -- Beta:`0.1.1b1` -- Release Candidate:`0.1.1rc1` -- 正式版:`0.1.1` +实机验收必须通过 `watcherobot app run` 启动当前受管 Application,桌面和设备业务帧均保持经过 Daemon 与 +当前 Application 的既定路由,不得为发布验证增加协议旁路。 -PyPI 版本不可覆盖或重新上传。发布失败但文件已经进入索引时,必须递增版本号后重新发布。 +版本族遵循 PEP 440:Alpha `0.1.1a1`、Beta `0.1.1b1`、RC `0.1.1rc1`、稳定版 `0.1.1`。 diff --git a/tests/test_release.py b/tests/test_release.py index 67bf18a..9daaa4b 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -25,56 +25,94 @@ def test_releasing_uses_one_next_patch_version_family() -> None: assert "0.1.0rc1" not in releasing -def test_publish_workflow_separates_test_and_production_indexes() -> None: - workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text(encoding="utf-8") +def test_release_workflow_separates_test_and_production_indexes() -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - assert "pull_request:" in workflow - assert "push:\n branches: [main]" in workflow - assert "workflow_dispatch:" in workflow - assert "release:" in workflow - assert "types: [published]" in workflow + assert "tags: [\"v*\"]" in workflow + assert "pull_request:" not in workflow + assert "workflow_dispatch:" not in workflow assert "environment: testpypi" in workflow assert "environment:\n name: pypi" in workflow assert "id-token: write" in workflow assert "https://test.pypi.org/legacy/" in workflow - assert "pypa/gh-action-pypi-publish@release/v1" in workflow + assert "astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b" in workflow + assert workflow.count("uv publish") == 2 + assert workflow.count("--trusted-publishing always") == 2 assert "actions/upload-artifact@v7" in workflow - assert workflow.count("actions/download-artifact@v8") == 2 + assert workflow.count("actions/download-artifact@v8") >= 3 + assert "runs-on: [self-hosted, Linux, X64, sdk-release]" in workflow + assert "tools/check_release_gate.py" in workflow + assert "sha256sum dist/* > SHA256SUMS" in workflow + assert "name: Clean release workspace before use" in workflow + assert "name: Clean release workspace after use" in workflow + assert "artifact/dist/*" in workflow + assert "--index-url https://test.pypi.org/simple/" in workflow + assert "--extra-index-url https://pypi.org/simple/" in workflow + assert "gh release create" in workflow + assert "--draft" in workflow + assert "gh release edit" in workflow assert "PYPI_API_TOKEN" not in workflow assert "password:" not in workflow def test_production_publish_requires_a_release_and_version_check() -> None: - workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - assert "github.event_name == 'release'" in workflow assert "tools/check_release_version.py" in workflow - assert "git merge-base --is-ancestor" in workflow + gate = (ROOT / "tools" / "check_release_gate.py").read_text(encoding="utf-8") + assert '"merge-base", "--is-ancestor"' in gate + assert "environment:\n name: pypi" in workflow def test_publish_workflow_tests_supported_dependency_profiles_before_one_build() -> None: - workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "sdk-ci.yml").read_text(encoding="utf-8") + assert "pull_request:" in workflow + assert "push:\n branches: [main]" in workflow + assert "runs-on: [self-hosted, Linux, X64, sdk-ci]" in workflow assert 'python-version: ["3.10", "3.11", "3.12"]' in workflow assert 'dependency-profile: ["lowest", "latest"]' in workflow assert "python-version: ${{ matrix.python-version }}" in workflow assert '"fastapi==0.129.*"' in workflow + assert '"huggingface-hub==1.26.*"' in workflow + assert '"packaging==24.*"' in workflow + assert '"uvicorn==0.30.*"' in workflow assert '"starlette==0.51.*"' in workflow assert '"websockets==14.*"' in workflow assert '"fastapi>=0.129,<1"' in workflow assert '"starlette>=0.51,<1"' in workflow assert '"websockets>=14,<16"' in workflow assert "python -m mypy src/watcherobot" in workflow + assert "id-token: write" not in workflow + assert "environment:" not in workflow assert "name: Build distributions" in workflow assert "needs: [test, ble-provisioning]" in workflow assert "python -m pip install --force-reinstall dist/*.whl" in workflow assert "python -m pip check" in workflow -def test_fake_ble_tests_run_on_windows_and_macos() -> None: - workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text(encoding="utf-8") +def test_fake_ble_tests_run_on_self_hosted_linux() -> None: + workflow = (ROOT / ".github" / "workflows" / "sdk-ci.yml").read_text(encoding="utf-8") assert "ble-provisioning:" in workflow - assert "os: [windows-latest, macos-latest]" in workflow + assert "runs-on: [self-hosted, Linux, X64, sdk-ci]" in workflow assert "python -m pytest tests/provisioning" in workflow assert "from watcherobot.provisioning.bleak_backend import BleakBackend" in workflow + + +def test_legacy_publish_workflow_is_removed() -> None: + assert not (ROOT / ".github" / "workflows" / "publish.yml").exists() + + +def test_prepare_release_uses_repository_scoped_github_app() -> None: + workflow = (ROOT / ".github" / "workflows" / "prepare-release.yml").read_text( + encoding="utf-8" + ) + + assert "actions/create-github-app-token@v2" in workflow + assert "ORULINK_RELEASE_APP_ID" in workflow + assert "ORULINK_RELEASE_APP_PRIVATE_KEY" in workflow + assert "token: ${{ steps.app-token.outputs.token }}" in workflow + assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in workflow + assert "workflow_dispatch:" in workflow + assert "tools/check_release_availability.py" in workflow diff --git a/tests/tools/test_check_release_availability.py b/tests/tools/test_check_release_availability.py new file mode 100644 index 0000000..32ab39d --- /dev/null +++ b/tests/tools/test_check_release_availability.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[2] + + +def _load_module(): + path = ROOT / "tools" / "check_release_availability.py" + spec = importlib.util.spec_from_file_location("check_release_availability", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_absent_canonical_version_is_accepted() -> None: + module = _load_module() + module.validate_absent("0.1.1a3", pypi_status=404, testpypi_status=404, release_exists=False) + + +@pytest.mark.parametrize( + ("pypi_status", "testpypi_status", "release_exists"), + [(200, 404, False), (404, 200, False), (404, 404, True)], +) +def test_existing_version_is_rejected( + pypi_status: int, testpypi_status: int, release_exists: bool +) -> None: + module = _load_module() + with pytest.raises(ValueError, match="already exists"): + module.validate_absent( + "0.1.1a3", + pypi_status=pypi_status, + testpypi_status=testpypi_status, + release_exists=release_exists, + ) + + +def test_noncanonical_pep440_version_is_rejected() -> None: + module = _load_module() + with pytest.raises(ValueError, match="canonical"): + module.validate_absent("0.1.1-alpha3", pypi_status=404, testpypi_status=404, release_exists=False) diff --git a/tests/tools/test_check_release_gate.py b/tests/tools/test_check_release_gate.py new file mode 100644 index 0000000..d14d91d --- /dev/null +++ b/tests/tools/test_check_release_gate.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[2] + + +def _load_module(): + path = ROOT / "tools" / "check_release_gate.py" + spec = importlib.util.spec_from_file_location("check_release_gate", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_exactly_one_release_request_label_is_required() -> None: + module = _load_module() + + assert module.select_release_label(["docs", "release:minor"]) == "minor" + with pytest.raises(ValueError, match="exactly one"): + module.select_release_label(["docs"]) + with pytest.raises(ValueError, match="exactly one"): + module.select_release_label(["release:minor", "release:major"]) + + +def test_release_version_pr_contract_is_strict() -> None: + module = _load_module() + + module.validate_version_pull_request( + merged=True, + base_ref="main", + labels=["release:version"], + head_ref="release/watcherobot-0.1.1a3", + version="0.1.1a3", + ) + with pytest.raises(ValueError, match="merged into main"): + module.validate_version_pull_request( + merged=False, + base_ref="main", + labels=["release:version"], + head_ref="release/watcherobot-0.1.1a3", + version="0.1.1a3", + ) + with pytest.raises(ValueError, match="release:version"): + module.validate_version_pull_request( + merged=True, + base_ref="main", + labels=[], + head_ref="release/watcherobot-0.1.1a3", + version="0.1.1a3", + ) + + +@pytest.mark.parametrize( + "status_code", + [200, 301, 302], +) +def test_existing_index_version_is_rejected(status_code: int) -> None: + module = _load_module() + + with pytest.raises(ValueError, match="already exists"): + module.validate_version_absent("PyPI", "0.1.1a3", status_code) + + +def test_missing_index_version_is_accepted() -> None: + module = _load_module() + + module.validate_version_absent("PyPI", "0.1.1a3", 404) + + +def test_existing_test_index_version_requires_a_matching_draft() -> None: + module = _load_module() + + module.validate_version_absent("TestPyPI", "0.1.1a3", 200, allow_existing=True) + + +def test_matching_draft_release_can_be_reused() -> None: + module = _load_module() + + assert module.validate_existing_release( + tag="v0.1.1a3", + sha="abc123", + release={"tagName": "v0.1.1a3", "targetCommitish": "abc123", "isDraft": True}, + ) is True + + +@pytest.mark.parametrize( + "release", + [ + {"tagName": "v0.1.1a3", "targetCommitish": "different", "isDraft": True}, + {"tagName": "v0.1.1a3", "targetCommitish": "abc123", "isDraft": False}, + ], +) +def test_nonmatching_or_published_release_is_rejected(release: dict[str, object]) -> None: + module = _load_module() + + with pytest.raises(ValueError, match="conflicting GitHub Release"): + module.validate_existing_release(tag="v0.1.1a3", sha="abc123", release=release) diff --git a/tests/tools/test_check_release_version.py b/tests/tools/test_check_release_version.py index 839b4c4..f8f0249 100644 --- a/tests/tools/test_check_release_version.py +++ b/tests/tools/test_check_release_version.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from packaging.version import Version ROOT = Path(__file__).parents[2] @@ -26,6 +27,49 @@ def test_release_tag_matches_package_version() -> None: assert module.validate_release_tag("v0.1.0a1", "0.1.0a1") == "0.1.0a1" +def test_release_version_must_be_canonical_pep440() -> None: + module = _load_module() + + assert module.validate_package_version("0.1.1a3") == Version("0.1.1a3") + with pytest.raises(ValueError, match="canonical PEP 440"): + module.validate_package_version("0.1.1-alpha3") + + +@pytest.mark.parametrize( + ("current", "release_type", "expected"), + [ + ("0.1.1a2", "prerelease", "0.1.1a3"), + ("0.1.1a2", "stable", "0.1.1"), + ("0.1.1", "prerelease", "0.1.2a1"), + ("0.1.1", "minor", "0.2.0a1"), + ("0.1.1", "major", "1.0.0a1"), + ], +) +def test_next_release_version_is_deterministic( + current: str, + release_type: str, + expected: str, +) -> None: + module = _load_module() + + assert module.next_release_version(current, release_type) == expected + + +def test_stable_release_requires_a_prerelease() -> None: + module = _load_module() + + with pytest.raises(ValueError, match="requires a pre-release"): + module.next_release_version("0.1.1", "stable") + + +def test_explicit_release_version_must_increase() -> None: + module = _load_module() + + assert module.validate_version_increment("0.1.1a2", "0.1.1a3") == "0.1.1a3" + with pytest.raises(ValueError, match="must be newer"): + module.validate_version_increment("0.1.1a2", "0.1.1a2") + + @pytest.mark.parametrize("tag", ["0.1.0a1", "v0.1.0", "release-0.1.0a1"]) def test_release_tag_mismatch_is_rejected(tag: str) -> None: module = _load_module() diff --git a/tests/tools/test_prepare_release.py b/tests/tools/test_prepare_release.py new file mode 100644 index 0000000..8659427 --- /dev/null +++ b/tests/tools/test_prepare_release.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[2] + + +def _load_module(): + path = ROOT / "tools" / "prepare_release.py" + spec = importlib.util.spec_from_file_location("prepare_release", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_prepare_release_updates_version_and_changelog(tmp_path: Path) -> None: + module = _load_module() + version_file = tmp_path / "__init__.py" + changelog = tmp_path / "CHANGELOG.md" + version_file.write_text('__version__ = "0.1.1a2"\n', encoding="utf-8") + changelog.write_text("# 更新日志\n\n## [0.1.1a2] - 2026-08-07\n\n- 旧版本\n", encoding="utf-8") + + result = module.prepare_release( + version_file=version_file, + changelog_file=changelog, + target="0.1.1a3", + release_type=None, + source="PR #37:增加实时视频", + ) + + assert result == "0.1.1a3" + assert '__version__ = "0.1.1a3"' in version_file.read_text(encoding="utf-8") + updated = changelog.read_text(encoding="utf-8") + assert "## [0.1.1a3] - 待发布" in updated + assert "PR #37:增加实时视频" in updated + assert updated.index("0.1.1a3") < updated.index("0.1.1a2") + + +def test_prepare_release_calculates_release_type(tmp_path: Path) -> None: + module = _load_module() + version_file = tmp_path / "__init__.py" + changelog = tmp_path / "CHANGELOG.md" + version_file.write_text('__version__ = "0.1.1a2"\n', encoding="utf-8") + changelog.write_text("# 更新日志\n", encoding="utf-8") + + result = module.prepare_release( + version_file=version_file, + changelog_file=changelog, + target=None, + release_type="prerelease", + source="飞书指令", + ) + + assert result == "0.1.1a3" + + +def test_prepare_release_rejects_ambiguous_or_duplicate_requests(tmp_path: Path) -> None: + module = _load_module() + version_file = tmp_path / "__init__.py" + changelog = tmp_path / "CHANGELOG.md" + version_file.write_text('__version__ = "0.1.1a2"\n', encoding="utf-8") + changelog.write_text("# 更新日志\n\n## [0.1.1a3] - 待发布\n", encoding="utf-8") + + with pytest.raises(ValueError, match="exactly one"): + module.prepare_release( + version_file=version_file, + changelog_file=changelog, + target="0.1.1a3", + release_type="prerelease", + source="ambiguous", + ) + with pytest.raises(ValueError, match="already exists"): + module.prepare_release( + version_file=version_file, + changelog_file=changelog, + target="0.1.1a3", + release_type=None, + source="duplicate", + ) diff --git a/tools/check_release_availability.py b/tools/check_release_availability.py new file mode 100644 index 0000000..a499372 --- /dev/null +++ b/tools/check_release_availability.py @@ -0,0 +1,63 @@ +"""Reject a release version that already exists on any immutable release surface.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import urllib.error +import urllib.request + +from packaging.version import Version + + +def http_status(url: str) -> int: + request = urllib.request.Request(url, headers={"User-Agent": "watcherobot-release-availability/1"}) + try: + with urllib.request.urlopen(request, timeout=15) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + + +def validate_absent(version: str, *, pypi_status: int, testpypi_status: int, release_exists: bool) -> None: + canonical = str(Version(version)) + if canonical != version: + raise ValueError(f"version {version!r} is not canonical PEP 440 ({canonical!r})") + existing = [] + if pypi_status != 404: + existing.append("PyPI") + if testpypi_status != 404: + existing.append("TestPyPI") + if release_exists: + existing.append("GitHub Release") + if existing: + raise ValueError(f"watcherobot {version} already exists on {', '.join(existing)}") + + +def check(version: str, repository: str) -> None: + release = subprocess.run( + ["gh", "release", "view", f"v{version}", "--repo", repository, "--json", "tagName"], + text=True, + capture_output=True, + ) + validate_absent( + version, + pypi_status=http_status(f"https://pypi.org/pypi/watcherobot/{version}/json"), + testpypi_status=http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), + release_exists=release.returncode == 0, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("--repository", required=True) + args = parser.parse_args() + check(args.version, args.repository) + print(json.dumps({"version": args.version, "available": True})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_release_gate.py b/tools/check_release_gate.py new file mode 100644 index 0000000..8694091 --- /dev/null +++ b/tools/check_release_gate.py @@ -0,0 +1,175 @@ +"""Fail closed unless a release tag satisfies the repository release contract.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from collections.abc import Iterable +from pathlib import Path + +_TOOLS_DIR = Path(__file__).resolve().parent +if str(_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(_TOOLS_DIR)) + +from check_release_version import read_package_version, validate_release_tag + + +_REQUEST_LABELS = { + "release:prerelease": "prerelease", + "release:stable": "stable", + "release:minor": "minor", + "release:major": "major", +} + + +def select_release_label(labels: Iterable[str]) -> str: + matches = [_REQUEST_LABELS[label] for label in labels if label in _REQUEST_LABELS] + if len(matches) != 1: + raise ValueError("exactly one release request label is required") + return matches[0] + + +def validate_version_pull_request( + *, + merged: bool, + base_ref: str, + labels: Iterable[str], + head_ref: str, + version: str, +) -> None: + if not merged or base_ref != "main": + raise ValueError("release version PR must be merged into main") + if "release:version" not in labels: + raise ValueError("release version PR must have the release:version label") + expected_head = f"release/watcherobot-{version}" + if head_ref != expected_head: + raise ValueError(f"release version PR head must be {expected_head!r}") + + +def validate_version_absent( + index_name: str, + version: str, + status_code: int, + *, + allow_existing: bool = False, +) -> None: + if status_code != 404 and not allow_existing: + raise ValueError(f"watcherobot {version} already exists on {index_name}") + + +def validate_existing_release( + *, + tag: str, + sha: str, + release: dict[str, object], +) -> bool: + if ( + release.get("tagName") == tag + and release.get("targetCommitish") == sha + and release.get("isDraft") is True + ): + return True + raise ValueError(f"conflicting GitHub Release already exists for {tag}") + + +def _run(*args: str) -> str: + return subprocess.run(args, check=True, text=True, capture_output=True).stdout.strip() + + +def _http_status(url: str) -> int: + request = urllib.request.Request(url, method="GET", headers={"User-Agent": "watcherobot-release-gate/1"}) + try: + with urllib.request.urlopen(request, timeout=15) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + + +def _associated_pull_requests(repository: str, sha: str) -> list[dict[str, object]]: + output = _run( + "gh", + "api", + f"repos/{repository}/commits/{sha}/pulls", + "-H", + "Accept: application/vnd.github+json", + ) + value = json.loads(output) + if not isinstance(value, list): + raise ValueError("GitHub returned an invalid pull request list") + return value + + +def validate_gate(*, repository: str, tag: str, sha: str) -> str: + version = validate_release_tag(tag, read_package_version()) + _run("git", "fetch", "origin", "main:refs/remotes/origin/main", "--no-tags") + _run("git", "merge-base", "--is-ancestor", sha, "refs/remotes/origin/main") + + pull_requests = _associated_pull_requests(repository, sha) + matching = [] + for pull_request in pull_requests: + labels = [label.get("name") for label in pull_request.get("labels", []) if isinstance(label, dict)] + try: + validate_version_pull_request( + merged=pull_request.get("merged_at") is not None, + base_ref=str((pull_request.get("base") or {}).get("ref") or ""), + labels=[str(label) for label in labels if label], + head_ref=str((pull_request.get("head") or {}).get("ref") or ""), + version=version, + ) + except ValueError: + continue + matching.append(pull_request) + if len(matching) != 1: + raise ValueError("tag commit must be associated with exactly one valid release version PR") + + reusable_draft = False + release_result = subprocess.run( + ( + "gh", + "release", + "view", + tag, + "--repo", + repository, + "--json", + "tagName,targetCommitish,isDraft", + ), + text=True, + capture_output=True, + ) + if release_result.returncode == 0: + release = json.loads(release_result.stdout) + if not isinstance(release, dict): + raise ValueError("GitHub returned invalid Release metadata") + reusable_draft = validate_existing_release(tag=tag, sha=sha, release=release) + + validate_version_absent( + "PyPI", + version, + _http_status(f"https://pypi.org/pypi/watcherobot/{version}/json"), + ) + validate_version_absent( + "TestPyPI", + version, + _http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), + allow_existing=reusable_draft, + ) + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--sha", required=True) + args = parser.parse_args() + print(validate_gate(repository=args.repository, tag=args.tag, sha=args.sha)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_release_version.py b/tools/check_release_version.py index c37a386..aae12f7 100644 --- a/tools/check_release_version.py +++ b/tools/check_release_version.py @@ -6,6 +6,8 @@ import re from pathlib import Path +from packaging.version import InvalidVersion, Version + _VERSION_PATTERN = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) _DEFAULT_VERSION_FILE = Path(__file__).resolve().parents[1] / "src" / "watcherobot" / "__init__.py" @@ -19,6 +21,7 @@ def read_package_version(path: Path = _DEFAULT_VERSION_FILE) -> str: def validate_release_tag(tag: str, version: str) -> str: + validate_package_version(version) expected_tag = f"v{version}" if tag != expected_tag: raise ValueError( @@ -27,12 +30,68 @@ def validate_release_tag(tag: str, version: str) -> str: return version +def validate_package_version(value: str) -> Version: + """Return a canonical PEP 440 version or reject ambiguous spellings.""" + + try: + version = Version(value) + except InvalidVersion as error: + raise ValueError(f"version {value!r} is not valid PEP 440") from error + if str(version) != value: + raise ValueError(f"version {value!r} must use canonical PEP 440 spelling {str(version)!r}") + return version + + +def next_release_version(current: str, release_type: str) -> str: + """Calculate the next supported release without guessing user intent.""" + + version = validate_package_version(current) + if release_type == "prerelease": + if version.pre and version.pre[0] == "a": + return f"{version.major}.{version.minor}.{version.micro}a{version.pre[1] + 1}" + if version.is_prerelease: + raise ValueError("prerelease increment only supports the current alpha series") + return f"{version.major}.{version.minor}.{version.micro + 1}a1" + if release_type == "stable": + if not version.is_prerelease: + raise ValueError("stable release requires a pre-release version") + return f"{version.major}.{version.minor}.{version.micro}" + if release_type == "minor": + return f"{version.major}.{version.minor + 1}.0a1" + if release_type == "major": + return f"{version.major + 1}.0.0a1" + raise ValueError(f"unsupported release type {release_type!r}") + + +def validate_version_increment(current: str, target: str) -> str: + current_version = validate_package_version(current) + target_version = validate_package_version(target) + if target_version <= current_version: + raise ValueError(f"target version {target!r} must be newer than {current!r}") + return target + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("tag", help="GitHub Release tag, for example v0.1.0a2") + parser.add_argument("tag", nargs="?", help="Git tag, for example v0.1.0a2") parser.add_argument("--version-file", type=Path, default=_DEFAULT_VERSION_FILE) + parser.add_argument("--current") + parser.add_argument("--release-type", choices=("prerelease", "stable", "minor", "major")) + parser.add_argument("--target") args = parser.parse_args() + if args.release_type: + if not args.current: + parser.error("--release-type requires --current") + print(next_release_version(args.current, args.release_type)) + return 0 + if args.target: + if not args.current: + parser.error("--target requires --current") + print(validate_version_increment(args.current, args.target)) + return 0 + if not args.tag: + parser.error("tag is required unless calculating a version") version = validate_release_tag(args.tag, read_package_version(args.version_file)) print(f"release tag matches watcherobot {version}") return 0 diff --git a/tools/prepare_release.py b/tools/prepare_release.py new file mode 100644 index 0000000..aaaf809 --- /dev/null +++ b/tools/prepare_release.py @@ -0,0 +1,91 @@ +"""Deterministically prepare the watcherobot version source and changelog.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_TOOLS_DIR = Path(__file__).resolve().parent +if str(_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(_TOOLS_DIR)) + +from check_release_version import ( + next_release_version, + read_package_version, + validate_version_increment, +) + + +_ROOT = Path(__file__).resolve().parents[1] +_DEFAULT_VERSION_FILE = _ROOT / "src" / "watcherobot" / "__init__.py" +_DEFAULT_CHANGELOG = _ROOT / "CHANGELOG.md" +_VERSION_ASSIGNMENT = re.compile(r'(?m)^__version__\s*=\s*["\'][^"\']+["\']$') + + +def prepare_release( + *, + version_file: Path, + changelog_file: Path, + target: str | None, + release_type: str | None, + source: str, +) -> str: + if (target is None) == (release_type is None): + raise ValueError("exactly one of target or release_type is required") + current = read_package_version(version_file) + version = ( + validate_version_increment(current, target) + if target is not None + else next_release_version(current, str(release_type)) + ) + + changelog = changelog_file.read_text(encoding="utf-8") if changelog_file.exists() else "# 更新日志\n" + heading = f"## [{version}]" + if heading in changelog: + raise ValueError(f"changelog entry for {version} already exists") + version_text = version_file.read_text(encoding="utf-8") + updated_version_text, count = _VERSION_ASSIGNMENT.subn( + f'__version__ = "{version}"', + version_text, + count=1, + ) + if count != 1: + raise ValueError(f"package version assignment not found in {version_file}") + + entry = f"## [{version}] - 待发布\n\n- {source.strip()}\n\n" + if changelog.startswith("# 更新日志"): + first_line, remainder = changelog.split("\n", 1) + updated_changelog = f"{first_line}\n\n{entry}{remainder.lstrip()}" + else: + updated_changelog = f"# 更新日志\n\n{entry}{changelog.lstrip()}" + + version_file.write_text(updated_version_text, encoding="utf-8") + changelog_file.write_text(updated_changelog, encoding="utf-8") + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--target") + group.add_argument("--release-type", choices=("prerelease", "stable", "minor", "major")) + parser.add_argument("--source", required=True) + parser.add_argument("--version-file", type=Path, default=_DEFAULT_VERSION_FILE) + parser.add_argument("--changelog", type=Path, default=_DEFAULT_CHANGELOG) + args = parser.parse_args() + print( + prepare_release( + version_file=args.version_file, + changelog_file=args.changelog, + target=args.target, + release_type=args.release_type, + source=args.source, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e34f8427c8a938a4ab4b5d8d7cc22afc2042aecd Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 16:58:23 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(release):=20=E6=94=B6=E7=B4=A7=20Tag?= =?UTF-8?q?=20=E8=A7=A3=E5=BC=95=E7=94=A8=E4=B8=8E=20Runner=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复合并前自审发现的两个发布门禁问题: - annotated tag 的 GitHub 事件 SHA 可能指向 tag object,现先解引用到真实 commit,再验证 main 祖先、版本 PR 与 Release target - 版本 PR 准备任务改由 sdk-ci 执行,sdk-release 仅接收受保护 v* Tag 发布工作流 - 发布门禁显式拒绝 lightweight tag,确保只接受 annotated tag 验证:发布工具、工作流契约与文档测试全部通过,git diff --check 通过。 --- .github/workflows/prepare-release.yml | 2 +- .github/workflows/release.yml | 7 +++++-- tests/test_release.py | 6 ++++++ tools/check_release_gate.py | 6 ++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9e91677..217dc95 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -28,7 +28,7 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') - runs-on: [self-hosted, Linux, X64, sdk-release] + runs-on: [self-hosted, Linux, X64, sdk-ci] steps: - name: Resolve one explicit release request id: release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b13743..44033ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ jobs: outputs: version: ${{ steps.gate.outputs.version }} prerelease: ${{ steps.gate.outputs.prerelease }} + commit: ${{ steps.gate.outputs.commit }} permissions: contents: read pull-requests: read @@ -52,11 +53,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") version=$(python tools/check_release_gate.py \ --repository "${GITHUB_REPOSITORY}" \ --tag "${GITHUB_REF_NAME}" \ - --sha "${GITHUB_SHA}") + --sha "${tag_commit}") echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "commit=${tag_commit}" >> "${GITHUB_OUTPUT}" python tools/check_release_version.py "${GITHUB_REF_NAME}" if [[ "${version}" =~ (a|b|rc)[0-9]+$ ]]; then echo "prerelease=true" >> "${GITHUB_OUTPUT}" @@ -123,7 +126,7 @@ jobs: else gh release create "${GITHUB_REF_NAME}" artifact/dist/* artifact/SHA256SUMS \ --repo "${GITHUB_REPOSITORY}" \ - --target "${GITHUB_SHA}" \ + --target "${{ needs.gate.outputs.commit }}" \ --title "watcherobot ${{ needs.gate.outputs.version }}" \ --generate-notes \ --draft \ diff --git a/tests/test_release.py b/tests/test_release.py index 9daaa4b..abe3ad0 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -42,6 +42,8 @@ def test_release_workflow_separates_test_and_production_indexes() -> None: assert workflow.count("actions/download-artifact@v8") >= 3 assert "runs-on: [self-hosted, Linux, X64, sdk-release]" in workflow assert "tools/check_release_gate.py" in workflow + assert 'tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}")' in workflow + assert '--target "${{ needs.gate.outputs.commit }}"' in workflow assert "sha256sum dist/* > SHA256SUMS" in workflow assert "name: Clean release workspace before use" in workflow assert "name: Clean release workspace after use" in workflow @@ -61,6 +63,8 @@ def test_production_publish_requires_a_release_and_version_check() -> None: assert "tools/check_release_version.py" in workflow gate = (ROOT / "tools" / "check_release_gate.py").read_text(encoding="utf-8") assert '"merge-base", "--is-ancestor"' in gate + assert '"cat-file", "-t", tag' in gate + assert "release ref must be an annotated tag" in gate assert "environment:\n name: pypi" in workflow @@ -116,3 +120,5 @@ def test_prepare_release_uses_repository_scoped_github_app() -> None: assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in workflow assert "workflow_dispatch:" in workflow assert "tools/check_release_availability.py" in workflow + assert "runs-on: [self-hosted, Linux, X64, sdk-ci]" in workflow + assert "runs-on: [self-hosted, Linux, X64, sdk-release]" not in workflow diff --git a/tools/check_release_gate.py b/tools/check_release_gate.py index 8694091..6c50e2c 100644 --- a/tools/check_release_gate.py +++ b/tools/check_release_gate.py @@ -105,6 +105,12 @@ def _associated_pull_requests(repository: str, sha: str) -> list[dict[str, objec def validate_gate(*, repository: str, tag: str, sha: str) -> str: version = validate_release_tag(tag, read_package_version()) + tag_type = _run("git", "cat-file", "-t", tag) + if tag_type != "tag": + raise ValueError("release ref must be an annotated tag") + resolved_sha = _run("git", "rev-list", "-n", "1", tag) + if resolved_sha != sha: + raise ValueError("resolved annotated tag commit does not match the release gate input") _run("git", "fetch", "origin", "main:refs/remotes/origin/main", "--no-tags") _run("git", "merge-base", "--is-ancestor", sha, "refs/remotes/origin/main") From 3e46bb42084080ea12b97bcabae06101086a6ef2 Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 17:04:49 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(release):=20=E4=B8=BA=20TestPyPI=20?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E5=A2=9E=E5=8A=A0=E5=88=B6=E5=93=81=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置 uv 的 TestPyPI simple check URL,使网络重试或工作流重跑只跳过与索引中哈希完全一致的制品;冲突制品仍然失败,避免盲目覆盖。 --- .github/workflows/release.yml | 2 ++ tests/test_release.py | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44033ec..13d8f1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,8 @@ jobs: path: artifact/ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Publish immutable distributions to TestPyPI with OIDC + env: + UV_PUBLISH_CHECK_URL: https://test.pypi.org/simple/ run: >- uv publish --trusted-publishing always diff --git a/tests/test_release.py b/tests/test_release.py index abe3ad0..bb74cb4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -38,6 +38,7 @@ def test_release_workflow_separates_test_and_production_indexes() -> None: assert "astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b" in workflow assert workflow.count("uv publish") == 2 assert workflow.count("--trusted-publishing always") == 2 + assert "UV_PUBLISH_CHECK_URL: https://test.pypi.org/simple/" in workflow assert "actions/upload-artifact@v7" in workflow assert workflow.count("actions/download-artifact@v8") >= 3 assert "runs-on: [self-hosted, Linux, X64, sdk-release]" in workflow From a04fedb353c3f69bacb42c5f877aedc5dab6df5a Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 17:24:42 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(ci):=20=E9=9A=94=E7=A6=BB=E9=99=86?= =?UTF-8?q?=E9=AA=81=E5=AE=A1=E6=9F=A5=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 PR diff 与审查结果从全局 /tmp 固定路径迁移到 GitHub Actions 每个 Job 独立的 RUNNER_TEMP,避免自托管 Runner 上历史文件属主冲突、跨 PR 结果污染和错误评论复用。 补充工作流契约测试,确保不再使用固定 /tmp 文件。 --- .github/workflows/pr-review.yml | 23 +++++++++++++---------- tests/test_release.py | 9 +++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 7cea8eb..989d7e2 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -17,9 +17,11 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} > /tmp/pr.diff 2>/dev/null - if [ ! -s /tmp/pr.diff ]; then echo "empty" > /tmp/pr.diff; fi - echo "Diff lines: $(wc -l < /tmp/pr.diff)" + diff_file="${RUNNER_TEMP}/pr-${{ github.event.pull_request.number }}.diff" + gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} > "${diff_file}" 2>/dev/null + if [ ! -s "${diff_file}" ]; then echo "empty" > "${diff_file}"; fi + echo "DIFF_FILE=${diff_file}" >> "${GITHUB_ENV}" + echo "Diff lines: $(wc -l < "${diff_file}")" - name: Run Luxiao Review run: | @@ -29,17 +31,18 @@ jobs: "${{ github.event.pull_request.additions }}" \ "${{ github.event.pull_request.deletions }}" \ "${{ github.event.pull_request.changed_files }}" \ - /tmp/pr.diff \ - /tmp/review_result.md + "${DIFF_FILE}" \ + "${RUNNER_TEMP}/review_result.md" - name: Post Review Comment if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ ! -s /tmp/review_result.md ]; then - echo "## 🤖 Luxiao PR 审查报告" > /tmp/review_result.md - echo "" >> /tmp/review_result.md - echo "⚠️ Review agent did not produce output." >> /tmp/review_result.md + review_file="${RUNNER_TEMP}/review_result.md" + if [ ! -s "${review_file}" ]; then + echo "## 🤖 Luxiao PR 审查报告" > "${review_file}" + echo "" >> "${review_file}" + echo "⚠️ Review agent did not produce output." >> "${review_file}" fi - gh pr comment ${{ github.event.pull_request.number }} --body "$(cat /tmp/review_result.md)" + gh pr comment ${{ github.event.pull_request.number }} --body "$(cat "${review_file}")" diff --git a/tests/test_release.py b/tests/test_release.py index bb74cb4..70ae5b2 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -105,6 +105,15 @@ def test_fake_ble_tests_run_on_self_hosted_linux() -> None: assert "from watcherobot.provisioning.bleak_backend import BleakBackend" in workflow +def test_luxiao_review_uses_job_scoped_temporary_files() -> None: + workflow = (ROOT / ".github" / "workflows" / "pr-review.yml").read_text(encoding="utf-8") + + assert "${RUNNER_TEMP}/pr-${{ github.event.pull_request.number }}.diff" in workflow + assert "${RUNNER_TEMP}/review_result.md" in workflow + assert "/tmp/pr.diff" not in workflow + assert "/tmp/review_result.md" not in workflow + + def test_legacy_publish_workflow_is_removed() -> None: assert not (ROOT / ".github" / "workflows" / "publish.yml").exists() From 79c275f0bf9627e11c441ab2d75738a424eed893 Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 17:27:54 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(ci):=20=E5=8A=A0=E5=9B=BA=E9=99=86?= =?UTF-8?q?=E9=AA=81=E5=AE=A1=E6=9F=A5=E8=BE=93=E5=85=A5=E4=B8=8E=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复自托管 Runner 迁移后暴露的两类问题: - PR 标题和描述不再直接拼进 Bash 命令,改用环境变量传入,避免 Markdown 反引号被执行 - 桥接脚本使用 Job 独立临时文件和 Hermes 用户缓存目录,不再读写共享 /tmp 固定文件 - scp、ssh 和远端 Agent 失败均显式返回失败,不再发布伪成功审查结果 - 调用结束后清理本地与远端 prompt 文件,降低跨 PR 污染风险 补充工作流契约和 Python 语法验证。 --- .github/scripts/luxiao_review.py | 118 +++++++++++++++++++++++++++++++ .github/workflows/pr-review.yml | 11 +-- tests/test_release.py | 7 ++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/luxiao_review.py diff --git a/.github/scripts/luxiao_review.py b/.github/scripts/luxiao_review.py new file mode 100644 index 0000000..15724db --- /dev/null +++ b/.github/scripts/luxiao_review.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Bridge one GitHub review job to the Luxiao Hermes profile safely.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import tempfile +import uuid +from pathlib import Path + + +HERMES_HOST = "hermesadmin@192.168.1.116" +REMOTE_SCRIPT = "/home/hermesadmin/scripts/luxiao-run.sh" +REMOTE_DIR = "/home/hermesadmin/.cache/luxiao-review" +SSH_OPTIONS = ("-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes") + + +def _write_result(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def main() -> int: + if len(sys.argv) != 3: + print("Usage: luxiao_review.py ", file=sys.stderr) + return 2 + + diff_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + if not diff_path.is_file(): + _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n⚠️ Diff 文件不存在。") + return 1 + + diff_text = diff_path.read_text(encoding="utf-8", errors="replace")[:8000] + if not diff_text.strip() or diff_text.strip() == "empty": + _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n✅ 无代码变更。") + return 0 + + prompt = f"""请审查以下 Pull Request。按照你的审查框架(架构、产品、规范、损伤 + 意图分析 + Merge 建议)给出完整审查报告。 + +## PR 信息 + +- 标题: {os.environ.get('PR_TITLE', '')} +- 描述: {os.environ.get('PR_BODY', '')} +- 变更: {os.environ.get('PR_FILES', '')} 个文件, +{os.environ.get('PR_ADDITIONS', '')} / -{os.environ.get('PR_DELETIONS', '')} + +## 代码 Diff + +{diff_text} + +请直接输出审查报告,不要多余的前缀。""" + + runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) + runner_temp.mkdir(parents=True, exist_ok=True) + remote_file = f"{REMOTE_DIR}/{uuid.uuid4().hex}.txt" + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", prefix="luxiao-prompt-", suffix=".txt", dir=runner_temp, delete=False + ) as prompt_file: + prompt_file.write(prompt) + local_file = Path(prompt_file.name) + + try: + subprocess.run( + ["ssh", *SSH_OPTIONS, HERMES_HOST, "mkdir", "-p", REMOTE_DIR], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + subprocess.run( + ["scp", *SSH_OPTIONS, str(local_file), f"{HERMES_HOST}:{remote_file}"], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + result = subprocess.run( + ["ssh", *SSH_OPTIONS, HERMES_HOST, f"{REMOTE_SCRIPT} {shlex.quote(remote_file)}"], + capture_output=True, + text=True, + timeout=300, + ) + finally: + local_file.unlink(missing_ok=True) + subprocess.run( + ["ssh", *SSH_OPTIONS, HERMES_HOST, "rm", "-f", "--", remote_file], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + if result.returncode != 0 or not result.stdout.strip(): + _write_result( + output_path, + "## 🤖 Luxiao PR 审查报告\n\n" + f"⚠️ Luxiao Agent 调用失败(退出码 {result.returncode})。", + ) + return 1 + + text = result.stdout + markers = ("🤖 PR 审查报告", "PR 审查报告", "## PR 审查", "## 审查报告") + for marker in markers: + if marker in text: + text = text[text.index(marker) :] + break + else: + text = text[-6000:] + _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n" + text) + print(f"Review saved to {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 989d7e2..89a54d1 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -24,13 +24,14 @@ jobs: echo "Diff lines: $(wc -l < "${diff_file}")" - name: Run Luxiao Review + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_ADDITIONS: ${{ github.event.pull_request.additions }} + PR_DELETIONS: ${{ github.event.pull_request.deletions }} + PR_FILES: ${{ github.event.pull_request.changed_files }} run: | python3 /home/runner-ci/scripts/luxiao-review.py \ - "${{ github.event.pull_request.title }}" \ - "${{ github.event.pull_request.body }}" \ - "${{ github.event.pull_request.additions }}" \ - "${{ github.event.pull_request.deletions }}" \ - "${{ github.event.pull_request.changed_files }}" \ "${DIFF_FILE}" \ "${RUNNER_TEMP}/review_result.md" diff --git a/tests/test_release.py b/tests/test_release.py index 70ae5b2..379ce89 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -112,6 +112,13 @@ def test_luxiao_review_uses_job_scoped_temporary_files() -> None: assert "${RUNNER_TEMP}/review_result.md" in workflow assert "/tmp/pr.diff" not in workflow assert "/tmp/review_result.md" not in workflow + assert "PR_BODY: ${{ github.event.pull_request.body }}" in workflow + assert '"${{ github.event.pull_request.body }}" \\' not in workflow + bridge = (ROOT / ".github" / "scripts" / "luxiao_review.py").read_text(encoding="utf-8") + assert "os.environ.get('PR_BODY', '')" in bridge + assert "NamedTemporaryFile" in bridge + assert 'REMOTE_DIR = "/home/hermesadmin/.cache/luxiao-review"' in bridge + assert 'local_file = "/tmp/luxiao_prompt.txt"' not in bridge def test_legacy_publish_workflow_is_removed() -> None: From 7986a5e4db5fc94c0edd2df49cb5e583523394cd Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 17:36:51 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(ci):=20=E5=AE=8C=E5=96=84=E9=99=86?= =?UTF-8?q?=E9=AA=81=E5=85=A8=E9=87=8F=E5=AE=A1=E6=9F=A5=E4=B8=8E=E7=A7=81?= =?UTF-8?q?=E6=9C=89=E8=BF=9E=E6=8E=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将陆骁审查输入上限提升到 100000 字符,并在超限时明确输出省略字符和行数,防止有限上下文被误判为全量审查。 Hermes 主机改由仓库变量注入,远端脚本与缓存目录支持 Runner 私有环境覆盖;审查任务同时匹配 sdk-ci 与 pr-review 标签,保持执行身份边界清晰。 补充契约测试,并已将同版本桥接脚本部署至 sdk-ci-103 Runner。 --- .github/scripts/luxiao_review.py | 42 ++++++++++++++++++++++++-------- .github/workflows/pr-review.yml | 3 ++- tests/test_release.py | 8 +++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/scripts/luxiao_review.py b/.github/scripts/luxiao_review.py index 15724db..d477d9b 100644 --- a/.github/scripts/luxiao_review.py +++ b/.github/scripts/luxiao_review.py @@ -12,9 +12,7 @@ from pathlib import Path -HERMES_HOST = "hermesadmin@192.168.1.116" -REMOTE_SCRIPT = "/home/hermesadmin/scripts/luxiao-run.sh" -REMOTE_DIR = "/home/hermesadmin/.cache/luxiao-review" +MAX_DIFF_CHARS = 100_000 SSH_OPTIONS = ("-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes") @@ -34,7 +32,31 @@ def main() -> int: _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n⚠️ Diff 文件不存在。") return 1 - diff_text = diff_path.read_text(encoding="utf-8", errors="replace")[:8000] + hermes_host = os.environ.get("LUXIAO_HERMES_HOST", "").strip() + remote_script = os.environ.get( + "LUXIAO_REMOTE_SCRIPT", "/home/hermesadmin/scripts/luxiao-run.sh" + ).strip() + remote_dir = os.environ.get( + "LUXIAO_REMOTE_DIR", "/home/hermesadmin/.cache/luxiao-review" + ).strip() + if not hermes_host: + _write_result( + output_path, + "## 🤖 Luxiao PR 审查报告\n\n⚠️ Runner 未配置 LUXIAO_HERMES_HOST。", + ) + return 1 + + full_diff = diff_path.read_text(encoding="utf-8", errors="replace") + diff_text = full_diff[:MAX_DIFF_CHARS] + truncation_notice = "" + if len(full_diff) > MAX_DIFF_CHARS: + omitted_chars = len(full_diff) - MAX_DIFF_CHARS + omitted_lines = full_diff[MAX_DIFF_CHARS:].count("\n") + truncation_notice = ( + "\n\n> ⚠️ Diff 过大,本次输入已明确截断:" + f"省略 {omitted_chars} 个字符、约 {omitted_lines} 行。" + "审查结论必须注明未覆盖范围,不能宣称完成全量审查。" + ) if not diff_text.strip() or diff_text.strip() == "empty": _write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n✅ 无代码变更。") return 0 @@ -49,13 +71,13 @@ def main() -> int: ## 代码 Diff -{diff_text} +{diff_text}{truncation_notice} 请直接输出审查报告,不要多余的前缀。""" runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) runner_temp.mkdir(parents=True, exist_ok=True) - remote_file = f"{REMOTE_DIR}/{uuid.uuid4().hex}.txt" + remote_file = f"{remote_dir}/{uuid.uuid4().hex}.txt" with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", prefix="luxiao-prompt-", suffix=".txt", dir=runner_temp, delete=False ) as prompt_file: @@ -64,21 +86,21 @@ def main() -> int: try: subprocess.run( - ["ssh", *SSH_OPTIONS, HERMES_HOST, "mkdir", "-p", REMOTE_DIR], + ["ssh", *SSH_OPTIONS, hermes_host, "mkdir", "-p", remote_dir], check=True, capture_output=True, text=True, timeout=30, ) subprocess.run( - ["scp", *SSH_OPTIONS, str(local_file), f"{HERMES_HOST}:{remote_file}"], + ["scp", *SSH_OPTIONS, str(local_file), f"{hermes_host}:{remote_file}"], check=True, capture_output=True, text=True, timeout=30, ) result = subprocess.run( - ["ssh", *SSH_OPTIONS, HERMES_HOST, f"{REMOTE_SCRIPT} {shlex.quote(remote_file)}"], + ["ssh", *SSH_OPTIONS, hermes_host, f"{remote_script} {shlex.quote(remote_file)}"], capture_output=True, text=True, timeout=300, @@ -86,7 +108,7 @@ def main() -> int: finally: local_file.unlink(missing_ok=True) subprocess.run( - ["ssh", *SSH_OPTIONS, HERMES_HOST, "rm", "-f", "--", remote_file], + ["ssh", *SSH_OPTIONS, hermes_host, "rm", "-f", "--", remote_file], capture_output=True, text=True, timeout=30, diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 89a54d1..936d88a 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -10,7 +10,7 @@ permissions: jobs: review: - runs-on: [self-hosted, Linux, X64, sdk-ci] + runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review] if: github.event.pull_request.draft == false steps: - name: Get PR Diff via API @@ -25,6 +25,7 @@ jobs: - name: Run Luxiao Review env: + LUXIAO_HERMES_HOST: ${{ vars.LUXIAO_HERMES_HOST }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} PR_ADDITIONS: ${{ github.event.pull_request.additions }} diff --git a/tests/test_release.py b/tests/test_release.py index 379ce89..e998023 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -117,8 +117,14 @@ def test_luxiao_review_uses_job_scoped_temporary_files() -> None: bridge = (ROOT / ".github" / "scripts" / "luxiao_review.py").read_text(encoding="utf-8") assert "os.environ.get('PR_BODY', '')" in bridge assert "NamedTemporaryFile" in bridge - assert 'REMOTE_DIR = "/home/hermesadmin/.cache/luxiao-review"' in bridge + assert '"LUXIAO_REMOTE_DIR", "/home/hermesadmin/.cache/luxiao-review"' in bridge assert 'local_file = "/tmp/luxiao_prompt.txt"' not in bridge + assert 'MAX_DIFF_CHARS = 100_000' in bridge + assert 'os.environ.get("LUXIAO_HERMES_HOST", "")' in bridge + assert "LUXIAO_HERMES_HOST: ${{ vars.LUXIAO_HERMES_HOST }}" in workflow + assert 'HERMES_HOST = "hermesadmin@192.168.1.116"' not in bridge + assert "审查结论必须注明未覆盖范围" in bridge + assert "runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review]" in workflow def test_legacy_publish_workflow_is_removed() -> None: From 34de9e54371e33da9ae3f87acd585f84209e5120 Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 18:19:02 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(release):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=91=E5=B8=83=E5=AE=A1=E6=9F=A5=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E4=B8=8E=E9=A2=84=E5=8F=91=E5=B8=83=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隔离可信版本编排与 PR 代码执行身份,关闭版本 PR 可重新发起;发布准备同时检查 Git Tag 和 GitHub Release,并在 GitHub 查询异常时失败关闭。 强制发布 Tag 指向版本 PR 的 merge commit,重跑时复用 Draft Release 中的原始制品并校验哈希,避免 TestPyPI 与后续制品不一致。 TestPyPI 验证先从正式 PyPI 安装依赖,再以 no-deps 下载目标 wheel 并比对哈希,消除依赖混淆风险;PEP 440 预发布版仅发布 TestPyPI 与 GitHub prerelease,不进入正式 PyPI 审批。 保留公司自托管 Runner 策略下的 Linux BLE 契约测试,并在发布文档中明确其不替代 Windows/macOS 实机验收。 --- .github/workflows/prepare-release.yml | 5 +- .github/workflows/release.yml | 76 +++++++++++++++---- .github/workflows/sdk-ci.yml | 2 + docs/releasing.md | 14 ++-- tests/test_release.py | 9 ++- .../tools/test_check_release_availability.py | 50 ++++++++++-- tests/tools/test_check_release_gate.py | 21 +++++ tools/check_release_availability.py | 32 ++++++-- tools/check_release_gate.py | 20 ++++- 9 files changed, 191 insertions(+), 38 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 217dc95..34f8c9c 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -28,7 +28,8 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main') - runs-on: [self-hosted, Linux, X64, sdk-ci] + # Trusted orchestration never shares an execution identity with pull-request code. + runs-on: [self-hosted, Linux, X64, sdk-orchestrator] steps: - name: Resolve one explicit release request id: release @@ -124,7 +125,7 @@ jobs: version=$(python tools/prepare_release.py "${arguments[@]}") python tools/check_release_availability.py "${version}" --repository "${GITHUB_REPOSITORY}" branch="release/watcherobot-${version}" - existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state all --json number --jq 'length') + existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state open --json number --jq 'length') if [[ "${existing}" != "0" ]]; then echo "Release PR already exists for ${version}; nothing to do." exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13d8f1b..8f9da3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,7 @@ jobs: version: ${{ steps.gate.outputs.version }} prerelease: ${{ steps.gate.outputs.prerelease }} commit: ${{ steps.gate.outputs.commit }} + reuse-artifact: ${{ steps.gate.outputs.reuse-artifact }} permissions: contents: read pull-requests: read @@ -54,18 +55,17 @@ jobs: GH_TOKEN: ${{ github.token }} run: | tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") - version=$(python tools/check_release_gate.py \ + gate_json=$(python tools/check_release_gate.py \ --repository "${GITHUB_REPOSITORY}" \ --tag "${GITHUB_REF_NAME}" \ --sha "${tag_commit}") + version=$(python -c 'import json,sys; print(json.load(sys.stdin)["version"])' <<< "${gate_json}") + reuse_artifact=$(python -c 'import json,sys; print(str(json.load(sys.stdin)["reuse_artifact"]).lower())' <<< "${gate_json}") echo "version=${version}" >> "${GITHUB_OUTPUT}" echo "commit=${tag_commit}" >> "${GITHUB_OUTPUT}" + echo "reuse-artifact=${reuse_artifact}" >> "${GITHUB_OUTPUT}" python tools/check_release_version.py "${GITHUB_REF_NAME}" - if [[ "${version}" =~ (a|b|rc)[0-9]+$ ]]; then - echo "prerelease=true" >> "${GITHUB_OUTPUT}" - else - echo "prerelease=false" >> "${GITHUB_OUTPUT}" - fi + python -c 'from packaging.version import Version; import sys; print(f"prerelease={str(Version(sys.argv[1]).is_prerelease).lower()}")' "${version}" >> "${GITHUB_OUTPUT}" build: name: Build immutable distributions @@ -73,20 +73,29 @@ jobs: runs-on: [self-hosted, Linux, X64, sdk-release] steps: - uses: actions/checkout@v6 + if: needs.gate.outputs.reuse-artifact != 'true' with: fetch-depth: 0 persist-credentials: false - uses: actions/setup-python@v6 + if: needs.gate.outputs.reuse-artifact != 'true' with: python-version: "3.12" cache: pip - - run: python -m pip install --upgrade pip build twine - - run: python -m pip install -e ".[test]" - - run: python -m pytest - - run: python -m mypy src/watcherobot - - run: python -m build - - run: python -m twine check dist/* + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m pip install --upgrade pip build twine + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m pip install -e ".[test]" + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m pytest + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m mypy src/watcherobot + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m build + - if: needs.gate.outputs.reuse-artifact != 'true' + run: python -m twine check dist/* - name: Verify wheel installation + if: needs.gate.outputs.reuse-artifact != 'true' run: | python -m venv .venv-wheel-check .venv-wheel-check/bin/python -m pip install --force-reinstall dist/*.whl @@ -94,7 +103,17 @@ jobs: .venv-wheel-check/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'" .venv-wheel-check/bin/watcherobot --help - name: Record distribution hashes + if: needs.gate.outputs.reuse-artifact != 'true' run: sha256sum dist/* > SHA256SUMS + - name: Recover immutable distributions from the matching draft Release + if: needs.gate.outputs.reuse-artifact == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p dist + gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --pattern 'watcherobot-*' --dir dist + gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --pattern SHA256SUMS --dir . + sha256sum --check SHA256SUMS - uses: actions/upload-artifact@v7 with: name: watcherobot-${{ needs.gate.outputs.version }} @@ -157,25 +176,35 @@ jobs: verify-testpypi: name: Verify TestPyPI installation - needs: [gate, publish-testpypi] + needs: [gate, build, publish-testpypi] runs-on: [self-hosted, Linux, X64, sdk-release] steps: - uses: actions/setup-python@v6 with: python-version: "3.12" + - uses: actions/download-artifact@v8 + with: + name: watcherobot-${{ needs.gate.outputs.version }} + path: artifact/ - name: Install exact release from TestPyPI run: | python -m venv .venv-testpypi + .venv-testpypi/bin/python -m pip install --index-url https://pypi.org/simple/ artifact/dist/*.whl + .venv-testpypi/bin/python -m pip uninstall -y watcherobot for attempt in {1..12}; do - if .venv-testpypi/bin/python -m pip install \ + if .venv-testpypi/bin/python -m pip download \ --index-url https://test.pypi.org/simple/ \ - --extra-index-url https://pypi.org/simple/ \ + --no-deps --only-binary=:all: --dest testpypi-download \ "watcherobot==${{ needs.gate.outputs.version }}"; then break fi if [[ "${attempt}" == "12" ]]; then exit 1; fi sleep 10 done + expected_wheel=$(find artifact/dist -maxdepth 1 -name '*.whl' -printf '%f\n') + test "$(sha256sum "testpypi-download/${expected_wheel}" | cut -d' ' -f1)" = \ + "$(sha256sum "artifact/dist/${expected_wheel}" | cut -d' ' -f1)" + .venv-testpypi/bin/python -m pip install --no-deps "testpypi-download/${expected_wheel}" .venv-testpypi/bin/python -m pip check .venv-testpypi/bin/python -c "import watcherobot; assert watcherobot.__version__ == '${{ needs.gate.outputs.version }}'" .venv-testpypi/bin/watcherobot --help @@ -189,6 +218,7 @@ jobs: url: https://pypi.org/p/watcherobot permissions: id-token: write + if: needs.gate.outputs.prerelease == 'false' steps: - uses: actions/download-artifact@v8 with: @@ -206,6 +236,7 @@ jobs: runs-on: [self-hosted, Linux, X64, sdk-release] permissions: contents: write + if: needs.gate.outputs.prerelease == 'false' steps: - uses: actions/setup-python@v6 with: @@ -226,10 +257,23 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false + finalize-prerelease: + name: Publish verified TestPyPI prerelease + needs: [gate, verify-testpypi] + if: needs.gate.outputs.prerelease == 'true' + runs-on: [self-hosted, Linux, X64, sdk-release] + permissions: + contents: write + steps: + - name: Publish GitHub prerelease after TestPyPI verification + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --draft=false --prerelease + finish-clean: name: Clean release workspace after use if: always() - needs: [gate, build, draft-release, publish-testpypi, verify-testpypi, publish-pypi, verify-pypi] + needs: [gate, build, draft-release, publish-testpypi, verify-testpypi, publish-pypi, verify-pypi, finalize-prerelease] runs-on: [self-hosted, Linux, X64, sdk-release] steps: - name: Remove checked-out code, environments and downloaded artifacts diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml index 2994b6c..3f8851d 100644 --- a/.github/workflows/sdk-ci.yml +++ b/.github/workflows/sdk-ci.yml @@ -68,6 +68,8 @@ jobs: ble-provisioning: name: BLE fake backend + # CI executes on company-owned runners by policy. This Linux contract test + # covers the fake backend without reintroducing GitHub-hosted runners. runs-on: [self-hosted, Linux, X64, sdk-ci] steps: - uses: actions/checkout@v6 diff --git a/docs/releasing.md b/docs/releasing.md index 8fb6194..ff4123c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -42,6 +42,9 @@ PR 和 `main` push 由 `.github/workflows/sdk-ci.yml` 在自托管 `sdk-ci` Runn - pytest、BLE fake backend、mypy; - wheel/sdist 构建、`twine check`、安装和 `pip check`。 +BLE fake backend 按公司自托管 Runner 策略在 Linux 执行契约测试;不使用 GitHub 托管的 Windows/macOS Runner。 +这项门禁验证导入和 fake backend 行为,不替代 Windows/macOS 实机蓝牙验收。 + 合法 Tag 由 `.github/workflows/release.yml` 在隔离的 `sdk-release` Runner 上执行。wheel 与 sdist 只构建一次, 随后生成 `SHA256SUMS` 并上传为 GitHub Actions Artifact。TestPyPI 与 PyPI 下载并使用同一份 Artifact,正式发布前 再次验证哈希,不重新构建。 @@ -50,11 +53,12 @@ PR 和 `main` push 由 `.github/workflows/sdk-ci.yml` 在自托管 `sdk-ci` Runn 1. 发布并验证 TestPyPI; 2. 创建 Draft GitHub Release; -3. 飞书群收到待审批提醒; -4. 负责人在 GitHub `pypi` Environment 批准; -5. 使用原始 Artifact 发布 PyPI; -6. 从正式 PyPI 安装验证; -7. 将 Draft GitHub Release 转为已发布状态。 +3. 预发布版直接发布 GitHub prerelease,到此结束,不进入正式 PyPI; +4. 只有稳定版才由飞书群收到正式发布待审批提醒; +5. 负责人在 GitHub `pypi` Environment 批准; +6. 使用原始 Artifact 发布 PyPI; +7. 从正式 PyPI 安装验证; +8. 将 Draft GitHub Release 转为已发布状态。 正式审批默认等待七天。陆骁监视器会在超时后取消运行并标记为 `CANCELLED`,不会自动恢复。陆骁无权合并版本 PR,也无权批准 `pypi` Environment。 diff --git a/tests/test_release.py b/tests/test_release.py index e998023..d7d7a90 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -50,7 +50,11 @@ def test_release_workflow_separates_test_and_production_indexes() -> None: assert "name: Clean release workspace after use" in workflow assert "artifact/dist/*" in workflow assert "--index-url https://test.pypi.org/simple/" in workflow - assert "--extra-index-url https://pypi.org/simple/" in workflow + assert "--extra-index-url https://pypi.org/simple/" not in workflow + assert "--no-deps --only-binary=:all:" in workflow + assert "needs.gate.outputs.prerelease == 'false'" in workflow + assert "needs.gate.outputs.prerelease == 'true'" in workflow + assert "finalize-prerelease:" in workflow assert "gh release create" in workflow assert "--draft" in workflow assert "gh release edit" in workflow @@ -143,5 +147,6 @@ def test_prepare_release_uses_repository_scoped_github_app() -> None: assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in workflow assert "workflow_dispatch:" in workflow assert "tools/check_release_availability.py" in workflow - assert "runs-on: [self-hosted, Linux, X64, sdk-ci]" in workflow + assert "runs-on: [self-hosted, Linux, X64, sdk-orchestrator]" in workflow assert "runs-on: [self-hosted, Linux, X64, sdk-release]" not in workflow + assert '--state open' in workflow diff --git a/tests/tools/test_check_release_availability.py b/tests/tools/test_check_release_availability.py index 32ab39d..f761c82 100644 --- a/tests/tools/test_check_release_availability.py +++ b/tests/tools/test_check_release_availability.py @@ -22,15 +22,21 @@ def _load_module(): def test_absent_canonical_version_is_accepted() -> None: module = _load_module() - module.validate_absent("0.1.1a3", pypi_status=404, testpypi_status=404, release_exists=False) + module.validate_absent( + "0.1.1a3", + pypi_status=404, + testpypi_status=404, + release_exists=False, + tag_exists=False, + ) @pytest.mark.parametrize( - ("pypi_status", "testpypi_status", "release_exists"), - [(200, 404, False), (404, 200, False), (404, 404, True)], + ("pypi_status", "testpypi_status", "release_exists", "tag_exists"), + [(200, 404, False, False), (404, 200, False, False), (404, 404, True, False), (404, 404, False, True)], ) def test_existing_version_is_rejected( - pypi_status: int, testpypi_status: int, release_exists: bool + pypi_status: int, testpypi_status: int, release_exists: bool, tag_exists: bool ) -> None: module = _load_module() with pytest.raises(ValueError, match="already exists"): @@ -39,10 +45,44 @@ def test_existing_version_is_rejected( pypi_status=pypi_status, testpypi_status=testpypi_status, release_exists=release_exists, + tag_exists=tag_exists, ) def test_noncanonical_pep440_version_is_rejected() -> None: module = _load_module() with pytest.raises(ValueError, match="canonical"): - module.validate_absent("0.1.1-alpha3", pypi_status=404, testpypi_status=404, release_exists=False) + module.validate_absent( + "0.1.1-alpha3", + pypi_status=404, + testpypi_status=404, + release_exists=False, + tag_exists=False, + ) + + +def test_github_lookup_fails_closed_for_operational_errors(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_module() + + class Result: + returncode = 1 + stdout = "" + stderr = "HTTP 500: internal server error" + + monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: Result()) + with pytest.raises(RuntimeError, match="GitHub lookup failed"): + module.github_resource_exists("orulink-ai/WatcheRobot_python_sdk", "releases/tags/v0.1.1a4") + + +def test_github_lookup_accepts_only_verified_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_module() + + class Result: + returncode = 1 + stdout = "" + stderr = "gh: Not Found (HTTP 404)" + + monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: Result()) + assert not module.github_resource_exists( + "orulink-ai/WatcheRobot_python_sdk", "git/ref/tags/v0.1.1a4" + ) diff --git a/tests/tools/test_check_release_gate.py b/tests/tools/test_check_release_gate.py index d14d91d..e57723c 100644 --- a/tests/tools/test_check_release_gate.py +++ b/tests/tools/test_check_release_gate.py @@ -39,6 +39,8 @@ def test_release_version_pr_contract_is_strict() -> None: labels=["release:version"], head_ref="release/watcherobot-0.1.1a3", version="0.1.1a3", + merge_commit_sha="abc123", + tag_sha="abc123", ) with pytest.raises(ValueError, match="merged into main"): module.validate_version_pull_request( @@ -47,6 +49,8 @@ def test_release_version_pr_contract_is_strict() -> None: labels=["release:version"], head_ref="release/watcherobot-0.1.1a3", version="0.1.1a3", + merge_commit_sha="abc123", + tag_sha="abc123", ) with pytest.raises(ValueError, match="release:version"): module.validate_version_pull_request( @@ -55,6 +59,23 @@ def test_release_version_pr_contract_is_strict() -> None: labels=[], head_ref="release/watcherobot-0.1.1a3", version="0.1.1a3", + merge_commit_sha="abc123", + tag_sha="abc123", + ) + + +def test_release_tag_must_target_version_pr_merge_commit() -> None: + module = _load_module() + + with pytest.raises(ValueError, match="merge commit"): + module.validate_version_pull_request( + merged=True, + base_ref="main", + labels=["release:version"], + head_ref="release/watcherobot-0.1.1a4", + version="0.1.1a4", + merge_commit_sha="merge123", + tag_sha="bump123", ) diff --git a/tools/check_release_availability.py b/tools/check_release_availability.py index a499372..e451f58 100644 --- a/tools/check_release_availability.py +++ b/tools/check_release_availability.py @@ -20,7 +20,14 @@ def http_status(url: str) -> int: return error.code -def validate_absent(version: str, *, pypi_status: int, testpypi_status: int, release_exists: bool) -> None: +def validate_absent( + version: str, + *, + pypi_status: int, + testpypi_status: int, + release_exists: bool, + tag_exists: bool, +) -> None: canonical = str(Version(version)) if canonical != version: raise ValueError(f"version {version!r} is not canonical PEP 440 ({canonical!r})") @@ -31,21 +38,36 @@ def validate_absent(version: str, *, pypi_status: int, testpypi_status: int, rel existing.append("TestPyPI") if release_exists: existing.append("GitHub Release") + if tag_exists: + existing.append("Git tag") if existing: raise ValueError(f"watcherobot {version} already exists on {', '.join(existing)}") -def check(version: str, repository: str) -> None: - release = subprocess.run( - ["gh", "release", "view", f"v{version}", "--repo", repository, "--json", "tagName"], +def github_resource_exists(repository: str, resource: str) -> bool: + """Return False only for a verified GitHub 404; fail closed otherwise.""" + + result = subprocess.run( + ["gh", "api", f"repos/{repository}/{resource}"], text=True, capture_output=True, ) + if result.returncode == 0: + return True + diagnostic = f"{result.stdout}\n{result.stderr}" + if "HTTP 404" in diagnostic: + return False + raise RuntimeError(f"GitHub lookup failed for {resource}: {diagnostic.strip()}") + + +def check(version: str, repository: str) -> None: + tag = f"v{version}" validate_absent( version, pypi_status=http_status(f"https://pypi.org/pypi/watcherobot/{version}/json"), testpypi_status=http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), - release_exists=release.returncode == 0, + release_exists=github_resource_exists(repository, f"releases/tags/{tag}"), + tag_exists=github_resource_exists(repository, f"git/ref/tags/{tag}"), ) diff --git a/tools/check_release_gate.py b/tools/check_release_gate.py index 6c50e2c..69c3472 100644 --- a/tools/check_release_gate.py +++ b/tools/check_release_gate.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from dataclasses import dataclass import json import subprocess import sys @@ -26,6 +27,12 @@ } +@dataclass(frozen=True) +class ReleaseGateResult: + version: str + reuse_artifact: bool + + def select_release_label(labels: Iterable[str]) -> str: matches = [_REQUEST_LABELS[label] for label in labels if label in _REQUEST_LABELS] if len(matches) != 1: @@ -40,6 +47,8 @@ def validate_version_pull_request( labels: Iterable[str], head_ref: str, version: str, + merge_commit_sha: str, + tag_sha: str, ) -> None: if not merged or base_ref != "main": raise ValueError("release version PR must be merged into main") @@ -48,6 +57,8 @@ def validate_version_pull_request( expected_head = f"release/watcherobot-{version}" if head_ref != expected_head: raise ValueError(f"release version PR head must be {expected_head!r}") + if merge_commit_sha != tag_sha: + raise ValueError("release tag must target the version PR merge commit") def validate_version_absent( @@ -103,7 +114,7 @@ def _associated_pull_requests(repository: str, sha: str) -> list[dict[str, objec return value -def validate_gate(*, repository: str, tag: str, sha: str) -> str: +def validate_gate(*, repository: str, tag: str, sha: str) -> ReleaseGateResult: version = validate_release_tag(tag, read_package_version()) tag_type = _run("git", "cat-file", "-t", tag) if tag_type != "tag": @@ -125,6 +136,8 @@ def validate_gate(*, repository: str, tag: str, sha: str) -> str: labels=[str(label) for label in labels if label], head_ref=str((pull_request.get("head") or {}).get("ref") or ""), version=version, + merge_commit_sha=str(pull_request.get("merge_commit_sha") or ""), + tag_sha=sha, ) except ValueError: continue @@ -164,7 +177,7 @@ def validate_gate(*, repository: str, tag: str, sha: str) -> str: _http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), allow_existing=reusable_draft, ) - return version + return ReleaseGateResult(version=version, reuse_artifact=reusable_draft) def main() -> int: @@ -173,7 +186,8 @@ def main() -> int: parser.add_argument("--tag", required=True) parser.add_argument("--sha", required=True) args = parser.parse_args() - print(validate_gate(repository=args.repository, tag=args.tag, sha=args.sha)) + result = validate_gate(repository=args.repository, tag=args.tag, sha=args.sha) + print(json.dumps({"version": result.version, "reuse_artifact": result.reuse_artifact})) return 0 From 14b91603f68ae733f8fa3c3f88021f8b620d9ad3 Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 18:36:26 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix(release):=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E5=88=B6=E5=93=81=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 release Artifact 名称绑定 github.run_attempt,保证同一次 Workflow 重跑恢复原始制品后能以本次唯一名称重新上传,并让后续 TestPyPI、PyPI 与 Release 任务引用同一份制品。 --- .github/workflows/release.yml | 10 +++++----- tests/test_release.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f9da3e..e025d16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,7 +116,7 @@ jobs: sha256sum --check SHA256SUMS - uses: actions/upload-artifact@v7 with: - name: watcherobot-${{ needs.gate.outputs.version }} + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} path: | dist/ SHA256SUMS @@ -132,7 +132,7 @@ jobs: steps: - uses: actions/download-artifact@v8 with: - name: watcherobot-${{ needs.gate.outputs.version }} + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} path: artifact/ - name: Create immutable draft Release env: @@ -162,7 +162,7 @@ jobs: steps: - uses: actions/download-artifact@v8 with: - name: watcherobot-${{ needs.gate.outputs.version }} + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} path: artifact/ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Publish immutable distributions to TestPyPI with OIDC @@ -184,7 +184,7 @@ jobs: python-version: "3.12" - uses: actions/download-artifact@v8 with: - name: watcherobot-${{ needs.gate.outputs.version }} + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} path: artifact/ - name: Install exact release from TestPyPI run: | @@ -222,7 +222,7 @@ jobs: steps: - uses: actions/download-artifact@v8 with: - name: watcherobot-${{ needs.gate.outputs.version }} + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} path: artifact/ - name: Verify artifact hashes before production upload run: (cd artifact && sha256sum --check SHA256SUMS) diff --git a/tests/test_release.py b/tests/test_release.py index d7d7a90..1c6c03f 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -41,6 +41,7 @@ def test_release_workflow_separates_test_and_production_indexes() -> None: assert "UV_PUBLISH_CHECK_URL: https://test.pypi.org/simple/" in workflow assert "actions/upload-artifact@v7" in workflow assert workflow.count("actions/download-artifact@v8") >= 3 + assert workflow.count("watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }}") >= 4 assert "runs-on: [self-hosted, Linux, X64, sdk-release]" in workflow assert "tools/check_release_gate.py" in workflow assert 'tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}")' in workflow From 44e54c01b66ae24705620d2a57a3df4420602391 Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 18:51:12 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(ci):=20=E9=9A=94=E7=A6=BB=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E4=BB=BB=E5=8A=A1=E7=9A=84=20Python=20?= =?UTF-8?q?=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为测试矩阵、BLE 假后端和制品构建任务分别创建全新虚拟环境,并通过 GITHUB_PATH 让后续步骤统一使用该环境。 避免持久化 Runner 上遗留的 twine 等工具污染最低依赖校验,同时增加工作流结构测试,确保三个任务持续保持环境隔离。 --- .github/workflows/sdk-ci.yml | 13 +++++++++++++ tests/test_release.py | 2 ++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml index 3f8851d..25ce3c9 100644 --- a/.github/workflows/sdk-ci.yml +++ b/.github/workflows/sdk-ci.yml @@ -31,6 +31,11 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip + - name: Create isolated virtual environment + run: | + python -m venv .venv + echo "$PWD/.venv/bin" >> "$GITHUB_PATH" + - name: Install SDK and test dependencies run: | python -m pip install --upgrade pip @@ -79,6 +84,10 @@ jobs: with: python-version: "3.12" cache: pip + - name: Create isolated virtual environment + run: | + python -m venv .venv + echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - run: python -m pip install -e ".[test]" - run: python -m pip check - run: python -c "from watcherobot.provisioning.bleak_backend import BleakBackend; print(BleakBackend)" @@ -97,6 +106,10 @@ jobs: with: python-version: "3.12" cache: pip + - name: Create isolated virtual environment + run: | + python -m venv .venv + echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - run: python -m pip install --upgrade build twine - run: python -m build - run: python -m twine check dist/* diff --git a/tests/test_release.py b/tests/test_release.py index 1c6c03f..58b8629 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -99,6 +99,8 @@ def test_publish_workflow_tests_supported_dependency_profiles_before_one_build() assert "needs: [test, ble-provisioning]" in workflow assert "python -m pip install --force-reinstall dist/*.whl" in workflow assert "python -m pip check" in workflow + assert workflow.count("name: Create isolated virtual environment") == 3 + assert 'echo "$PWD/.venv/bin" >> "$GITHUB_PATH"' in workflow def test_fake_ble_tests_run_on_self_hosted_linux() -> None: From 28959186b6d980dc77b9c63ac7318622faf8811b Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 18:55:46 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(review):=20=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=9A=84=E9=99=86=E9=AA=81=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将远端审查预算调整为十分钟,并由远端 timeout 在到期后先发送 TERM、三十秒后强制结束,防止本地 SSH 超时留下孤儿 Agent。 本地桥接超时略晚于远端门限,确保能够接收远端退出状态;增加结构测试锁定双层超时与进程回收策略。 --- .github/scripts/luxiao_review.py | 15 +++++++++++++-- tests/test_release.py | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/scripts/luxiao_review.py b/.github/scripts/luxiao_review.py index d477d9b..2364085 100644 --- a/.github/scripts/luxiao_review.py +++ b/.github/scripts/luxiao_review.py @@ -13,6 +13,7 @@ MAX_DIFF_CHARS = 100_000 +REMOTE_REVIEW_TIMEOUT_SECONDS = 600 SSH_OPTIONS = ("-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes") @@ -100,10 +101,20 @@ def main() -> int: timeout=30, ) result = subprocess.run( - ["ssh", *SSH_OPTIONS, hermes_host, f"{remote_script} {shlex.quote(remote_file)}"], + [ + "ssh", + *SSH_OPTIONS, + hermes_host, + "timeout", + "--signal=TERM", + "--kill-after=30s", + f"{REMOTE_REVIEW_TIMEOUT_SECONDS}s", + shlex.quote(remote_script), + shlex.quote(remote_file), + ], capture_output=True, text=True, - timeout=300, + timeout=REMOTE_REVIEW_TIMEOUT_SECONDS + 60, ) finally: local_file.unlink(missing_ok=True) diff --git a/tests/test_release.py b/tests/test_release.py index 58b8629..fa5b025 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -127,6 +127,10 @@ def test_luxiao_review_uses_job_scoped_temporary_files() -> None: assert '"LUXIAO_REMOTE_DIR", "/home/hermesadmin/.cache/luxiao-review"' in bridge assert 'local_file = "/tmp/luxiao_prompt.txt"' not in bridge assert 'MAX_DIFF_CHARS = 100_000' in bridge + assert 'REMOTE_REVIEW_TIMEOUT_SECONDS = 600' in bridge + assert '"timeout",' in bridge + assert '"--kill-after=30s",' in bridge + assert "timeout=REMOTE_REVIEW_TIMEOUT_SECONDS + 60" in bridge assert 'os.environ.get("LUXIAO_HERMES_HOST", "")' in bridge assert "LUXIAO_HERMES_HOST: ${{ vars.LUXIAO_HERMES_HOST }}" in workflow assert 'HERMES_HOST = "hermesadmin@192.168.1.116"' not in bridge From d9678668c417cdab705c6dffa82ad9a995fdc51b Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 19:28:38 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(test):=20=E8=A1=A5=E5=85=A8=20Python?= =?UTF-8?q?=203.10=20=E5=AD=90=E7=8E=AF=E5=A2=83=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon 多应用 Python 环境测试会创建无 pip 的最小虚拟环境;在 Python 3.10 下 websockets 14 仍依赖 typing_extensions,原夹具只注入 websockets 路径导致子进程启动失败。 将 typing_extensions 所在目录一并写入测试环境的 pth 文件,保持真实解释器切换测试的隔离语义,不修改 Daemon 业务路由。 --- tests/runtime/test_daemon_runtime_routing.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/runtime/test_daemon_runtime_routing.py b/tests/runtime/test_daemon_runtime_routing.py index 9ae16ef..40a4fb9 100644 --- a/tests/runtime/test_daemon_runtime_routing.py +++ b/tests/runtime/test_daemon_runtime_routing.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import typing_extensions import websockets from websockets.asyncio.client import connect @@ -135,7 +136,12 @@ def _create_test_python_environment(root: Path) -> Path: / "site-packages" ) site_packages.joinpath("watcher-test-dependencies.pth").write_text( - str(Path(websockets.__file__).resolve().parent.parent), + "\n".join( + ( + str(Path(websockets.__file__).resolve().parent.parent), + str(Path(typing_extensions.__file__).resolve().parent), + ) + ), encoding="utf-8", ) return executable.resolve() From b015abab0f5ee97c51d3d1dbb9753a32ec1f5a6a Mon Sep 17 00:00:00 2001 From: KID <2577235661@qq.com> Date: Wed, 12 Aug 2026 19:47:21 +0800 Subject: [PATCH 12/12] =?UTF-8?q?revert(test):=20=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=E6=97=A0=E6=95=88=E7=9A=84=20Python=203.10=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远端复现确认失败发生在解释器加载标准库之前:setup-python 二进制仍指向 /opt/hostedtoolcache,而自托管 Runner 缓存实际位于私有 _work/_tool。 typing_extensions 与本次失败无关,恢复原测试夹具,后续在 Runner 服务层修复工具缓存前缀,保持测试继续验证真实虚拟环境。 --- tests/runtime/test_daemon_runtime_routing.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/runtime/test_daemon_runtime_routing.py b/tests/runtime/test_daemon_runtime_routing.py index 40a4fb9..9ae16ef 100644 --- a/tests/runtime/test_daemon_runtime_routing.py +++ b/tests/runtime/test_daemon_runtime_routing.py @@ -8,7 +8,6 @@ from pathlib import Path import pytest -import typing_extensions import websockets from websockets.asyncio.client import connect @@ -136,12 +135,7 @@ def _create_test_python_environment(root: Path) -> Path: / "site-packages" ) site_packages.joinpath("watcher-test-dependencies.pth").write_text( - "\n".join( - ( - str(Path(websockets.__file__).resolve().parent.parent), - str(Path(typing_extensions.__file__).resolve().parent), - ) - ), + str(Path(websockets.__file__).resolve().parent.parent), encoding="utf-8", ) return executable.resolve()