Skip to content

Commit f3d80eb

Browse files
joaoh82claude
andauthored
SQLR-12 — make release.yml publish jobs idempotent (skip-if-already-published) (#156)
Re-dispatching release.yml at a version where some-but-not-all artifacts already published (the v0.11.0 wave: the engine crate 413'd but every other channel had shipped) used to fail on every job whose artifact was already on its registry ("crate version already uploaded", "cannot publish over existing version"). Completing a partial release meant hand-publishing the missing crates from a local checkout. Add a per-registry "already published?" guard to each registry-publish job so a re-dispatch at the same version skips what's done and publishes only what's missing: - crates.io (publish-crate/-ask/-mcp): GET crates.io/api/v1/crates/ <name>/<version> with a User-Agent (crates.io 403s without one); 200 -> skip the cargo publish step, 404 -> publish. Transport error falls through to publish (preserves old fail-loud behavior). - npm (publish-nodejs/-wasm/-notes-example): `npm view <pkg>@<ver> version` non-empty -> skip the npm publish step. - PyPI (publish-python): GET pypi.org/pypi/sqlrite/<ver>/json logged for visibility; flip skip-existing false->true so a re-run uploads only the missing wheels (file-granular is the right unit for PyPI's multi-wheel wave) instead of failing on the first already-present file. Each guard emits a ::notice:: so the run log explains every skip. The GitHub-Release-only jobs (publish-ffi/-desktop/-go/build-mcp-binaries) were already idempotent via softprops/action-gh-release create-or-update and tolerate an existing tag/release — left unchanged. docs/release-plan.md: rewrite the "Sad path" section to document same-version re-dispatch recovery (retiring the old "never reuse a tag, always bump past" workaround) and correct the stale tag-all "aborts on existing tag" claim (it skips with a ::notice::). Verified the guard decisions against the live registries: a re-dispatch at the published 0.11.0 skips all single-artifact channels; the genuinely unpublished sqlrite-notes@0.11.0 still publishes; a fresh 0.99.0 publishes everywhere. actionlint (incl. shellcheck of all run: blocks) clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc071de commit f3d80eb

2 files changed

Lines changed: 191 additions & 21 deletions

File tree

.github/workflows/release.yml

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,33 @@ jobs:
173173
with:
174174
shared-key: publish-crate
175175

176+
# Idempotency guard (SQLR-12). A re-dispatch of release.yml at a
177+
# version where some-but-not-all artifacts already published (the
178+
# v0.11.0 partial failure: the engine 413'd but every other channel
179+
# had already shipped) must SKIP what's done and publish only what's
180+
# missing — instead of erroring with "crate version already uploaded".
181+
# crates.io returns HTTP 200 for an existing crate@version, 404 for a
182+
# missing one. The `User-Agent` header is mandatory: crates.io 403s
183+
# any request without one. On a transport error (curl fails → "000")
184+
# we fall through to publish, preserving the old fail-loud behavior.
185+
- name: Skip if already on crates.io
186+
id: published_check
187+
run: |
188+
V="${{ needs.detect.outputs.version }}"
189+
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
190+
-H 'User-Agent: sqlrite-release-ci (https://github.com/joaoh82/rust_sqlite)' \
191+
"https://crates.io/api/v1/crates/sqlrite-engine/$V" || true)
192+
CODE=${CODE:-000}
193+
if [ "$CODE" = "200" ]; then
194+
echo "skip=true" >> "$GITHUB_OUTPUT"
195+
echo "::notice::sqlrite-engine@$V already on crates.io (HTTP 200) — skipping cargo publish"
196+
else
197+
echo "skip=false" >> "$GITHUB_OUTPUT"
198+
echo "::notice::sqlrite-engine@$V not found on crates.io (HTTP $CODE) — will publish"
199+
fi
200+
176201
- name: cargo publish
202+
if: steps.published_check.outputs.skip != 'true'
177203
env:
178204
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
179205
run: |
@@ -236,7 +262,26 @@ jobs:
236262
with:
237263
shared-key: publish-ask
238264

265+
# Idempotency guard (SQLR-12) — see publish-crate for the full
266+
# rationale. crates.io: 200 = already published (skip), else publish.
267+
- name: Skip if already on crates.io
268+
id: published_check
269+
run: |
270+
V="${{ needs.detect.outputs.version }}"
271+
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
272+
-H 'User-Agent: sqlrite-release-ci (https://github.com/joaoh82/rust_sqlite)' \
273+
"https://crates.io/api/v1/crates/sqlrite-ask/$V" || true)
274+
CODE=${CODE:-000}
275+
if [ "$CODE" = "200" ]; then
276+
echo "skip=true" >> "$GITHUB_OUTPUT"
277+
echo "::notice::sqlrite-ask@$V already on crates.io (HTTP 200) — skipping cargo publish"
278+
else
279+
echo "skip=false" >> "$GITHUB_OUTPUT"
280+
echo "::notice::sqlrite-ask@$V not found on crates.io (HTTP $CODE) — will publish"
281+
fi
282+
239283
- name: cargo publish
284+
if: steps.published_check.outputs.skip != 'true'
240285
env:
241286
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
242287
# `--no-verify` mirrors `publish-crate` — Release-PR CI
@@ -302,7 +347,26 @@ jobs:
302347
with:
303348
shared-key: publish-mcp
304349

350+
# Idempotency guard (SQLR-12) — see publish-crate for the full
351+
# rationale. crates.io: 200 = already published (skip), else publish.
352+
- name: Skip if already on crates.io
353+
id: published_check
354+
run: |
355+
V="${{ needs.detect.outputs.version }}"
356+
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
357+
-H 'User-Agent: sqlrite-release-ci (https://github.com/joaoh82/rust_sqlite)' \
358+
"https://crates.io/api/v1/crates/sqlrite-mcp/$V" || true)
359+
CODE=${CODE:-000}
360+
if [ "$CODE" = "200" ]; then
361+
echo "skip=true" >> "$GITHUB_OUTPUT"
362+
echo "::notice::sqlrite-mcp@$V already on crates.io (HTTP 200) — skipping cargo publish"
363+
else
364+
echo "skip=false" >> "$GITHUB_OUTPUT"
365+
echo "::notice::sqlrite-mcp@$V not found on crates.io (HTTP $CODE) — will publish"
366+
fi
367+
305368
- name: cargo publish
369+
if: steps.published_check.outputs.skip != 'true'
306370
env:
307371
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
308372
# `--no-verify` mirrors `publish-crate` / `publish-ask` —
@@ -827,17 +891,40 @@ jobs:
827891
- name: List files about to be uploaded
828892
run: ls -la dist/
829893

830-
# Single atomic upload of all wheels + sdist. If any file
831-
# fails to upload, none are published — no partial wave
832-
# on PyPI.
894+
# Idempotency log line (SQLR-12). PyPI's JSON API returns HTTP 200
895+
# for an existing project/version, 404 otherwise. Unlike the
896+
# single-artifact crates.io / npm jobs, PyPI's publish unit is a
897+
# *set* of files (one wheel per platform + the sdist), so we do NOT
898+
# gate the whole step on this check: a partial wave (some wheels
899+
# landed before the job died) would leave the version "present" yet
900+
# incomplete, and a version-level skip would strand the missing
901+
# wheels. Instead this step just records the state in the run log;
902+
# `skip-existing: true` below does the actual file-granular skipping.
903+
- name: Report PyPI publish state
904+
run: |
905+
V="${{ needs.detect.outputs.version }}"
906+
CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
907+
"https://pypi.org/pypi/sqlrite/$V/json" || true)
908+
CODE=${CODE:-000}
909+
if [ "$CODE" = "200" ]; then
910+
echo "::notice::sqlrite==$V already present on PyPI (HTTP 200) — upload will skip files already there (skip-existing)"
911+
else
912+
echo "::notice::sqlrite==$V not found on PyPI (HTTP $CODE) — uploading all wheels + sdist"
913+
fi
914+
915+
# Upload all wheels + sdist aggregated from the build matrix.
916+
# `skip-existing: true` (SQLR-12) is what makes this job idempotent:
917+
# a re-dispatch (full or partial-wave recovery) uploads only the
918+
# files not yet on PyPI and silently skips the rest, instead of the
919+
# old `skip-existing: false` which failed loudly the moment any one
920+
# file already existed. File-granular skipping is the right unit for
921+
# PyPI's multi-file publish — it cleanly handles both a fully-shipped
922+
# version (every file skipped → no-op) and a half-shipped one.
833923
- name: Publish to PyPI
834924
uses: pypa/gh-action-pypi-publish@release/v1
835925
with:
836926
packages-dir: dist
837-
# Keep `skip-existing: false` so a re-run of this job
838-
# (after a partial-failure scenario) fails loudly rather
839-
# than silently ignoring already-uploaded files.
840-
skip-existing: false
927+
skip-existing: true
841928

842929
- name: GitHub Release
843930
uses: softprops/action-gh-release@v2
@@ -1080,6 +1167,26 @@ jobs:
10801167
# devDep pulled in by accident would be visible here.
10811168
npm pack --dry-run
10821169
1170+
# Idempotency guard (SQLR-12) — see publish-crate for the full
1171+
# rationale. `npm view <pkg>@<version> version` prints the version
1172+
# on a hit and exits non-zero with empty stdout on a miss; the
1173+
# `|| true` keeps the `-e` shell from aborting on the miss path.
1174+
# npm's publish unit is a single tarball per version, so a present
1175+
# version means a complete publish — a clean version-level skip.
1176+
- name: Skip if already on npm
1177+
id: published_check
1178+
working-directory: sdk/nodejs
1179+
run: |
1180+
V="${{ needs.detect.outputs.version }}"
1181+
EXISTING=$(npm view "@joaoh82/sqlrite@$V" version 2>/dev/null || true)
1182+
if [ -n "$EXISTING" ]; then
1183+
echo "skip=true" >> "$GITHUB_OUTPUT"
1184+
echo "::notice::@joaoh82/sqlrite@$V already on npm ($EXISTING) — skipping npm publish"
1185+
else
1186+
echo "skip=false" >> "$GITHUB_OUTPUT"
1187+
echo "::notice::@joaoh82/sqlrite@$V not found on npm — will publish"
1188+
fi
1189+
10831190
# Single atomic publish via OIDC trusted publisher.
10841191
#
10851192
# The `--provenance` flag is what tells npm CLI to use the
@@ -1111,6 +1218,7 @@ jobs:
11111218
# diagnosable from the run log without re-running with
11121219
# debug-on. Cheap insurance against another silent failure.
11131220
- name: Publish to npm
1221+
if: steps.published_check.outputs.skip != 'true'
11141222
working-directory: sdk/nodejs
11151223
run: npm publish --access public --provenance --loglevel verbose
11161224

@@ -1226,7 +1334,24 @@ jobs:
12261334
# node_modules).
12271335
npm pack --dry-run
12281336
1337+
# Idempotency guard (SQLR-12) — see publish-crate / publish-nodejs.
1338+
# Package is the unscoped `sqlrite-notes`.
1339+
- name: Skip if already on npm
1340+
id: published_check
1341+
working-directory: examples/nodejs-notes
1342+
run: |
1343+
V="${{ needs.detect.outputs.version }}"
1344+
EXISTING=$(npm view "sqlrite-notes@$V" version 2>/dev/null || true)
1345+
if [ -n "$EXISTING" ]; then
1346+
echo "skip=true" >> "$GITHUB_OUTPUT"
1347+
echo "::notice::sqlrite-notes@$V already on npm ($EXISTING) — skipping npm publish"
1348+
else
1349+
echo "skip=false" >> "$GITHUB_OUTPUT"
1350+
echo "::notice::sqlrite-notes@$V not found on npm — will publish"
1351+
fi
1352+
12291353
- name: Publish to npm
1354+
if: steps.published_check.outputs.skip != 'true'
12301355
working-directory: examples/nodejs-notes
12311356
run: npm publish --access public --provenance --loglevel verbose
12321357

@@ -1386,12 +1511,32 @@ jobs:
13861511
echo "ACTIONS_ID_TOKEN_REQUEST_TOKEN is set: ${ACTIONS_ID_TOKEN_REQUEST_TOKEN:+yes}${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-NO}"
13871512
npm pack --dry-run
13881513
1514+
# Idempotency guard (SQLR-12) — see publish-crate / publish-nodejs.
1515+
# Package is the scoped `@joaoh82/sqlrite-wasm`. (The wasm-pack build
1516+
# above still runs on a skip — gating only the publish keeps this
1517+
# consistent with the other channels; the build is cheap relative to
1518+
# the rest of the release wave.)
1519+
- name: Skip if already on npm
1520+
id: published_check
1521+
working-directory: sdk/wasm/pkg
1522+
run: |
1523+
V="${{ needs.detect.outputs.version }}"
1524+
EXISTING=$(npm view "@joaoh82/sqlrite-wasm@$V" version 2>/dev/null || true)
1525+
if [ -n "$EXISTING" ]; then
1526+
echo "skip=true" >> "$GITHUB_OUTPUT"
1527+
echo "::notice::@joaoh82/sqlrite-wasm@$V already on npm ($EXISTING) — skipping npm publish"
1528+
else
1529+
echo "skip=false" >> "$GITHUB_OUTPUT"
1530+
echo "::notice::@joaoh82/sqlrite-wasm@$V not found on npm — will publish"
1531+
fi
1532+
13891533
# Atomic OIDC publish. Same flag combo proven in
13901534
# publish-nodejs: `--provenance` to trigger OIDC code path,
13911535
# `--access public` because scoped packages default to
13921536
# private, `--loglevel verbose` to keep error logs
13931537
# diagnosable.
13941538
- name: Publish to npm
1539+
if: steps.published_check.outputs.skip != 'true'
13951540
working-directory: sdk/wasm/pkg
13961541
run: npm publish --access public --provenance --loglevel verbose
13971542

docs/release-plan.md

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,11 @@ The "publish" half. Auto-fires on the release commit.
231231
commit: `sqlrite-vX.Y.Z`, `sqlrite-ffi-vX.Y.Z`,
232232
`sqlrite-py-vX.Y.Z`, `sqlrite-node-vX.Y.Z`, `sqlrite-wasm-vX.Y.Z`,
233233
`sdk/go/vX.Y.Z`, `sqlrite-desktop-vX.Y.Z`, `vX.Y.Z`. Pushes
234-
them. Runs *before* the publish jobs — if a tag already exists
235-
(accidental re-run, cosmic ray), the whole workflow aborts
236-
cleanly.
234+
them. Runs *before* the publish jobs. Idempotent on re-run: if a
235+
tag already exists (partial-failure re-dispatch, accidental
236+
re-trigger), that tag is skipped with a `::notice::` rather than
237+
failing, so a re-dispatch at the same version proceeds to the
238+
publish jobs instead of aborting.
237239
- **publish-crate**`cargo publish -p sqlrite-engine` the root
238240
crate to crates.io. (The crates.io name is `sqlrite-engine`, not
239241
`sqlrite`, because the short name was already taken by an
@@ -284,18 +286,41 @@ The "publish" half. Auto-fires on the release commit.
284286
in parallel. Umbrella GitHub Release finalizes. No branch-
285287
protection bypass needed, no deploy keys, no admin override.
286288
- **Sad path — publish fails after tag push**: say
287-
`publish-python` fails on wheel upload. The tag
288-
`sqlrite-py-vX.Y.Z` is already on the remote. **Convention:
289-
never reuse a tag, always bump past.** Next release is
290-
`v0.2.1`, not a re-try of `v0.2.0`. Partial success is visible
291-
— the `sqlrite-vX.Y.Z` crate *did* publish, the Python wheels
292-
didn't, and both facts are recorded. Operators can fix the
293-
Python SDK and re-dispatch `release.yml` in manual mode at
294-
`v0.2.1`.
289+
`publish-crate` fails while the other channels succeed (this is
290+
exactly the v0.11.0 wave — the engine crate hit a crates.io 413
291+
but `sqlrite-ask`, npm, PyPI, FFI, Go and desktop had all
292+
already shipped). The publish jobs are **idempotent** (SQLR-12):
293+
each one probes its registry first and skips with a `::notice::`
294+
when the version is already there. So the recovery is to fix the
295+
failing channel and **re-dispatch `release.yml` at the same
296+
version**`tag-all` skips the existing tags, the
297+
already-published channels skip their `publish` step, and only
298+
the missing artifact actually publishes. No tag bump required;
299+
the old "never reuse a tag, always bump past" workaround is
300+
retired. Per-registry guards:
301+
- **crates.io** (`publish-crate` / `-ask` / `-mcp`): `GET
302+
crates.io/api/v1/crates/<name>/<version>` (with a mandatory
303+
`User-Agent`) → HTTP 200 skips, 404 publishes.
304+
- **npm** (`publish-nodejs` / `-wasm` / `-notes-example`):
305+
`npm view <pkg>@<version> version` — non-empty skips.
306+
- **PyPI** (`publish-python`): `GET
307+
pypi.org/pypi/sqlrite/<version>/json` is logged for
308+
visibility, and `skip-existing: true` does the actual
309+
file-granular skipping — the right unit for PyPI's
310+
multi-wheel wave (a partial wave fills in the missing wheels
311+
without erroring on the ones already there).
312+
- **GitHub Releases** (`publish-ffi` / `-desktop` / `-go` /
313+
`build-mcp-binaries`): `softprops/action-gh-release` is
314+
create-or-update, so re-runs refresh the release in place.
315+
- **Sad path — a fully-successful release re-dispatched at the
316+
same version**: a clean no-op. Every tag is skipped, every
317+
`publish` step is skipped, GitHub Releases refresh in place —
318+
no wall of "already exists" failures.
295319
- **Sad path — an accidental `release: v…` commit message**: the
296-
auto-trigger fires. `tag-all` runs and finds the tags already
297-
exist (because the real release happened weeks ago). Workflow
298-
aborts with a clear "tag already exists" error. No damage.
320+
auto-trigger fires at a version that shipped weeks ago.
321+
`tag-all` finds every tag present and skips them; each publish
322+
job finds its artifact already on the registry and skips. The
323+
run is a green no-op. No damage.
299324

300325
## Pinned binaryen / wasm-opt
301326

0 commit comments

Comments
 (0)