@ondeinference / onde / commits / ff2fdb3

Fix the crates.io publish guard that skipped 1.2.3 and 1.2.4

Both versions were tagged, ran a green "Release SDK Rust" workflow, and never reached crates.io. The publish job log says why: onde 1.2.4 is already on crates.io — skipping publish. It was not. The guard asked `cargo info onde@$VERSION`, and cargo run from inside this workspace resolves `onde` against the local package rather than the registry, so it succeeds for whatever version the working tree happens to carry. The answer was always yes, the skip was always taken, and the workflow exited 0 having uploaded nothing. Reproducible here: `cargo info onde@1.2.5` exits 0 in the repo and 101 anywhere else. scripts/crates-io-published.py queries the sparse index instead and keeps cargo out of it. It exits 0 published, 1 not published, 2 if it could not tell, and the workflow now treats that third case as a hard error rather than folding it into "not published". After publishing, the job asserts the version actually appeared on the index, because a green run that uploaded nothing is the failure that already shipped twice. Also added the root CHANGELOG.md to sync-release-version.py's checked changelogs. It was the only version source nothing validated, which is how it went from 1.1.1 straight to 1.2.5 with five releases unwritten in between. Preflight now fails on a missing section, and the skill's checklist and pitfalls say so. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 10, 2026 at 18:52 UTC ff2fdb3f3674623fc9b879e523abcd4c0c81dd4c
5 files changed +130 -5
.agents/skills/sdk-releases/SKILL.md
+3 -2
@@ -40,7 +40,7 @@ in a single commit before tagging.**
40 | 8 | `sdk/dart/rust/Cargo.lock` | `onde` package version | Run `cd sdk/dart/rust && cargo update -p onde` |
41 | 9 | `sdk/react-native/rust/Cargo.lock` | `onde` package version | Run `cd sdk/react-native/rust && cargo update -p onde` |
42 | 10 | `Cargo.lock` (root) | `onde` package version | Run `cargo check` at repo root |
43 -| 11 | `CHANGELOG.md` (root) | New `## 0.1.3` section | Prepend at top of file. Missed for every 1.2.x release, which is why the file jumps from 1.1.1 to 1.2.5 |
43 +| 11 | `CHANGELOG.md` (root) | New `## 0.1.3` section | Prepend at top of file. `sync-release-version.py --check` gates on it since 1.2.5; before that nothing did, which is why the file jumps from 1.1.1 to 1.2.5 |
44
45 ### Files you do NOT manually edit
46
@@ -371,7 +371,8 @@ All four must match. If any pair diverges, the relevant CI job fails.
371 | Forgot to update `sdk/react-native/rust/Cargo.lock` | npm SDK's Rust bridge builds against stale `onde` version | Run `cd sdk/react-native/rust && cargo update -p onde` |
372 | Forgot to update `sdk/dart/CHANGELOG.md` | pub.dev shows stale changelog | Prepend new `## 0.1.3` section before tagging |
373 | Forgot to bump `sdk/react-native/CHANGELOG.md` | npm shows stale changelog | Prepend new `## 0.1.3` section before tagging |
374 -| Forgot to update the root `CHANGELOG.md` | No CI failure at all, the file just goes stale silently | Prepend the new section before tagging. Nothing validates this one, so it is the easiest to miss |
374 +| Forgot to update the root `CHANGELOG.md` | Preflight fails: "CHANGELOG.md: no '## 0.1.3' section" | Prepend the new section before tagging |
375 +| crates.io publish reports success but nothing is published | `cargo publish` step logs "already on crates.io — skipping" for a version that is not there | Fixed in 1.2.5. The guard used `cargo info`, which resolves against the local workspace. If it recurs, check that `scripts/crates-io-published.py` is still what the workflow calls |
376 | Forgot to bump `sdk/kotlin/gradle.properties` `VERSION_NAME` | Kotlin CI fails: tag vs gradle.properties mismatch | Bump `VERSION_NAME`, amend commit, re-tag |
377 | Tag has `v` prefix (`v0.1.3`) | CI does not trigger — tag pattern requires bare semver | Delete the tag, re-tag without `v` |
378 | `CARGO_REGISTRY_TOKEN` missing | `cargo publish` fails with auth error | Create a scoped token at crates.io/settings/tokens, add as repo secret |
.github/workflows/release-sdk-rust.yml
+21 -2
@@ -50,14 +50,24 @@ jobs:
50 # Skip a version already on crates.io instead of hard-failing. crates.io is
51 # immutable, so a re-run of a partially-failed release would otherwise die
52 # here on "already uploaded" — the npm workflow has had this guard all along.
53 + #
54 + # The check must not go through cargo. `cargo info onde@$VERSION` run from
55 + # inside this workspace resolves against the local package rather than the
56 + # registry, so it answered yes for every version, the skip was always
57 + # taken, and 1.2.3 and 1.2.4 were tagged, reported green, and never
58 + # published. crates-io-published.py queries the index directly.
59 - name: Check whether this version is already published
60 id: published
61 run: |
62 VERSION="$(python3 scripts/sync-release-version.py --print)"
57 - if cargo info "onde@${VERSION}" >/dev/null 2>&1; then
58 - echo "onde ${VERSION} is already on crates.io — skipping publish."
63 + if python3 scripts/crates-io-published.py "$VERSION"; then
64 echo "skip=true" >> "$GITHUB_OUTPUT"
65 else
66 + status=$?
67 + if [ "$status" -ne 1 ]; then
68 + echo "::error::Could not determine whether onde ${VERSION} is published"
69 + exit "$status"
70 + fi
71 echo "skip=false" >> "$GITHUB_OUTPUT"
72 fi
73
@@ -72,3 +82,12 @@ jobs:
82 env:
83 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
84 run: cargo publish --locked
85 +
86 + # A green run that published nothing is the exact failure this workflow
87 + # already shipped twice, so assert the upload landed rather than trusting
88 + # the exit code above.
89 + - name: Verify the version reached crates.io
90 + if: startsWith(github.ref, 'refs/tags/') && steps.published.outputs.skip != 'true'
91 + run: |
92 + VERSION="$(python3 scripts/sync-release-version.py --print)"
93 + python3 scripts/crates-io-published.py "$VERSION" --wait
CHANGELOG.md
+2
@@ -13,6 +13,8 @@ None of this reaches the UniFFI surface yet, so the Swift, Kotlin, Flutter/Dart,
13
14 * The React Native package publishes through npm trusted publishing (OIDC) instead of a long-lived `NPM_TOKEN`. The publish job requests `id-token: write` and upgrades npm before publishing, since Node 22 still ships npm 10 and trusted publishing needs 11.5.1 or newer. The trusted publisher configured on npmjs.com has to keep matching what the job declares, or npm answers the publish with a 404.
15 * The npm workflow runs a preflight gate before the publish step.
16 +* Fixed the crates.io publish guard. It asked `cargo info onde@$VERSION`, which resolves against the local workspace rather than the registry when run from inside the repo, so it reported every version as already published and skipped the upload while exiting green. That is why 1.2.3 and 1.2.4 were tagged, showed a passing release run, and never reached crates.io. The check now queries the sparse index through `scripts/crates-io-published.py`, and the workflow asserts the version is actually on the index after publishing.
17 +* `sync-release-version.py --check` now gates on the root `CHANGELOG.md` as well as the Dart and React Native ones, so a release cannot be tagged without notes here.
18
19 ### Packaging
20
scripts/crates-io-published.py new
+99
@@ -0,0 +1,99 @@
1 +#!/usr/bin/env python3
2 +"""Ask crates.io whether a version of a crate is already published.
3 +
4 +Exits 0 if it is, 1 if it is not, and 2 if the question could not be answered.
5 +Release workflows use the exit code to decide whether to skip a publish, and
6 +again afterwards to assert the publish actually landed.
7 +
8 +This exists because the obvious answer is wrong. `cargo info onde@1.2.4` run
9 +from inside the onde workspace resolves `onde` against the local package, not
10 +the registry, so it succeeds for whatever version the working tree happens to
11 +carry. The release workflow used that as its idempotency guard, concluded every
12 +version was already published, skipped the upload, and exited green: 1.2.3 and
13 +1.2.4 were tagged and never reached crates.io. Query the index directly and
14 +keep cargo out of it.
15 +
16 +Usage:
17 + crates-io-published.py 1.2.5 # defaults to the `onde` crate
18 + crates-io-published.py 1.2.5 --crate x
19 + crates-io-published.py 1.2.5 --wait # retry until it appears
20 +"""
21 +
22 +import argparse
23 +import json
24 +import sys
25 +import time
26 +import urllib.error
27 +import urllib.request
28 +
29 +INDEX = "https://index.crates.io"
30 +USER_AGENT = "onde-release (https://github.com/ondeinference/onde)"
31 +
32 +
33 +def index_path(crate):
34 + """The sparse index shards by name length, then by leading characters."""
35 + name = crate.lower()
36 + if len(name) == 1:
37 + return "1/{}".format(name)
38 + if len(name) == 2:
39 + return "2/{}".format(name)
40 + if len(name) == 3:
41 + return "3/{}/{}".format(name[0], name)
42 + return "{}/{}/{}".format(name[0:2], name[2:4], name)
43 +
44 +
45 +def published_versions(crate):
46 + url = "{}/{}".format(INDEX, index_path(crate))
47 + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
48 + try:
49 + with urllib.request.urlopen(request, timeout=30) as response:
50 + body = response.read().decode("utf-8")
51 + except urllib.error.HTTPError as error:
52 + if error.code == 404:
53 + return []
54 + raise
55 + versions = []
56 + for line in body.splitlines():
57 + line = line.strip()
58 + if line:
59 + versions.append(json.loads(line)["vers"])
60 + return versions
61 +
62 +
63 +def main():
64 + parser = argparse.ArgumentParser(description=__doc__)
65 + parser.add_argument("version")
66 + parser.add_argument("--crate", default="onde")
67 + parser.add_argument(
68 + "--wait",
69 + action="store_true",
70 + help="retry until the version appears; the index is a CDN and lags a publish",
71 + )
72 + parser.add_argument("--attempts", type=int, default=10)
73 + parser.add_argument("--interval", type=int, default=15)
74 + args = parser.parse_args()
75 +
76 + attempts = args.attempts if args.wait else 1
77 + for attempt in range(1, attempts + 1):
78 + try:
79 + versions = published_versions(args.crate)
80 + except (urllib.error.URLError, OSError, ValueError) as error:
81 + print("could not reach the crates.io index: {}".format(error), file=sys.stderr)
82 + return 2
83 + if args.version in versions:
84 + print("{} {} is on crates.io".format(args.crate, args.version))
85 + return 0
86 + if attempt < attempts:
87 + print(
88 + "attempt {}: {} {} not visible yet, waiting {}s".format(
89 + attempt, args.crate, args.version, args.interval
90 + )
91 + )
92 + time.sleep(args.interval)
93 +
94 + print("{} {} is not on crates.io".format(args.crate, args.version))
95 + return 1
96 +
97 +
98 +if __name__ == "__main__":
99 + sys.exit(main())
scripts/sync-release-version.py
+5 -1
@@ -28,6 +28,7 @@ from pathlib import Path
28 REPO_ROOT = Path(__file__).resolve().parent.parent
29
30 CARGO_TOML = REPO_ROOT / "Cargo.toml"
31 +ROOT_CHANGELOG = REPO_ROOT / "CHANGELOG.md"
32 DART_PUBSPEC = REPO_ROOT / "sdk/dart/pubspec.yaml"
33 DART_CHANGELOG = REPO_ROOT / "sdk/dart/CHANGELOG.md"
34 RN_PACKAGE_JSON = REPO_ROOT / "sdk/react-native/package.json"
@@ -36,7 +37,10 @@ RN_RUST_CARGO = REPO_ROOT / "sdk/react-native/rust/Cargo.toml"
37 RN_CHANGELOG = REPO_ROOT / "sdk/react-native/CHANGELOG.md"
38 KOTLIN_PROPS = REPO_ROOT / "sdk/kotlin/gradle.properties"
39
39 -CHANGELOGS = (DART_CHANGELOG, RN_CHANGELOG)
40 +# The root changelog is in here so `--check` gates on it like the other two.
41 +# It is the only version source nothing validated, which is how it went from
42 +# 1.1.1 straight to 1.2.5 with five releases unwritten in between.
43 +CHANGELOGS = (ROOT_CHANGELOG, DART_CHANGELOG, RN_CHANGELOG)
44
45 # Cargo.lock entries carrying a release version, and the packages to patch in
46 # each. A version bump only changes these strings, so they are rewritten