diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c0c34d..3018e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,31 @@ on: pull_request: branches: [main] + workflow_call: + inputs: + ref: + type: string + required: true + +permissions: + contents: read + jobs: sensitive-identifiers: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - name: Scan tracked files for account and conversation identifiers - run: python scripts/check_sensitive_identifiers.py + run: | + python scripts/check_sensitive_identifiers.py + python scripts/release_metadata.py --check test: runs-on: ubuntu-latest @@ -23,14 +38,18 @@ jobs: matrix: python-version: ["3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + fetch-depth: 0 - name: Install Node.js (for execjs) - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "20" + node-version: "24" - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} @@ -49,11 +68,15 @@ jobs: run: uv run pytest -q build: - needs: [test, sensitive-identifiers] + needs: [test, sensitive-identifiers, openclaw-plugin] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - name: Build sdist + wheel @@ -61,7 +84,7 @@ jobs: pip install build python -m build - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist path: dist/ @@ -69,9 +92,13 @@ jobs: openclaw-plugin: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version: "24" - name: Validate OpenClaw plugin package run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c509af..dda6d0b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,41 +2,99 @@ name: Publish to PyPI on: push: - tags: - - "v*.*.*" + tags: ['v*.*.*'] workflow_dispatch: +permissions: + contents: read + +concurrency: + group: pypi-${{ github.ref }} + cancel-in-progress: false + jobs: - build: + release-ref: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.11" - - name: Build sdist + wheel + python-version: '3.11' + - name: Require a matching stable release tag + env: + RELEASE_REF: ${{ github.ref }} + RELEASE_TAG: ${{ github.ref_name }} run: | - pip install build - python -m build - - name: Upload dist - uses: actions/upload-artifact@v4 + [[ "$RELEASE_REF" == refs/tags/v* ]] || { echo 'Publishing requires a version tag'; exit 1; } + python scripts/release_metadata.py --check --tag "$RELEASE_TAG" + git merge-base --is-ancestor HEAD origin/main + checks: + needs: release-ref + uses: ./.github/workflows/ci.yml + with: + ref: ${{ github.ref }} + existing-artifacts: + needs: checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist - path: dist/ - + path: dist + - name: Reject conflicting already published bytes + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python scripts/verify_pypi_release.py "${RELEASE_TAG#v}" --dist dist --existing-only publish: - needs: build + needs: existing-artifacts runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/project/goofish-cli/ permissions: + contents: read id-token: write steps: - - name: Download dist - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist - path: dist/ - - name: Publish to PyPI (Trusted Publisher) - uses: pypa/gh-action-pypi-publish@release/v1 + path: dist + - name: Publish checked distributions via OIDC + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + skip-existing: true + verify: + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist + - name: Install release verification runtime + run: | + pip install uv + uv venv + uv pip install 'mcp>=1.2,<2' + - name: Verify actual PyPI CLI, MCP and artifact hashes + env: + RELEASE_TAG: ${{ github.ref_name }} + run: uv run python scripts/verify_pypi_release.py "${RELEASE_TAG#v}" --dist dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..051cc35 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Automatic PyPI release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: automatic-pypi-release + cancel-in-progress: false + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + actions: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare version and changelog PR + id: plan + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + with: + target-branch: main + skip-github-release: true + - name: Validate generated PR identity + if: steps.plan.outputs.pr != '' + id: candidate + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PR: ${{ steps.plan.outputs.pr }} + run: python scripts/release_candidate.py prepare + - name: Check out immutable candidate + if: steps.candidate.outputs.sha != '' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.candidate.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + - name: Restrict candidate to release metadata + if: steps.candidate.outputs.sha != '' + env: + RELEASE_BASE_SHA: ${{ steps.candidate.outputs.base }} + RELEASE_VERSION: ${{ steps.candidate.outputs.version }} + run: python scripts/release_metadata.py --check --base "$RELEASE_BASE_SHA" --tag "v$RELEASE_VERSION" + - name: Validate release source + run: | + pip install uv + uv venv + uv pip install -e '.[dev]' + uv run pytest -q + uv run ruff check src tests + python scripts/check_sensitive_identifiers.py + python scripts/release_metadata.py --check + npm test + - name: Merge verified metadata PR + if: steps.candidate.outputs.sha != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PR_NUMBER: ${{ steps.candidate.outputs.number }} + RELEASE_PR_SHA: ${{ steps.candidate.outputs.sha }} + RELEASE_BASE_SHA: ${{ steps.candidate.outputs.base }} + run: python scripts/release_candidate.py merge + - name: Create release and tag + id: publish + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + with: + target-branch: main + skip-github-pull-request: true + - name: Dispatch trusted PyPI publisher + if: steps.publish.outputs.release_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.publish.outputs.tag_name }} + run: gh workflow run publish.yml --repo "$GITHUB_REPOSITORY" --ref "$RELEASE_TAG" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..f1c1e58 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.5.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cba1c9a..c2d206e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,10 @@ clawhub package publish ./openclaw-goofish-.tgz \ "我只是小改" 也不能跳过。参考 [真实验证准则](./docs/architecture.md#验证准则)。 +## 版本与发布 + +PyPI 由程序自动维护版本 PR、验证、合并、生成 tag 并发布;ClawHub 保持手动。触发规则、权限和失败恢复见 [发布流程](docs/releases.md)。新增自动化不能依赖个人 token,也不能跳过 tag、工件哈希或 PyPI 实装验证。 + ## 加命令:典型流程 1. 在 `src/goofish_cli/commands//.py` 写一个函数 diff --git a/README.md b/README.md index da89d55..292d10d 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ claude /plugin marketplace add fancyboi999/goofish-cli OpenClaw `2026.6.1` 及以上可把本仓库作为 compatible bundle 加载。已发布到 [ClawHub](https://clawhub.ai/plugins/openclaw-goofish),安装: + ```bash openclaw plugins install clawhub:openclaw-goofish @@ -143,6 +144,7 @@ uvx --from goofish-cli==0.5.0 goofish auth login --qr openclaw plugins inspect goofish --json openclaw gateway restart ``` + 本地开发无需发布: diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md index bb67c2c..ebc5a67 100644 --- a/docs/mcp-setup.md +++ b/docs/mcp-setup.md @@ -6,12 +6,14 @@ ClawHub 发布后安装: + ```bash openclaw plugins install clawhub:openclaw-goofish uvx --from goofish-cli==0.5.0 goofish auth login --qr openclaw plugins inspect goofish --json openclaw gateway restart ``` + 本地开发使用 link 安装: diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..80492a1 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,37 @@ +# 发布 + +PyPI 自动发布,ClawHub 手动发布。业务 PR 合入 main 后,release-please 根据 Conventional Commits 判断下一版本,维护发布 PR。程序只合并机器人生成、通过检查且仅修改版本、安装示例和 CHANGELOG 的发布 PR;生成 tag/GitHub Release 后自动启动 PyPI 发布。无需人工改版本、推 tag 或上传 Python 包。 + +`fix:` 通常递增 patch,`feat:` 通常递增 minor。0.x 的破坏性变更递增 minor。仓库的 Python、插件元数据、MCP 精确版本锁定和安装文档由同一发布 PR 同步;当前 ClawHub 上的旧 bundle 仍锁定其已发布 Python 版本,不会随 PyPI 自动更新。 + +## 流程与权限 + +`.github/workflows/release.yml` 使用仓库自己的 GITHUB_TOKEN 创建和合并发布 PR,不保存个人访问 token。仓库 Settings → Actions → General 必须允许 GitHub Actions 创建 PR;程序不会绕过 branch protection 或人工审核规则。后续若收紧 main 的规则,自动合并受到相同限制。 + +发布 PR 验证按完整 SHA 执行。非机器人、外部仓库、非 main 基线或版本文件范围外的 PR 不进入自动合并;版本 JSON/TOML 中除版本外的内容、MCP 其他参数以及文档非版本内容也不得改变。检查后候选或 main 更新会中止合并,下一次执行重新生成并验证。 + +GITHUB_TOKEN 创建的 tag 不会触发另一个 push workflow,所以程序显式 dispatch `publish.yml` 到版本 tag;不依赖 tag 事件级联。发布始终由 `publish.yml` 执行,保留既有 PyPI Trusted Publisher 的 workflow 身份。 + +## 上传与回读 + +`publish.yml` 只接受与元数据一致的稳定版本 tag,并要求该提交属于 main 历史。对应 tag 必须通过 Python 3.11/3.12、lint、敏感标识扫描、版本契约、插件打包检查和构建,才进入上传。手工推 tag 和手工运行 publisher 也受这些检查约束;不能从 main 分支直接运行 publisher 上传。 + +上传前对照 PyPI 已存在文件的 SHA256;不一致则拒绝。上传使用专用 pypi environment 和 OIDC,无长期 PyPI API token。只有上传 job 有 id-token:write;安装验证 job 没有该权限。 + +上传后从 PyPI 安装精确版本,验证 CLI 版本、MCP 握手和核心工具目录,并对照发布工件哈希。索引尚未可见时有限重试;失败明确保留失败状态,不能把上传成功当成安装验证成功。 + +## 恢复 + +自动发布流程按仓库串行,publisher 按 tag 串行。发布准备、合并、生成 tag、上传、安装验证是独立阶段。查看两个 workflow 的实际结论,不以 GitHub Release 或 tag 存在代替 PyPI 成功。 + +同一发布运行可重跑失败 job。已上传文件仅在哈希与检查过的产物一致时跳过;缺失文件继续上传。产物冲突不能用删 tag、覆盖文件或再次 bump 版本掩盖。若 tag/release 已生成而发布未启动,可执行: + +```bash +gh workflow run publish.yml --ref v<版本> +``` + +重新执行 `release.yml` 会继续处理已合并且待生成 tag 的发布 PR,不需要重新提交业务代码。只希望暂停自动版本发布时,禁用 Automatic PyPI release 工作流;手动 publisher 通道仍存在。 + +## ClawHub + +ClawHub 不在任何自动 workflow 中上传。选择一个已通过 PyPI 安装验证的版本 tag,从干净源码打包,再按 CONTRIBUTING.md 运行真实 embedded-agent 工具过滤验收、dry-run、平台安全检查和注册表下载哈希核验后手动发布。命令见 CONTRIBUTING.md。不会自动生成或上传 ClawHub token。 diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ee51334 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "d13d8dc098863d018abf6115291cfee53603d894", + "packages": { + ".": { + "release-type": "python", + "package-name": "goofish-cli", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "json", + "path": "package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".codex-plugin/plugin.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "openclaw.plugin.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".claude-plugin/marketplace.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": ".mcp.json", + "jsonpath": "$.mcpServers.goofish.args[1]" + }, + "README.md", + "docs/mcp-setup.md" + ] + } + } +} diff --git a/scripts/release_candidate.py b/scripts/release_candidate.py new file mode 100644 index 0000000..174c26b --- /dev/null +++ b/scripts/release_candidate.py @@ -0,0 +1,64 @@ +"""只为本仓库机器人生成的发布 PR 输出候选,验证 SHA 后再合并。""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess + + +def gh(*args: str): + return json.loads(subprocess.check_output(["gh", *args], text=True)) + + +def output(**values) -> None: + with open(os.environ["GITHUB_OUTPUT"], "a") as stream: + for key, value in values.items(): + stream.write(f"{key}={value}\n") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def pull(number: int): + repo = os.environ["GITHUB_REPOSITORY"] + pr = gh("api", f"repos/{repo}/pulls/{number}") + require(pr["user"]["login"] == "github-actions[bot]", "发布 PR 必须由 Actions 生成") + require(pr["base"]["ref"] == "main" and pr["head"]["repo"]["full_name"] == repo, "错误的仓库或基线") + require(pr["head"]["ref"].startswith("release-please--"), "错误的发布分支") + require("autorelease: pending" in {x["name"] for x in pr["labels"]}, "缺少发布标记") + require(bool(re.fullmatch(r"[0-9a-f]{40}", pr["head"]["sha"])), "无效的候选 SHA") + return pr + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=["prepare", "merge"]) + args = parser.parse_args() + if args.mode == "prepare": + data = json.loads(os.environ["RELEASE_PR"]) + pr = pull(int(data["number"])) + require(pr["state"] == "open" and not pr["draft"], "候选不是可合并发布 PR") + require(pr["title"] == data["title"] and pr["head"]["ref"] == data["headBranchName"], "候选与发布程序输出不一致") + versions = re.findall(r"\b\d+\.\d+\.\d+\b", data["title"]) + require(len(versions) == 1, "发布标题必须包含唯一版本") + output(number=pr["number"], sha=pr["head"]["sha"], base=pr["base"]["sha"], version=versions[0]) + return + number = int(os.environ["RELEASE_PR_NUMBER"]) + expected = os.environ["RELEASE_PR_SHA"] + base = os.environ["RELEASE_BASE_SHA"] + pr = pull(number) + require(pr["head"]["sha"] == expected, "候选已更新,须重新验证") + repo = os.environ["GITHUB_REPOSITORY"] + current = gh("api", f"repos/{repo}/git/ref/heads/main")["object"]["sha"] + require(current == base, "main 已更新,须重新生成发布 PR") + subprocess.run(["gh", "pr", "merge", str(number), "--repo", repo, + "--squash", "--match-head-commit", expected], check=True) + print(f"Validated release PR #{number} merged") + + +if __name__ == "__main__": + main() diff --git a/scripts/release_metadata.py b/scripts/release_metadata.py new file mode 100644 index 0000000..495e358 --- /dev/null +++ b/scripts/release_metadata.py @@ -0,0 +1,99 @@ +"""核对版本契约,并限定自动发布 PR 只能更新版本、安装示例和发布说明。""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +JSON_VERSIONS = ( + "package.json", ".codex-plugin/plugin.json", "openclaw.plugin.json", + ".claude-plugin/marketplace.json", +) +DOCS = ("README.md", "docs/mcp-setup.md") +ALLOWED = {*JSON_VERSIONS, *DOCS, ".mcp.json", "pyproject.toml", "CHANGELOG.md", + ".release-please-manifest.json"} +VERSION = re.compile(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)") + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def normalize(path: str, content: str, version: str): + if path in JSON_VERSIONS: + value = json.loads(content) + value["version"] = "VERSION" + return value + if path == ".mcp.json": + value = json.loads(content) + value["mcpServers"]["goofish"]["args"][1] = "VERSION" + return value + if path == ".release-please-manifest.json": + value = json.loads(content) + value["."] = "VERSION" + return value + if path == "pyproject.toml": + value = tomllib.loads(content) + value["project"]["version"] = "VERSION" + return value + return content.replace("goofish-cli==" + version, "goofish-cli==VERSION") + + +def check(tag: str = "", base: str = "") -> str: + version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"] + if not VERSION.fullmatch(version): + raise ValueError("发布版本必须是稳定 SemVer") + if tag and tag != "v" + version: + raise ValueError("tag 与构建版本不一致;只允许从对应版本 tag 发布") + for path in JSON_VERSIONS: + if json.loads((ROOT / path).read_text())["version"] != version: + raise ValueError(f"版本不一致:{path}") + args = json.loads((ROOT / ".mcp.json").read_text())["mcpServers"]["goofish"]["args"] + if args != ["--from", "goofish-cli==" + version, "goofish-cli"]: + raise ValueError("MCP 必须精确锁定本次 Python 版本") + if json.loads((ROOT / ".release-please-manifest.json").read_text()) != {".": version}: + raise ValueError("release-please manifest 与 Python 版本不一致") + if not re.search(r"^## .*\b" + re.escape(version) + r"\b", (ROOT / "CHANGELOG.md").read_text(), re.M): + raise ValueError("CHANGELOG 缺少本次发布说明") + for path in DOCS: + pins = re.findall(r"goofish-cli==(\d+\.\d+\.\d+)", (ROOT / path).read_text()) + if not pins or any(pin != version for pin in pins): + raise ValueError(f"安装示例没有同步版本:{path}") + if base: + if not re.fullmatch(r"[0-9a-f]{40}", base): + raise ValueError("基线必须是完整 commit SHA") + changed = git("diff", "--name-only", base, "HEAD").splitlines() + if not changed or set(changed) - ALLOWED: + raise ValueError("自动发布 PR 包含版本文件范围外的改动") + old_version = tomllib.loads(git("show", base + ":pyproject.toml"))["project"]["version"] + if version == old_version: + raise ValueError("自动发布 PR 没有更新版本") + for path in changed: + before = git("show", base + ":" + path) + after = (ROOT / path).read_text().strip() + mode = git("ls-tree", "HEAD", path).split()[0] + if mode != "100644": + raise ValueError(f"版本文件必须是普通不可执行文件:{path}") + if path != "CHANGELOG.md" and normalize(path, before, old_version) != normalize(path, after, version): + raise ValueError(f"自动发布 PR 修改了非版本内容:{path}") + return version + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + parser.add_argument("--tag", default="") + parser.add_argument("--base", default="") + args = parser.parse_args() + try: + print("Release metadata verified:", check(args.tag, args.base)) + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as exc: + parser.exit(1, f"Release rejected: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_pypi_release.py b/scripts/verify_pypi_release.py new file mode 100644 index 0000000..a29a9fa --- /dev/null +++ b/scripts/verify_pypi_release.py @@ -0,0 +1,85 @@ +"""从 PyPI 安装精确版本,核对工件哈希并验证真实 CLI/MCP,不使用账号凭证。""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import subprocess +import time +import urllib.error +import urllib.request +from datetime import timedelta +from pathlib import Path + + +async def handshake(version: str) -> int: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + args = ["--refresh-package", "goofish-cli", "--default-index", "https://pypi.org/simple", + "--from", f"goofish-cli=={version}"] + result = subprocess.run(["uvx", *args, "goofish", "version"], check=True, + capture_output=True, text=True, timeout=180) + if result.stdout.strip() != f"goofish-cli {version}": + raise ValueError("PyPI 安装后的 CLI 版本不一致") + params = StdioServerParameters(command="uvx", args=[*args, "goofish-cli"], env=os.environ.copy()) + async with stdio_client(params) as (reader, writer), ClientSession( + reader, writer, read_timeout_seconds=timedelta(seconds=90) + ) as client: + await client.initialize() + tools = (await client.list_tools()).tools + names = {tool.name for tool in tools} + if not {"auth_status", "item_get", "item_publish", "search_items", "message_history"} <= names: + raise ValueError("MCP 缺少核心工具") + return len(tools) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--existing-only", action="store_true") + args = parser.parse_args() + artifacts = [p for p in args.dist.iterdir() if p.name.endswith((".whl", ".tar.gz"))] + if len(artifacts) != 2: + raise ValueError("必须验证同一次构建的 wheel 和 sdist") + for attempt in range(1, 6): + try: + try: + with urllib.request.urlopen(f"https://pypi.org/pypi/goofish-cli/{args.version}/json", timeout=20) as response: + data = json.load(response) + except urllib.error.HTTPError as exc: + if args.existing_only and exc.code == 404: + print("New PyPI version; no existing artifacts") + return + raise + files = {value["filename"]: value for value in data["urls"]} + for artifact in artifacts: + if args.existing_only and artifact.name not in files: + continue + remote = files[artifact.name] + if remote.get("yanked") or remote["digests"]["sha256"] != hashlib.sha256(artifact.read_bytes()).hexdigest(): + raise ValueError("PyPI 工件与通过检查的构建产物不一致") + if set(files) - {p.name for p in artifacts}: + raise ValueError("PyPI 存在构建范围外的工件") + if args.existing_only: + print("Existing PyPI artifact hashes match checked build") + return + count = asyncio.run(handshake(args.version)) + print(json.dumps({"version": args.version, "artifact_hashes_match": True, + "cli_version_verified": True, "mcp_handshake": True, + "tool_count": count})) + return + except ValueError: + raise + except Exception as exc: + if attempt == 5: + raise + print(f"PyPI validation attempt {attempt} pending: {type(exc).__name__}", flush=True) + time.sleep(15) + + +if __name__ == "__main__": + main()