diff --git a/.github/workflows/release-one-click.yml b/.github/workflows/release-one-click.yml index 86d04a0d..381cb572 100644 --- a/.github/workflows/release-one-click.yml +++ b/.github/workflows/release-one-click.yml @@ -4,6 +4,7 @@ on: push: tags: - '*' + - '!python-sdk-v*' concurrency: group: release-one-click-${{ github.ref }} diff --git a/.github/workflows/release-python-sdk.yml b/.github/workflows/release-python-sdk.yml new file mode 100644 index 00000000..26af99d8 --- /dev/null +++ b/.github/workflows/release-python-sdk.yml @@ -0,0 +1,108 @@ +name: Release Python SDK + +on: + push: + tags: + - 'python-sdk-v*' + workflow_dispatch: + inputs: + target: + description: 'Publish target' + type: choice + options: + - testpypi + - pypi + default: testpypi + +concurrency: + group: release-python-sdk-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +defaults: + run: + working-directory: sdk/python + +jobs: + build-and-publish: + name: Build and publish cubesandbox + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Verify version consistency + if: github.event_name == 'push' + run: | + set -euo pipefail + tag_ref="${GITHUB_REF_NAME#python-sdk-v}" + init_ver="$(python -c 'import re,pathlib;print(re.search(r"__version__\s*=\s*\"([^\"]+)\"", pathlib.Path("cubesandbox/__init__.py").read_text()).group(1))')" + echo "tag=${tag_ref}" + echo "__version__=${init_ver}" + if [[ "${tag_ref}" != "${init_ver}" ]]; then + echo "::error::Tag (python-sdk-v${tag_ref}) does not match cubesandbox.__version__ (${init_ver})" >&2 + exit 1 + fi + + - name: Build sdist and wheel + run: | + rm -rf dist + python -m build + + - name: Verify built version matches __version__ + run: | + set -euo pipefail + init_ver="$(python -c 'import re,pathlib;print(re.search(r"__version__\s*=\s*\"([^\"]+)\"", pathlib.Path("cubesandbox/__init__.py").read_text()).group(1))')" + ls dist/ + test -f "dist/cubesandbox-${init_ver}.tar.gz" + test -f "dist/cubesandbox-${init_ver}-py3-none-any.whl" + + - name: Twine check + run: python -m twine check --strict dist/* + + - name: Publish to TestPyPI + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/python/dist + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.CUBE_PYPI_TOKEN }} + skip-existing: true + + - name: Publish to PyPI + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/python/dist + password: ${{ secrets.CUBE_PYPI_TOKEN }} + + - name: Ensure GitHub Release exists + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + working-directory: ${{ github.workspace }} + run: | + if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes "Python SDK release. Install with: pip install cubesandbox==${GITHUB_REF_NAME#python-sdk-v}" + fi + + - name: Attach distributions to GitHub Release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" dist/* --clobber diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 34f928a4..74dbae96 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -163,7 +163,8 @@ export default withMermaid(defineConfig({ { text: 'Maintainer Docs', items: [ - { text: 'Blog Maintenance', link: '/guide/maintainer/blog' } + { text: 'Blog Maintenance', link: '/guide/maintainer/blog' }, + { text: 'Releasing the Python SDK', link: '/guide/maintainer/release-python-sdk' } ] } ], @@ -257,7 +258,8 @@ export default withMermaid(defineConfig({ { text: '维护者文档', items: [ - { text: '博客维护', link: '/zh/guide/maintainer/blog' } + { text: '博客维护', link: '/zh/guide/maintainer/blog' }, + { text: '发布 Python SDK', link: '/zh/guide/maintainer/release-python-sdk' } ] } ], diff --git a/docs/guide/maintainer/release-python-sdk.md b/docs/guide/maintainer/release-python-sdk.md new file mode 100644 index 00000000..bb9e7ed3 --- /dev/null +++ b/docs/guide/maintainer/release-python-sdk.md @@ -0,0 +1,91 @@ +# Releasing the Python SDK + +This guide explains how to publish the `cubesandbox` Python package on PyPI from CI. + +The SDK source lives in [`sdk/python/`](https://github.com/TencentCloud/CubeSandbox/tree/main/sdk/python). +Releases are driven by the [`release-python-sdk.yml`](https://github.com/TencentCloud/CubeSandbox/blob/main/.github/workflows/release-python-sdk.yml) +GitHub Actions workflow. + +## Prerequisites + +- A maintainer-level GitHub account on the repository (push access for tags). +- The repository secret `CUBE_PYPI_TOKEN` must be configured with a valid PyPI API token. + - For first-ever release: a user-scoped token can be used. Replace it with a project-scoped token (`Scope = Project: cubesandbox`) immediately after the first successful publish. + - Optionally, add a TestPyPI token under the same `CUBE_PYPI_TOKEN` name in a separate environment if you want to dry-run against TestPyPI. + +## Versioning rules + +- Follow [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH` (pre-releases such as `0.3.0rc1` are also accepted by PyPI). +- Version is the **single source of truth** in `sdk/python/cubesandbox/__init__.py` (`__version__ = "X.Y.Z"`). + `pyproject.toml` reads it dynamically via `[tool.setuptools.dynamic].version`. +- The git tag must match the package version exactly: + - Tag format: `python-sdk-vX.Y.Z` + - The release workflow fails if `tag` ≠ `__version__`. + +## Tag conventions and workflow isolation + +| Workflow | Tag pattern | +|---|---| +| `release-one-click.yml` (main bundle release) | `*` excluding `python-sdk-v*` | +| `release-python-sdk.yml` (Python SDK on PyPI) | `python-sdk-v*` | + +Both workflows are mutually exclusive: a `python-sdk-v*` tag never triggers the one-click bundle, and a regular `vX.Y.Z` tag never triggers a PyPI publish. + +## Standard release procedure + +1. **Bump the version** on `main` in a regular PR: + + ```python + # sdk/python/cubesandbox/__init__.py + __version__ = "0.2.1" + ``` + + Update `sdk/python/README.md` and the changelogs (`docs/changelog.md`, `docs/zh/changelog.md`) if the release contains user-visible changes. Get the PR reviewed and merged like any other change. + +2. **(Optional but recommended) Dry-run via TestPyPI.** Open the + [Release Python SDK workflow](https://github.com/TencentCloud/CubeSandbox/actions/workflows/release-python-sdk.yml), + click **Run workflow**, and pick `target: testpypi`. Then verify install: + + ```bash + python -m venv /tmp/venv && source /tmp/venv/bin/activate + pip install -i https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "cubesandbox==0.2.1" + python -c "import cubesandbox; print(cubesandbox.__version__)" + ``` + +3. **Tag and push** from the merge commit on `main`: + + ```bash + git checkout main && git pull + git tag python-sdk-v0.2.1 + git push origin python-sdk-v0.2.1 + ``` + +4. **Watch the workflow.** The `Release Python SDK` workflow runs automatically and: + - Validates `tag` ↔ `__version__` consistency. + - Builds `sdist` and `wheel` with `python -m build`. + - Validates package metadata via `twine check --strict`. + - Publishes to PyPI using `CUBE_PYPI_TOKEN`. + - Creates the matching GitHub Release and attaches the built artifacts. + +5. **Smoke-test the release** from a clean environment: + + ```bash + python -m venv /tmp/venv && source /tmp/venv/bin/activate + pip install "cubesandbox==0.2.1" + python -c "from cubesandbox import Sandbox; print('ok')" + ``` + +## Recovering from a bad release + +PyPI **does not allow re-uploading the same version**, even after deletion. If a published version has a serious bug: + +1. Yank the affected version on PyPI (Project → Manage → Releases → Yank). Yanked versions stay installable when pinned but are skipped by `pip install cubesandbox`. +2. Bump the version (e.g. `0.2.1` → `0.2.2`) and follow the standard release procedure. +3. If the workflow itself failed before publish, simply fix the issue, delete the failed tag (`git push --delete origin python-sdk-v0.2.1 && git tag -d python-sdk-v0.2.1`), and re-tag. + +## Future improvements + +- **Trusted Publishing (OIDC).** Replace the `CUBE_PYPI_TOKEN` secret with PyPI Trusted Publishing. Add the workflow as a trusted publisher on the PyPI project page, set `permissions: id-token: write` on the publish job, and remove the `password:` input. Tokens then no longer need to be rotated. +- **Test gating.** Run `pytest -m "not e2e"`, `ruff check`, and `mypy` as a prerequisite job for `build-and-publish`, so a release cannot be cut from a broken state. diff --git a/docs/zh/guide/maintainer/release-python-sdk.md b/docs/zh/guide/maintainer/release-python-sdk.md new file mode 100644 index 00000000..79b02c32 --- /dev/null +++ b/docs/zh/guide/maintainer/release-python-sdk.md @@ -0,0 +1,89 @@ +# 发布 Python SDK + +本文说明如何通过 CI 将 `cubesandbox` Python 包发布到 PyPI。 + +SDK 源码位于 [`sdk/python/`](https://github.com/TencentCloud/CubeSandbox/tree/main/sdk/python), +发布流水线为 [`release-python-sdk.yml`](https://github.com/TencentCloud/CubeSandbox/blob/main/.github/workflows/release-python-sdk.yml)。 + +## 前置条件 + +- 仓库的维护者权限(可以推送 tag)。 +- 仓库 Secret `CUBE_PYPI_TOKEN` 已配置为有效的 PyPI API Token。 + - 首次发版时可以使用 user-scoped token;首次发布成功后**立即**替换成 project-scoped token(`Scope = Project: cubesandbox`)。 + - 如需先在 TestPyPI dry-run,可在独立 environment 下用同名 secret 配置 TestPyPI token。 + +## 版本规范 + +- 遵循 [SemVer](https://semver.org/) `MAJOR.MINOR.PATCH`,预发布版本(如 `0.3.0rc1`)也被 PyPI 接受。 +- 版本号以 `sdk/python/cubesandbox/__init__.py` 中的 `__version__ = "X.Y.Z"` 为**唯一来源**,`pyproject.toml` 通过 `[tool.setuptools.dynamic].version` 动态读取,无需重复维护。 +- git tag 必须与版本号严格一致: + - tag 格式:`python-sdk-vX.Y.Z` + - 不一致时,发布工作流会直接失败。 + +## tag 约定与流水线隔离 + +| 工作流 | 触发 tag | +|---|---| +| `release-one-click.yml`(主包一键部署 release) | `*`,但排除 `python-sdk-v*` | +| `release-python-sdk.yml`(Python SDK 发布到 PyPI) | `python-sdk-v*` | + +两条流水线互不重叠:`python-sdk-v*` 不会触发 one-click bundle,普通的 `vX.Y.Z` 也不会触发 PyPI 发布。 + +## 标准发版流程 + +1. **在 `main` 分支提一个常规 PR 升版本号**: + + ```python + # sdk/python/cubesandbox/__init__.py + __version__ = "0.2.1" + ``` + + 如果发布内容有用户可见变更,同步更新 `sdk/python/README.md`、`docs/changelog.md`、`docs/zh/changelog.md`。然后走正常的 review 与合入流程。 + +2. **(可选,强烈建议)TestPyPI dry-run。** 打开 + [Release Python SDK 工作流](https://github.com/TencentCloud/CubeSandbox/actions/workflows/release-python-sdk.yml), + 点击 **Run workflow**,选 `target: testpypi`。完成后验证安装: + + ```bash + python -m venv /tmp/venv && source /tmp/venv/bin/activate + pip install -i https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "cubesandbox==0.2.1" + python -c "import cubesandbox; print(cubesandbox.__version__)" + ``` + +3. **在 `main` 上的合入 commit 打 tag 并推送**: + + ```bash + git checkout main && git pull + git tag python-sdk-v0.2.1 + git push origin python-sdk-v0.2.1 + ``` + +4. **观察工作流**。`Release Python SDK` 自动执行: + - 校验 `tag` 与 `__version__` 一致; + - 用 `python -m build` 构建 sdist 和 wheel; + - `twine check --strict` 校验包元信息; + - 用 `CUBE_PYPI_TOKEN` 发布到 PyPI; + - 创建对应的 GitHub Release 并附上构建产物。 + +5. **在干净环境冒烟验证**: + + ```bash + python -m venv /tmp/venv && source /tmp/venv/bin/activate + pip install "cubesandbox==0.2.1" + python -c "from cubesandbox import Sandbox; print('ok')" + ``` + +## 发版异常处理 + +PyPI **不允许同一版本号重复上传**,即使已删除。如果某个版本上线后发现严重问题: + +1. 在 PyPI 上 yank 该版本(Project → Manage → Releases → Yank)。被 yank 的版本依然可以通过精确 pin 安装,但 `pip install cubesandbox` 会跳过它。 +2. 升一位版本(例如 `0.2.1` → `0.2.2`),再走一遍标准发版流程。 +3. 如果是工作流自身在发布前就失败,直接修问题,删掉失败的 tag(`git push --delete origin python-sdk-v0.2.1 && git tag -d python-sdk-v0.2.1`),再重新打 tag。 + +## 后续可改进项 + +- **切换到 PyPI Trusted Publishing(OIDC)**。在 PyPI 项目页将本工作流加为 trusted publisher,给 publish job 加 `permissions: id-token: write` 并移除 `password:` 字段,从此不再需要 `CUBE_PYPI_TOKEN`,也无需轮换。 +- **加测试门禁**。在 `build-and-publish` 之前增加一个 job 跑 `pytest -m "not e2e"`、`ruff check`、`mypy`,避免从坏状态发版。 diff --git a/sdk/python/README.md b/sdk/python/README.md index 2c239083..547d91b0 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -17,8 +17,11 @@ and control the full sandbox lifecycle — including pause/resume with memory sn ## Installation -> **Note:** `cubesandbox` is not yet published to PyPI. -> Install from source until the first release is available: +```bash +pip install cubesandbox +``` + +Or install the latest development version from source: ```bash git clone https://github.com/TencentCloud/CubeSandbox.git diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 4f930f5e..29ee05ed 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,17 +4,40 @@ build-backend = "setuptools.build_meta" [project] name = "cubesandbox" -version = "0.2.0" description = "Python SDK for CubeSandbox" +readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.9" +authors = [ + { name = "Tencent CubeSandbox Authors" }, +] +keywords = ["cubesandbox", "sandbox", "code-execution", "ai-agent", "microvm"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] dependencies = [ "httpx>=0.27", "requests>=2", ] +dynamic = ["version"] [project.urls] Homepage = "https://github.com/TencentCloud/CubeSandbox" +Repository = "https://github.com/TencentCloud/CubeSandbox" +Issues = "https://github.com/TencentCloud/CubeSandbox/issues" +Documentation = "https://github.com/TencentCloud/CubeSandbox/tree/main/sdk/python" [project.optional-dependencies] dev = [ @@ -24,6 +47,9 @@ dev = [ "ruff>=0.4", ] +[tool.setuptools.dynamic] +version = { attr = "cubesandbox.__version__" } + [tool.setuptools.packages.find] where = ["."] include = ["cubesandbox*"] @@ -47,4 +73,4 @@ ignore = ["E501"] [tool.mypy] python_version = "3.9" strict = true -ignore_missing_imports = true \ No newline at end of file +ignore_missing_imports = true