Repository navigation
Publish totalreclaw Python client to PyPI #165
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish totalreclaw Python client to PyPI | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release-type: | |
| description: 'stable or rc (appends rc<N>; pip install ignores pre-release by default, users pin explicitly)' | |
| required: false | |
| type: choice | |
| options: | |
| - stable | |
| - rc | |
| default: stable | |
| rc-number: | |
| description: 'RC increment (1, 2, 3...) — only used when release-type=rc' | |
| required: false | |
| type: number | |
| default: 1 | |
| dry-run: | |
| description: 'Dry run (build only, no publish)' | |
| required: false | |
| type: boolean | |
| default: false | |
| # RC mode (added 2026-04-20 — see docs/guides/release-process.md): | |
| # - Appends `rc<N>` (PEP 440) to python/pyproject.toml version. | |
| # - `pip install totalreclaw` ignores the RC by default. Users opt in with | |
| # `pip install totalreclaw==2.1.0rc1`. | |
| # - Mutation is ephemeral; not committed back to main. | |
| # | |
| # Environment-binding rule (post-F-flip, 2.3.12-rc.1): | |
| # - BOTH stable and RC wheels default to PRODUCTION | |
| # (``api.totalreclaw.xyz``). No publish-time URL rewrite is needed — | |
| # the canonical default-URL site at ``_HARDCODED_DEFAULT_URL`` in | |
| # ``python/src/totalreclaw/relay.py`` already binds to production. | |
| # - Staging access is opt-in via | |
| # ``TOTALRECLAW_SERVER_URL=https://api-staging.totalreclaw.xyz`` at | |
| # runtime. The rule only changes the default. | |
| # - A pre-publish CI guard fails the build if any wheel still contains | |
| # ``api-staging`` (stranded staging defaults are forbidden). | |
| jobs: | |
| build: | |
| name: Build Python package | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install build tools | |
| run: pip install build | |
| # 2026-05-29 (closes totalreclaw-internal#356): same-version race | |
| # guard. Two workflow dispatches against rc-number=2 from | |
| # different branches on 2026-05-28 silently burnt the rc2 slot: | |
| # the first publish at 14:04 UTC (PR #285 F5 fix) succeeded and | |
| # took the slot; the second at 23:22 UTC (PR #286 F6+F7 work) | |
| # failed at Test step + would have hit PyPI's "version already | |
| # exists" 400 anyway. Downstream QA was misled into reporting | |
| # the F6+F7 code as "missing from package" when the actual | |
| # cause was the upload race. Fail-loud BEFORE Build + Test + Upload | |
| # so the operator knows immediately to bump rc-number. | |
| - name: Guard against same-version PyPI collision (rc + stable) | |
| if: inputs.dry-run != true | |
| env: | |
| INPUT_RELEASE_TYPE: ${{ inputs.release-type }} | |
| INPUT_RC_NUMBER: ${{ inputs.rc-number }} | |
| run: | | |
| cd python | |
| BASE_VERSION=$(grep '^version' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') | |
| # Apply rc suffix exactly the way "Apply RC version suffix" | |
| # step below applies it, so the guard's target matches what | |
| # the publish step will upload. | |
| if [ "$INPUT_RELEASE_TYPE" = "rc" ]; then | |
| BASE_NO_RC=$(echo "$BASE_VERSION" | sed -E 's/rc[0-9]+$//') | |
| TARGET_VERSION="${BASE_NO_RC}rc${INPUT_RC_NUMBER}" | |
| else | |
| TARGET_VERSION="$BASE_VERSION" | |
| fi | |
| echo "Target version: $TARGET_VERSION (release-type=$INPUT_RELEASE_TYPE)" | |
| # Probe PyPI with one retry on transient 5xx. Fail closed on | |
| # network errors — uncertain state is worse than blocked. | |
| for attempt in 1 2; do | |
| HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ | |
| "https://pypi.org/pypi/totalreclaw/${TARGET_VERSION}/json" || echo "000") | |
| echo "PyPI probe attempt $attempt: HTTP $HTTP_CODE" | |
| if [ "$HTTP_CODE" = "404" ]; then | |
| echo "Version slot free. Proceeding to build." | |
| exit 0 | |
| fi | |
| if [ "$HTTP_CODE" = "200" ]; then | |
| cat >&2 <<EOM | |
| ::error::Version ${TARGET_VERSION} already exists on PyPI. | |
| This usually means another workflow run published this RC number first | |
| from a different branch (the same-version race documented in | |
| totalreclaw-internal#356). To resolve: | |
| 1. Bump rc-number to the next free integer: | |
| gh workflow run publish-python-client.yml \\ | |
| --ref ${{ github.ref_name }} \\ | |
| -f release-type=rc -f rc-number=<next> | |
| 2. Or, if you intended to ship the same content as the existing | |
| ${TARGET_VERSION} on PyPI, no action needed — that build is live. | |
| 3. Or, if you need to change the BASE version, edit | |
| python/pyproject.toml's `version = "..."` first. | |
| The workflow refuses to proceed because pip / PyPI's idempotency | |
| guarantee blocks re-upload; downstream auto-QA would mis-diagnose | |
| the failure as missing code. | |
| EOM | |
| exit 1 | |
| fi | |
| # 5xx / network error: short sleep then retry once. | |
| if [ "$attempt" = "1" ]; then | |
| echo "Transient PyPI response. Retrying after 5s..." | |
| sleep 5 | |
| fi | |
| done | |
| echo "::error::PyPI returned HTTP $HTTP_CODE after retry. Refusing to proceed on uncertain state — re-dispatch when PyPI is reachable." >&2 | |
| exit 1 | |
| - name: Apply RC version suffix (rc mode only) | |
| if: inputs.release-type == 'rc' | |
| run: | | |
| cd python | |
| python3 <<'PY' | |
| import re, pathlib | |
| rc_num = "${{ inputs.rc-number }}" | |
| p = pathlib.Path('pyproject.toml') | |
| text = p.read_text() | |
| def repl(m): | |
| base = re.sub(r'rc\d+$', '', m.group(2)) | |
| return f'{m.group(1)}"{base}rc{rc_num}"' | |
| new_text = re.sub( | |
| r'(^version\s*=\s*)"([^"]+)"', | |
| repl, | |
| text, | |
| count=1, | |
| flags=re.MULTILINE, | |
| ) | |
| p.write_text(new_text) | |
| print("pyproject.toml head:") | |
| print('\n'.join(new_text.splitlines()[:10])) | |
| PY | |
| # Sync SKILL.md frontmatter `version:` field to the (post-mutation) | |
| # pyproject.toml version. Issue #352: rc.2.4.4rc2 shipped with | |
| # SKILL.md `version: 2.3.1rc10` because the field was hardcoded in | |
| # source and the publish workflow only mutated pyproject.toml. | |
| # Agents load SKILL.md from the installed package — a stale version | |
| # there causes them to follow an old install script that diverges | |
| # from the current setup guide. Mirroring the pyproject mutation | |
| # here closes that drift structurally. | |
| - name: Sync SKILL.md version field to pyproject.toml version | |
| run: | | |
| python3 <<'PY' | |
| import re, pathlib | |
| pyproject = pathlib.Path('python/pyproject.toml').read_text() | |
| m = re.search(r'^version\s*=\s*"([^"]+)"', pyproject, flags=re.MULTILINE) | |
| if not m: | |
| raise SystemExit("pyproject.toml version line not found") | |
| target_version = m.group(1) | |
| skill = pathlib.Path('python/src/totalreclaw/hermes/SKILL.md') | |
| text = skill.read_text() | |
| new_text, n = re.subn( | |
| r'(^version:\s*).*$', | |
| lambda mm: f'{mm.group(1)}{target_version}', | |
| text, | |
| count=1, | |
| flags=re.MULTILINE, | |
| ) | |
| if n != 1: | |
| raise SystemExit("SKILL.md frontmatter `version:` line not found") | |
| skill.write_text(new_text) | |
| print(f"SKILL.md version field synced to {target_version}") | |
| print('\n'.join(new_text.splitlines()[:8])) | |
| PY | |
| # 2.3.12-rc.1 (F flip): no URL rewrite needed — the source at | |
| # `_HARDCODED_DEFAULT_URL` already binds to production for both | |
| # release types. | |
| - name: Build sdist and wheel | |
| run: cd python && python -m build | |
| # 2.3.12-rc.1 (F flip) pre-publish CI guard. Both stable and RC | |
| # wheels MUST default to production. Any stranded ``api-staging`` | |
| # literal is a release-blocker. | |
| - name: Verify wheel default-URL binding (post-F-flip — production for both) | |
| run: | | |
| cd python | |
| set -euo pipefail | |
| rm -rf /tmp/wheel-check | |
| mkdir -p /tmp/wheel-check | |
| for whl in dist/*.whl; do | |
| python -m zipfile -e "$whl" /tmp/wheel-check/ | |
| done | |
| echo "Scanning extracted wheel for URL literals..." | |
| default_url_line=$(grep -RIn '^_HARDCODED_DEFAULT_URL\s*=' /tmp/wheel-check/ --include='*.py' || true) | |
| echo "Canonical default-URL line: ${default_url_line:-<not-found>}" | |
| if [ -z "$default_url_line" ]; then | |
| echo "::error::Canonical _HARDCODED_DEFAULT_URL line not found in wheel." | |
| exit 1 | |
| fi | |
| if echo "$default_url_line" | grep -q "api-staging\.totalreclaw\.xyz"; then | |
| echo "::error::Wheel default-URL still binds to staging. Both stable and RC must default to production." | |
| exit 1 | |
| fi | |
| if ! echo "$default_url_line" | grep -q "https://api\.totalreclaw\.xyz"; then | |
| echo "::error::Wheel default-URL is neither staging nor production. Inspect the line above." | |
| exit 1 | |
| fi | |
| echo "OK (${{ inputs.release-type }}): wheel binds to production by default." | |
| - uses: actions/upload-artifact@v6 | |
| with: | |
| name: python-dist | |
| path: python/dist/* | |
| test: | |
| name: Test package | |
| runs-on: ubuntu-latest | |
| needs: [build] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Build totalreclaw_core wheel from local source | |
| uses: PyO3/maturin-action@v1 | |
| with: | |
| working-directory: rust/totalreclaw-core | |
| args: --release --features python-extension --out dist -i python3.12 | |
| - name: Install local totalreclaw_core wheel | |
| run: pip install --force-reinstall rust/totalreclaw-core/dist/totalreclaw_core-*.whl | |
| - name: Install and test | |
| run: | | |
| cd python | |
| pip install -e ".[dev]" | |
| python -m pytest tests/ -v \ | |
| --ignore=tests/test_staging_integration.py \ | |
| --ignore=tests/cross_client_e2e.py \ | |
| --ignore=tests/test_cross_client.py \ | |
| --ignore=tests/test_embedding.py \ | |
| --ignore=tests/test_userop.py \ | |
| -x | |
| publish: | |
| name: Publish to PyPI | |
| needs: [build, test] | |
| runs-on: ubuntu-latest | |
| if: inputs.dry-run == false | |
| permissions: | |
| id-token: write | |
| steps: | |
| - uses: actions/download-artifact@v7 | |
| with: | |
| name: python-dist | |
| path: dist/ | |
| - name: Publish to PyPI | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| verbose: true | |
| attestations: false | |
| # After a successful RC publish, bump the human-readable "Most recent RC" | |
| # marker in the Hermes setup guide. The install commands are version-agnostic | |
| # (self-resolving), so this is display-only — but it keeps the doc the agent | |
| # fetches honest about what the newest cut is, with zero manual upkeep. | |
| bump-rc-doc: | |
| name: Bump latest-RC marker in hermes-setup.md | |
| needs: [publish] | |
| if: ${{ inputs.release-type == 'rc' && inputs.dry-run == false }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Compute target RC version | |
| id: ver | |
| run: | | |
| BASE_VERSION=$(grep '^version' python/pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') | |
| BASE_NO_RC=$(echo "$BASE_VERSION" | sed -E 's/rc[0-9]+$//') | |
| echo "v=${BASE_NO_RC}rc${{ inputs.rc-number }}" >> "$GITHUB_OUTPUT" | |
| - name: Update the LATEST_RC marker | |
| run: | | |
| python - "${{ steps.ver.outputs.v }}" <<'PY' | |
| import sys, re, pathlib | |
| v = sys.argv[1] | |
| p = pathlib.Path("docs/guides/hermes-setup.md") | |
| s = p.read_text() | |
| repl = f"<!-- LATEST_RC -->_Most recent RC published: `{v}`._<!-- /LATEST_RC -->" | |
| s2, n = re.subn(r"<!-- LATEST_RC -->.*?<!-- /LATEST_RC -->", repl, s, flags=re.S) | |
| if n == 0: | |
| raise SystemExit("LATEST_RC marker not found in hermes-setup.md — was it removed?") | |
| p.write_text(s2) | |
| print(f"hermes-setup.md latest-RC marker -> {v}") | |
| PY | |
| - name: Open / update the marker PR | |
| id: cpr | |
| uses: peter-evans/create-pull-request@v7 | |
| with: | |
| branch: ci/latest-rc-marker | |
| base: main | |
| commit-message: "docs(hermes-setup): latest RC = ${{ steps.ver.outputs.v }}" | |
| title: "docs(hermes-setup): latest RC = ${{ steps.ver.outputs.v }}" | |
| body: | | |
| Auto-bump of the display-only "Most recent RC published" marker after the **${{ steps.ver.outputs.v }}** PyPI publish, by `publish-python-client.yml`. | |
| The install commands in the guide are version-agnostic (`pip install --pre --upgrade totalreclaw`), so this number never affects an install — it just keeps the doc the Hermes agent fetches honest about the newest cut. | |
| 🤖 Generated with [Claude Code](https://claude.com/claude-code) | |
| delete-branch: true | |
| add-paths: | | |
| docs/guides/hermes-setup.md | |
| - name: Merge the marker PR (hands-off) | |
| if: steps.cpr.outputs.pull-request-number | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR="${{ steps.cpr.outputs.pull-request-number }}" | |
| # The marker change is one regex-replaced display line on a doc with | |
| # no required status checks. peter-evans opens the PR with | |
| # GITHUB_TOKEN, and GITHUB_TOKEN PRs do NOT trigger CI — so `--auto` | |
| # (which waits for checks) would hang forever. Merge immediately; | |
| # fall back to --auto only if a future required check blocks the | |
| # direct merge; never leave it dangling silently otherwise. | |
| gh pr merge --squash --delete-branch "$PR" \ | |
| || gh pr merge --squash --delete-branch --auto "$PR" \ | |
| || echo "::warning::marker PR #$PR could not be merged automatically — merge it manually." |