Skip to content

Publish totalreclaw Python client to PyPI #165

Publish totalreclaw Python client to PyPI

Publish totalreclaw Python client to PyPI #165

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."