ci(release): 建设 SDK 自托管自动发布与陆骁编排 - #39
Conversation
本次提交建立由自有 Runner 执行、GitHub Environment 人工批准、PyPI OIDC Trusted Publishing 发布的 SDK CI/CD 基础能力。 主要改动: - 拆分 sdk-ci 与 release 工作流,覆盖 Python 3.10-3.12、最低/最新依赖、BLE、pytest、mypy 与包安装验证 - wheel 和 sdist 仅构建一次,生成 SHA-256 清单,并以同一 Artifact 依次发布 TestPyPI 与 PyPI - 增加版本 PR 准备、PEP 440 版本计算、索引冲突检查、main 祖先和版本 PR 标签门禁 - 正式发布必须进入 pypi Environment 等待负责人审批,不保存长期 PyPI Token - 将陆骁 PR Review 切换到隔离的 sdk-ci Runner,并移除旧 publish.yml 重复发布入口 - 更新中文发布说明和 CHANGELOG,补充自动发布回归测试 验证结果: - 全量 pytest 通过,2 项按预期跳过 - mypy 检查 86 个源码文件通过 - 发布工具与工作流契约测试通过 - git diff --check 通过
修复合并前自审发现的两个发布门禁问题: - annotated tag 的 GitHub 事件 SHA 可能指向 tag object,现先解引用到真实 commit,再验证 main 祖先、版本 PR 与 Release target - 版本 PR 准备任务改由 sdk-ci 执行,sdk-release 仅接收受保护 v* Tag 发布工作流 - 发布门禁显式拒绝 lightweight tag,确保只接受 annotated tag 验证:发布工具、工作流契约与文档测试全部通过,git diff --check 通过。
配置 uv 的 TestPyPI simple check URL,使网络重试或工作流重跑只跳过与索引中哈希完全一致的制品;冲突制品仍然失败,避免盲目覆盖。
将 PR diff 与审查结果从全局 /tmp 固定路径迁移到 GitHub Actions 每个 Job 独立的 RUNNER_TEMP,避免自托管 Runner 上历史文件属主冲突、跨 PR 结果污染和错误评论复用。 补充工作流契约测试,确保不再使用固定 /tmp 文件。
修复自托管 Runner 迁移后暴露的两类问题: - PR 标题和描述不再直接拼进 Bash 命令,改用环境变量传入,避免 Markdown 反引号被执行 - 桥接脚本使用 Job 独立临时文件和 Hermes 用户缓存目录,不再读写共享 /tmp 固定文件 - scp、ssh 和远端 Agent 失败均显式返回失败,不再发布伪成功审查结果 - 调用结束后清理本地与远端 prompt 文件,降低跨 PR 污染风险 补充工作流契约和 Python 语法验证。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3e46bb4208
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| jobs: | ||
| test: | ||
| name: Python ${{ matrix.python-version }} / ${{ matrix.dependency-profile }} | ||
| runs-on: [self-hosted, Linux, X64, sdk-ci] |
There was a problem hiding this comment.
Isolate pull-request code from the release runner
For any untrusted pull request, this job checks out PR-controlled content and executes it through the editable install and pytest on a persistent self-hosted runner. The release-preparation workflow uses the same sdk-ci runner label and later exposes ORULINK_RELEASE_APP_PRIVATE_KEY and a credential-persisting App token, so malicious PR code can leave a process or other state on a runner and capture those credentials during a later release job. Run PR code on an ephemeral/hosted runner or reserve a separate runner pool that never handles release secrets.
Useful? React with 👍 / 👎.
| validate_version_absent( | ||
| "TestPyPI", | ||
| version, | ||
| _http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), | ||
| allow_existing=reusable_draft, |
There was a problem hiding this comment.
Reuse the verified artifact when retrying a release
When a run has created its draft and uploaded to TestPyPI but stops before PyPI, this exception lets a rerun continue even though the rerun always rebuilds the distributions, including with unpinned build tooling. uv publish --help describes --check-url as checking for existing files “to skip duplicate uploads,” so TestPyPI retains and verifies the first run's files while the production job uploads the newly built files; the bytes approved for PyPI are therefore not necessarily those tested on TestPyPI or attached to the draft. Preserve/download the original artifact on retry, or compare its recorded hashes with the existing TestPyPI and draft assets before allowing the rerun.
Useful? React with 👍 / 👎.
| ble-provisioning: | ||
| name: BLE fake backend | ||
| runs-on: [self-hosted, Linux, X64, sdk-ci] |
There was a problem hiding this comment.
Restore CI coverage for supported BLE platforms
The replacement BLE job now runs only on Linux, whereas the removed workflow exercised both windows-latest and macos-latest. Bluetooth provisioning is explicitly supported only on Windows and macOS (README.md:170-174), and BleakBackend is described as a Windows/macOS backend, so platform-specific import, event-loop, and Bleak regressions can now merge without running on either supported platform. Keep the self-hosted Linux checks if needed, but retain Windows and macOS jobs for this test suite.
Useful? React with 👍 / 👎.
| existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state all --json number --jq 'length') | ||
| if [[ "${existing}" != "0" ]]; then | ||
| echo "Release PR already exists for ${version}; nothing to do." | ||
| exit 0 |
There was a problem hiding this comment.
Allow a closed release PR to be retried
If a generated release PR is closed without merging, requesting the same version again always exits here instead of creating a replacement, even when the old branch was deleted or the PR cannot be reopened. gh pr list --help confirms that --state all includes closed PRs, so this reports success while leaving no open version PR and permanently stalls that release request until someone manually repairs the old PR. Restrict the idempotency check to open PRs, or explicitly reopen/recreate a closed one.
Useful? React with 👍 / 👎.
| capture_output=True, | ||
| ) | ||
| validate_absent( | ||
| version, | ||
| pypi_status=http_status(f"https://pypi.org/pypi/watcherobot/{version}/json"), | ||
| testpypi_status=http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), |
There was a problem hiding this comment.
Reject an existing Git tag before opening the version PR
When v<version> already exists without a GitHub Release—for example, a stale or prematurely created future tag—this check still declares the version available because gh release view only views Release objects and does not test repository refs. The workflow can then create and merge a version PR whose annotated tag cannot subsequently be created without deleting or moving the existing immutable tag, despite docs/releasing.md:33-34 requiring the preparation step to consult tag state. Check the fetched refs/tags/v<version> or the GitHub refs API in addition to the Release lookup.
Useful? React with 👍 / 👎.
| if [[ "${version}" =~ (a|b|rc)[0-9]+$ ]]; then | ||
| echo "prerelease=true" >> "${GITHUB_OUTPUT}" | ||
| else | ||
| echo "prerelease=false" >> "${GITHUB_OUTPUT}" |
There was a problem hiding this comment.
Classify PEP 440 development versions as prereleases
For an explicit canonical target such as 0.1.2.dev1, packaging.Version accepts the version and considers it a prerelease, but this suffix regex does not match and emits prerelease=false. The later gh release create therefore omits --prerelease, causing a development build to be published as a normal GitHub Release and potentially presented to users as the latest stable release. Derive this output from Version(version).is_prerelease rather than matching only alpha, beta, and RC suffixes.
Useful? React with 👍 / 👎.
| 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, |
There was a problem hiding this comment.
Require the tag to target the PR's merged commit
The associated-PR gate checks only that the PR has some merge timestamp; it never compares the tag SHA with the PR's merge_commit_sha. If a version PR has multiple commits, tagging an earlier version-bump commit after the PR is merged still satisfies main ancestry and remains associated with the valid PR, so the workflow can publish while omitting later corrections from that PR. Require the tag SHA to equal the actual merged commit returned by GitHub rather than accepting any associated commit.
Useful? React with 👍 / 👎.
| if .venv-testpypi/bin/python -m pip install \ | ||
| --index-url https://test.pypi.org/simple/ \ | ||
| --extra-index-url https://pypi.org/simple/ \ | ||
| "watcherobot==${{ needs.gate.outputs.version }}"; then |
There was a problem hiding this comment.
Keep TestPyPI dependencies off the untrusted test index
When verifying the release, pip resolves watcherobot and all of its dependencies across both TestPyPI and PyPI; pip install --help describes --extra-index-url as adding another index rather than assigning dependency priority. An attacker can publish a higher compatible version of a dependency such as av, bleak, or another project name to the separate TestPyPI namespace, causing arbitrary package code to run on the persistent sdk-release runner before later jobs obtain production publishing credentials. Install dependencies exclusively from PyPI first, then install the exact TestPyPI wheel with --no-deps and run pip check.
Useful? React with 👍 / 👎.
| version, | ||
| pypi_status=http_status(f"https://pypi.org/pypi/watcherobot/{version}/json"), | ||
| testpypi_status=http_status(f"https://test.pypi.org/pypi/watcherobot/{version}/json"), | ||
| release_exists=release.returncode == 0, |
There was a problem hiding this comment.
Fail closed when the Release lookup itself fails
Every nonzero result from gh release view is treated as proof that the Release is absent. gh help exit-codes states that exit code 1 represents failure for any reason and exit code 4 represents an authentication requirement, so an API outage, permission error, or expired App token can reach validate_absent(..., release_exists=False) and permit a conflicting version PR instead of failing closed. Distinguish a verified 404 from operational/authentication errors and propagate the latter.
Useful? React with 👍 / 👎.
51c5a78 to
79c275f
Compare
🤖 Luxiao PR 审查报告🤖 PR 审查报告 PR: ci(release): 建设 SDK 自托管自动发布与陆骁编排 维度一:代码质量🏗️ 架构视角 —
|
将陆骁审查输入上限提升到 100000 字符,并在超限时明确输出省略字符和行数,防止有限上下文被误判为全量审查。 Hermes 主机改由仓库变量注入,远端脚本与缓存目录支持 Runner 私有环境覆盖;审查任务同时匹配 sdk-ci 与 pr-review 标签,保持执行身份边界清晰。 补充契约测试,并已将同版本桥接脚本部署至 sdk-ci-103 Runner。
🤖 Luxiao PR 审查报告PR 审查报告 PR: ci(release): 建设 SDK 自托管自动发布与陆骁编排 维度一:代码质量🏗️ 架构视角 — ✅整体架构设计清晰且务实:
一个轻微架构注意点: 🎨 产品视角 — ✅从 SDK 使用者和维护者角度看,流程设计合理:
📏 规范视角 — ✅
|
隔离可信版本编排与 PR 代码执行身份,关闭版本 PR 可重新发起;发布准备同时检查 Git Tag 和 GitHub Release,并在 GitHub 查询异常时失败关闭。 强制发布 Tag 指向版本 PR 的 merge commit,重跑时复用 Draft Release 中的原始制品并校验哈希,避免 TestPyPI 与后续制品不一致。 TestPyPI 验证先从正式 PyPI 安装依赖,再以 no-deps 下载目标 wheel 并比对哈希,消除依赖混淆风险;PEP 440 预发布版仅发布 TestPyPI 与 GitHub prerelease,不进入正式 PyPI 审批。 保留公司自托管 Runner 策略下的 Linux BLE 契约测试,并在发布文档中明确其不替代 Windows/macOS 实机验收。
🤖 Luxiao PR 审查报告 |
将 release Artifact 名称绑定 github.run_attempt,保证同一次 Workflow 重跑恢复原始制品后能以本次唯一名称重新上传,并让后续 TestPyPI、PyPI 与 Release 任务引用同一份制品。
🤖 Luxiao PR 审查报告 |
为测试矩阵、BLE 假后端和制品构建任务分别创建全新虚拟环境,并通过 GITHUB_PATH 让后续步骤统一使用该环境。 避免持久化 Runner 上遗留的 twine 等工具污染最低依赖校验,同时增加工作流结构测试,确保三个任务持续保持环境隔离。
将远端审查预算调整为十分钟,并由远端 timeout 在到期后先发送 TERM、三十秒后强制结束,防止本地 SSH 超时留下孤儿 Agent。 本地桥接超时略晚于远端门限,确保能够接收远端退出状态;增加结构测试锁定双层超时与进程回收策略。
🤖 Luxiao PR 审查报告 |
1 similar comment
🤖 Luxiao PR 审查报告 |
🤖 Luxiao PR 审查报告 |
Daemon 多应用 Python 环境测试会创建无 pip 的最小虚拟环境;在 Python 3.10 下 websockets 14 仍依赖 typing_extensions,原夹具只注入 websockets 路径导致子进程启动失败。 将 typing_extensions 所在目录一并写入测试环境的 pth 文件,保持真实解释器切换测试的隔离语义,不修改 Daemon 业务路由。
远端复现确认失败发生在解释器加载标准库之前:setup-python 二进制仍指向 /opt/hostedtoolcache,而自托管 Runner 缓存实际位于私有 _work/_tool。 typing_extensions 与本次失败无关,恢复原测试夹具,后续在 Runner 服务层修复工具缓存前缀,保持测试继续验证真实虚拟环境。
🤖 Luxiao PR 审查报告 |
🤖 Luxiao PR 审查报告PR 审查报告 PR: ci(release): 建设 SDK 自托管自动发布与陆骁编排 维度一:代码质量🏗️ 架构视角 — ✅关注点分离清晰,是本次 PR 最大的亮点:
🎨 产品视角 — ✅从 SDK 使用者(开发者)视角:
📏 规范视角 — ✅
|
目标
建设由陆骁编排、自有 Runner 构建、飞书通知、GitHub 人工审批的 Python SDK CI/CD,并停用旧的重复发布入口。
主要改动
sdk-ci.yml:自托管sdk-ciRunner 执行 Python 3.10-3.12、最低/最新依赖、BLE、pytest、mypy 和包构建验证release.yml:合法版本 Tag 门禁、制品单次构建、SHA-256 清单、TestPyPI 验证、pypiEnvironment 人工审批及 OIDC 发布prepare-release.yml:飞书/标签统一进入版本 PR,使用仓库级 GitHub App 短期令牌sdk-ciRunner;移除旧publish.yml基础设施状态
cicd-runner(192.168.1.103)onboot: 1sdk-ci-103、sdk-release-103已在线验证
twine check对 wheel 与 sdist 均通过git diff --check通过合入前外部配置
ORULINK_RELEASE_APP_ID与ORULINK_RELEASE_APP_PRIVATE_KEYrelease.yml风险控制
此 PR 已完成代码自审;在外部平台配置完成前保持不合并。普通 PR、草稿 PR和无合法版本 PR 关联的普通 Tag 不会触发正式发布。