diff --git a/.github/scripts/luxiao_review.py b/.github/scripts/luxiao_review.py new file mode 100644 index 0000000..2364085 --- /dev/null +++ b/.github/scripts/luxiao_review.py @@ -0,0 +1,151 @@ +#!/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 + + +MAX_DIFF_CHARS = 100_000 +REMOTE_REVIEW_TIMEOUT_SECONDS = 600 +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 + + 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 + + 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}{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" + 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, + "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=REMOTE_REVIEW_TIMEOUT_SECONDS + 60, + ) + 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 f063400..936d88a 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -10,29 +10,41 @@ permissions: jobs: review: - runs-on: [self-hosted, Linux, X64, pr-review] + runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review] if: github.event.pull_request.draft == false steps: - name: Get PR Diff via API 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 + 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 }} + PR_DELETIONS: ${{ github.event.pull_request.deletions }} + PR_FILES: ${{ github.event.pull_request.changed_files }} 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 \ + "${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/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..34f8c9c --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,166 @@ +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') + # 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 + 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 open --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..e025d16 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,288 @@ +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 }} + commit: ${{ steps.gate.outputs.commit }} + reuse-artifact: ${{ steps.gate.outputs.reuse-artifact }} + 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: | + tag_commit=$(git rev-list -n 1 "${GITHUB_REF_NAME}") + 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}" + 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 + needs: gate + 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 + - 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 + .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 + 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 }}-${{ github.run_attempt }} + 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 }}-${{ github.run_attempt }} + 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 "${{ needs.gate.outputs.commit }}" \ + --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 }}-${{ github.run_attempt }} + 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 + --publish-url https://test.pypi.org/legacy/ + artifact/dist/* + + verify-testpypi: + name: Verify TestPyPI installation + 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 }}-${{ github.run_attempt }} + 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 download \ + --index-url https://test.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 + + 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 + if: needs.gate.outputs.prerelease == 'false' + steps: + - uses: actions/download-artifact@v8 + with: + name: watcherobot-${{ needs.gate.outputs.version }}-${{ github.run_attempt }} + 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 + if: needs.gate.outputs.prerelease == 'false' + 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 + + 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, finalize-prerelease] + 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..25ce3c9 --- /dev/null +++ b/.github/workflows/sdk-ci.yml @@ -0,0 +1,120 @@ +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: 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 + 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 + # 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 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + 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)" + - 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 + - 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/* + - 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..ff4123c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,106 +1,121 @@ -# 发布 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` 中的版本。 +BLE fake backend 按公司自托管 Runner 策略在 Linux 执行契约测试;不使用 GitHub 托管的 Windows/macOS Runner。 +这项门禁验证导入和 fake backend 行为,不替代 Windows/macOS 实机蓝牙验收。 -使用全新虚拟环境验证 TestPyPI 产物: +合法 Tag 由 `.github/workflows/release.yml` 在隔离的 `sdk-release` Runner 上执行。wheel 与 sdist 只构建一次, +随后生成 `SHA256SUMS` 并上传为 GitHub Actions Artifact。TestPyPI 与 PyPI 下载并使用同一份 Artifact,正式发布前 +再次验证哈希,不重新构建。 -```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 -``` +## 发布顺序 -安装后还需按照[硬件测试说明](hardware-testing.md),使用验收记录中的同一固件完成 -Runtime 配对并运行受管 Application。若版本已经上传但验收失败,不得覆盖上传, -必须递增 Alpha 版本后重新发布。 +1. 发布并验证 TestPyPI; +2. 创建 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。 -1. 修改 `src/watcherobot/__init__.py` 中的版本并通过 PR 合入 `main`。 -2. 确认测试、构建、TestPyPI 安装和必要的真机验收全部通过。 -3. 创建 Draft GitHub Release,标签必须严格等于 `v` 加包版本: +## 一次性平台配置 -```powershell -gh release create v0.1.1a3 --target main --draft --prerelease --generate-notes -``` +GitHub App 仅安装到 `orulink-ai/WatcheRobot_python_sdk`,向仓库提供版本 PR、Tag 和 Actions 监视能力。 +工作流使用以下仓库 Secret 获取短期安装令牌: -4. 核对 Release 内容后发布: +- `ORULINK_RELEASE_APP_ID` +- `ORULINK_RELEASE_APP_PRIVATE_KEY` -```powershell -gh release edit v0.1.1a3 --draft=false -``` +PyPI Pending Trusted Publisher: -`release.published` 事件会启动正式发布任务。流水线会再次运行测试,并检查: +| 字段 | 值 | +|---|---| +| Project | `watcherobot` | +| Owner | `orulink-ai` | +| Repository | `WatcheRobot_python_sdk` | +| Workflow | `release.yml` | +| Environment | `pypi` | -- Release 标签与包版本完全一致。 -- Release 对应 commit 已经属于 `main`。 -- wheel 和 sdist 均能通过 `twine check`。 -- `pypi` Environment 已完成人工审批。 +TestPyPI 使用相同仓库与 Workflow,Environment 为 `testpypi`。GitHub `pypi` Environment 只允许 `v*` Tag, +并配置负责人为 Required Reviewer。 -发布完成后验证: +## 首次自动演练与验证 + +`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..fa5b025 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -25,56 +25,135 @@ 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 "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") == 2 + 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 + 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 + assert "artifact/dist/*" in workflow + assert "--index-url https://test.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 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 '"cat-file", "-t", tag' in gate + assert "release ref must be an annotated tag" 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 + 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_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_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 + 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 '"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 + assert "审查结论必须注明未覆盖范围" in bridge + assert "runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review]" 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 + 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 new file mode 100644 index 0000000..f761c82 --- /dev/null +++ b/tests/tools/test_check_release_availability.py @@ -0,0 +1,88 @@ +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, + tag_exists=False, + ) + + +@pytest.mark.parametrize( + ("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, tag_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, + 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, + 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 new file mode 100644 index 0000000..e57723c --- /dev/null +++ b/tests/tools/test_check_release_gate.py @@ -0,0 +1,126 @@ +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", + merge_commit_sha="abc123", + tag_sha="abc123", + ) + 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", + merge_commit_sha="abc123", + tag_sha="abc123", + ) + 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", + 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", + ) + + +@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..e451f58 --- /dev/null +++ b/tools/check_release_availability.py @@ -0,0 +1,85 @@ +"""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, + tag_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 tag_exists: + existing.append("Git tag") + if existing: + raise ValueError(f"watcherobot {version} already exists on {', '.join(existing)}") + + +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=github_resource_exists(repository, f"releases/tags/{tag}"), + tag_exists=github_resource_exists(repository, f"git/ref/tags/{tag}"), + ) + + +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..69c3472 --- /dev/null +++ b/tools/check_release_gate.py @@ -0,0 +1,195 @@ +"""Fail closed unless a release tag satisfies the repository release contract.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +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", +} + + +@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: + 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, + 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") + 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}") + if merge_commit_sha != tag_sha: + raise ValueError("release tag must target the version PR merge commit") + + +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) -> ReleaseGateResult: + 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") + + 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, + merge_commit_sha=str(pull_request.get("merge_commit_sha") or ""), + tag_sha=sha, + ) + 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 ReleaseGateResult(version=version, reuse_artifact=reusable_draft) + + +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() + 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 + + +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())