Repository navigation
349 lines (316 loc) · 14.2 KB
/
Copy pathpublish-python-client.yml
File metadata and controls
349 lines (316 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
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."