Skip to content

Commit 0d6ba04

Browse files
ci(release): keep every registry and manifest on one version
SharpeArena publishes to crates.io, npm and PyPI from manifests that nothing rewrote, so the next bump would have drifted: the publish step reads a stale version, finds it already on the registry, and skips - green run, nothing shipped. That is how the sibling repo's npm package stalled two releases behind. Rewrites five version sites from the workspace version (including the pyo3 crate that is excluded from the workspace, and the Python __version__ that had already gone stale), asserts manifests match the tag before publishing, and verifies all three registries serve the release afterwards.
2 parents cfaff11 + 880f8f5 commit 0d6ba04

5 files changed

Lines changed: 168 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ jobs:
7575
# npm trusted publishing (OIDC) needs npm >= 11.5; provenance is automatic.
7676
- run: npm install -g npm@latest
7777

78+
# cargo-release rewrites this manifest from the workspace version (the rules live in
79+
# crates/sharpearena/release.toml), so it must already equal the tag. If it does not,
80+
# the publish step below reads a stale version, finds it already on the registry, and
81+
# "skips" - a green run that shipped nothing. That is exactly how the sibling repo's
82+
# npm package stalled at 0.0.10 while its crates went to 0.0.11.
83+
- name: Assert the npm manifest matches the release tag
84+
if: startsWith(github.ref, 'refs/tags/v')
85+
working-directory: npm/sharpearena
86+
run: |
87+
TAG="${GITHUB_REF_NAME#v}"
88+
V=$(node -p "require('./package.json').version")
89+
if [ "$V" != "$TAG" ]; then
90+
echo "::error file=npm/sharpearena/package.json::version $V does not match tag $TAG - the manifest is stale, cut the release with 'cargo release' so pre-release-replacements rewrite it"
91+
exit 1
92+
fi
93+
echo "ok npm manifest is at $V"
94+
7895
- name: Publish (skip if version exists)
7996
working-directory: npm/sharpearena
8097
run: |
@@ -94,6 +111,25 @@ jobs:
94111
environment: pypi
95112
steps:
96113
- uses: actions/checkout@v6
114+
115+
# Same stale-manifest trap as the npm job: `skip-existing: true` below turns an
116+
# already-published version into a silent no-op, so a pyproject.toml left behind by
117+
# a hand-edited bump would ship nothing and still report green. crates/sharpearena-py
118+
# is excluded from the Cargo workspace, so only pre-release-replacements move it.
119+
- name: Assert the Python manifest matches the release tag
120+
if: startsWith(github.ref, 'refs/tags/v')
121+
run: |
122+
TAG="${GITHUB_REF_NAME#v}"
123+
fail=0
124+
for f in crates/sharpearena-py/pyproject.toml crates/sharpearena-py/Cargo.toml; do
125+
V=$(grep -m1 -E '^version = "' "$f" | sed 's/.*"\(.*\)".*/\1/')
126+
if [ "$V" != "$TAG" ]; then
127+
echo "::error file=$f::version $V does not match tag $TAG - the manifest is stale, cut the release with 'cargo release' so pre-release-replacements rewrite it"
128+
fail=1
129+
fi
130+
done
131+
exit $fail
132+
97133
- name: Build manylinux wheels + sdist
98134
uses: PyO3/maturin-action@v1
99135
with:
@@ -114,3 +150,60 @@ jobs:
114150
# Idempotent re-runs: a version already on PyPI is skipped, not an error
115151
# (matches the crates + npm skip-if-published guards in this workflow).
116152
skip-existing: true
153+
154+
# Every publish step above is skip-if-present, which makes re-runs idempotent but also
155+
# lets a surface that shipped nothing still report green. This job asks the three
156+
# registries directly, so an incomplete release fails the run instead of passing quietly.
157+
verify:
158+
name: verify every registry serves the release
159+
needs: [crates, npm, pypi]
160+
if: always() && startsWith(github.ref, 'refs/tags/v')
161+
runs-on: ubuntu-latest
162+
steps:
163+
- uses: actions/setup-node@v6
164+
with:
165+
node-version: 24
166+
- name: Assert crates.io, npm and PyPI all serve the tag version
167+
run: |
168+
TAG="${GITHUB_REF_NAME#v}"
169+
UA="sharpearena-release (general-liquidity)" # crates.io rejects requests without a User-Agent
170+
fail=0
171+
172+
if [ "${{ vars.PUBLISH_CRATES }}" = "true" ]; then
173+
for c in sharpearena sharpearena-wasm; do
174+
if curl -s -A "$UA" "https://crates.io/api/v1/crates/$c/$TAG" | grep -q "\"num\":\"$TAG\""; then
175+
echo "ok crates.io $c@$TAG"
176+
else
177+
echo "::error::crates.io is not serving $c@$TAG"; fail=1
178+
fi
179+
done
180+
fi
181+
182+
if [ "${{ vars.PUBLISH_NPM }}" = "true" ]; then
183+
ok=0
184+
# the registry read-path lags a few seconds behind a publish
185+
for i in $(seq 1 20); do
186+
if [ "$(npm view "@general-liquidity/sharpearena@$TAG" version 2>/dev/null)" = "$TAG" ]; then ok=1; break; fi
187+
sleep 5
188+
done
189+
if [ "$ok" = "1" ]; then
190+
echo "ok npm @general-liquidity/sharpearena@$TAG"
191+
else
192+
echo "::error::npm is not serving @general-liquidity/sharpearena@$TAG"; fail=1
193+
fi
194+
fi
195+
196+
if [ "${{ vars.PUBLISH_PYPI }}" = "true" ]; then
197+
ok=0
198+
for i in $(seq 1 20); do
199+
if curl -sf -A "$UA" "https://pypi.org/pypi/sharpearena/$TAG/json" >/dev/null; then ok=1; break; fi
200+
sleep 5
201+
done
202+
if [ "$ok" = "1" ]; then
203+
echo "ok PyPI sharpearena@$TAG"
204+
else
205+
echo "::error::PyPI is not serving sharpearena@$TAG"; fail=1
206+
fi
207+
fi
208+
209+
exit $fail

RELEASING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ cargo release patch --execute # bump shared version + rewrite pins + tag vX.Y.Z
1818
`release.toml` sets `publish = false` — the local machine never publishes. The `v*`
1919
tag triggers CI, which publishes via **OIDC Trusted Publishing** (no stored tokens).
2020

21+
Never hand-edit a version. `Cargo.toml` is the only place one is authored: the npm
22+
`package.json` and the two `crates/sharpearena-py` manifests (that crate is excluded from
23+
the Cargo workspace) are rewritten from the workspace version by `pre-release-replacements`
24+
in **`crates/sharpearena/release.toml`** — they live on the crate, not at the workspace
25+
root, because cargo-release resolves `file` relative to each crate being processed.
26+
27+
Three guards keep the surfaces in lockstep, because every publish step is skip-if-present
28+
and a stale manifest therefore ships nothing while still reporting green:
29+
30+
1. `pre-release-replacements` rewrite all three non-inherited manifests on every bump.
31+
2. The npm and PyPI jobs assert their manifest equals the tag *before* publishing.
32+
3. The `verify` job queries crates.io, npm and PyPI after the fact and fails the run if
33+
any of them is not serving the tag version.
34+
2135
## One-time publishing setup (pending)
2236

2337
Before the first CI publish, each registry needs its trusted publisher configured —

crates/sharpearena-py/python/sharpearena/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,4 @@
294294
"DEFAULT_THRESHOLDS",
295295
"register_envs",
296296
]
297-
__version__ = "0.6.0"
297+
__version__ = "0.7.0"

crates/sharpearena/release.toml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Version rewrites for the non-Cargo-workspace release surfaces, attached to this crate
2+
# because cargo-release resolves `file` relative to the crate being processed, not to the
3+
# workspace root. Workspace-level rules look under crates/<first-crate>/ and abort. This
4+
# crate is the root of the published surface, so the rules belong here; the shared release
5+
# settings stay in the workspace release.toml.
6+
#
7+
# Without these the manifests keep the previous version across a bump, the publish step
8+
# finds that version already on the registry, and skips - reporting success while shipping
9+
# nothing. That is exactly how the sibling repo's npm package stalled at 0.0.10 while its
10+
# crates went to 0.0.11. Cargo.toml is the only place a version is authored.
11+
12+
# npm: @general-liquidity/sharpearena. NOT workspace-inherited.
13+
[[pre-release-replacements]]
14+
file = "../../npm/sharpearena/package.json"
15+
search = '"version": "[0-9]+\.[0-9]+\.[0-9]+"'
16+
replace = '"version": "{{version}}"'
17+
exactly = 1
18+
19+
# PyPI: sharpearena. crates/sharpearena-py is EXCLUDED from the workspace (pyo3
20+
# extension-module needs an interpreter at build time), so cargo-release never touches
21+
# it on its own - both its pyproject.toml and its Cargo.toml need explicit rules.
22+
[[pre-release-replacements]]
23+
file = "../../crates/sharpearena-py/pyproject.toml"
24+
search = '^version = "[0-9]+\.[0-9]+\.[0-9]+"'
25+
replace = 'version = "{{version}}"'
26+
exactly = 1
27+
28+
[[pre-release-replacements]]
29+
file = "../../crates/sharpearena-py/Cargo.toml"
30+
search = '^version = "[0-9]+\.[0-9]+\.[0-9]+"'
31+
replace = 'version = "{{version}}"'
32+
exactly = 1
33+
34+
# The pyo3 crate pins the published `sharpearena` version alongside the path dep, so it
35+
# must move in lockstep or the sdist resolves against a version that no longer exists.
36+
[[pre-release-replacements]]
37+
file = "../../crates/sharpearena-py/Cargo.toml"
38+
search = 'sharpearena = \{ path = "\.\./sharpearena", version = "[0-9]+\.[0-9]+\.[0-9]+" \}'
39+
replace = 'sharpearena = { path = "../sharpearena", version = "{{version}}" }'
40+
exactly = 1
41+
42+
# The Python package reports its own version too, and this one had already gone stale: it
43+
# still said 0.6.0 after 0.6.1 and 0.7.0 shipped, so `sharpearena.__version__` lied about
44+
# which release was installed. Same drift, just further from the registries.
45+
[[pre-release-replacements]]
46+
file = "../../crates/sharpearena-py/python/sharpearena/__init__.py"
47+
search = '^__version__ = "[0-9]+\.[0-9]+\.[0-9]+"'
48+
replace = '__version__ = "{{version}}"'
49+
exactly = 1

release.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@
88
# cargo release patch --execute # bump + tag + push (publishing happens in CI)
99
#
1010
# Publishing is done by CI via OIDC Trusted Publishing on the `v*` tag (see
11-
# RELEASING.md), NOT from the local machine — so `publish = false` here.
11+
# RELEASING.md), NOT from the local machine — so `publish = false` here. This is not just
12+
# a preference: these crates are Trusted-Publishing-only on crates.io, so a token upload
13+
# from a laptop is refused with a 403 ("New versions of this crate can only be published
14+
# using Trusted Publishing") part-way through the workspace, leaving a half-published
15+
# release behind. The `v*` tag is the only publish path.
16+
#
17+
# The three registry manifests that are NOT Cargo-workspace-inherited (npm/sharpearena,
18+
# and crates/sharpearena-py, which is excluded from the workspace) are rewritten from the
19+
# workspace version by `pre-release-replacements`. Those rules live in
20+
# crates/sharpearena/release.toml, because cargo-release resolves `file` relative to each
21+
# crate rather than to the workspace root - a workspace-level rule aborts.
1222

1323
shared-version = true
1424
consolidate-commits = true

0 commit comments

Comments
 (0)