@ondeinference / onde / commits / 4df88c6

WIP

Seto Elkahfi committed Jul 23, 2026 at 00:04 UTC 4df88c6680a957881f128d459d30ee0c7f3938c6
2 files changed +468
Makefile new
+103
@@ -0,0 +1,103 @@
1 +.PHONY: help patch minor major custom release release-commit tag sync-version relock verify
2 +
3 +SYNC := python3 scripts/sync-release-version.py
4 +VERSION = $(shell $(SYNC) --print)
5 +
6 +# Branch the tag must live on. Unlike smbcloud-cli (which tags development),
7 +# onde requires the tag to point at a commit on main that carries every
8 +# workflow file — see .agents/skills/sdk-releases/SKILL.md.
9 +RELEASE_BRANCH := main
10 +
11 +help:
12 + @echo "Release commands:"
13 + @echo " make patch # 1.2.2 -> 1.2.3"
14 + @echo " make minor # 1.2.2 -> 1.3.0"
15 + @echo " make major # 1.2.2 -> 2.0.0"
16 + @echo " make custom VERSION=1.3.0"
17 + @echo ""
18 + @echo " ...then fill in the CHANGELOG stubs and:"
19 + @echo " make release-commit # verify + commit the bump"
20 + @echo " make tag # on $(RELEASE_BRANCH), after merging"
21 + @echo ""
22 + @echo "Utilities:"
23 + @echo " make verify # check every version source agrees"
24 + @echo " make sync-version # re-propagate the current version"
25 + @echo " make relock # re-resolve lockfiles (only when deps change)"
26 +
27 +release:
28 + @test -n "$(BUMP)" || (echo "BUMP is required" && exit 1)
29 + @if [ "$(BUMP)" = "custom" ] && [ -z "$(VERSION_ARG)" ]; then \
30 + echo "VERSION is required for custom releases"; exit 1; \
31 + fi
32 + @case "$$(git branch --show-current)" in \
33 + development|release/*) ;; \
34 + *) echo "Releases must be prepared on development or release/*"; exit 1;; \
35 + esac
36 + @if [ -n "$$(git status --short --untracked-files=all)" ]; then \
37 + echo "Working tree must be clean before preparing a release"; exit 1; \
38 + fi
39 + @if [ "$(BUMP)" = "custom" ]; then \
40 + $(SYNC) --set "$(VERSION_ARG)"; \
41 + else \
42 + $(SYNC) --bump "$(BUMP)"; \
43 + fi
44 + @echo ""
45 + @echo "Version bumped to $$($(SYNC) --print)."
46 + @echo "Next: describe the release in the CHANGELOG stubs, then 'make release-commit'."
47 +
48 +patch:
49 + @$(MAKE) --no-print-directory release BUMP=patch
50 +
51 +minor:
52 + @$(MAKE) --no-print-directory release BUMP=minor
53 +
54 +major:
55 + @$(MAKE) --no-print-directory release BUMP=major
56 +
57 +custom:
58 + @$(MAKE) --no-print-directory release BUMP=custom VERSION_ARG="$(VERSION)"
59 +
60 +release-commit: verify
61 + @release_version="$$($(SYNC) --print)"; \
62 + git add -A; \
63 + git commit -m "$$release_version"; \
64 + echo ""; \
65 + echo "Committed $$release_version."; \
66 + echo "Next: git checkout $(RELEASE_BRANCH) && git merge development --no-ff --no-edit && make tag"
67 +
68 +tag: verify
69 + @current="$$(git branch --show-current)"; \
70 + if [ "$$current" != "$(RELEASE_BRANCH)" ]; then \
71 + echo "Tag must be cut on $(RELEASE_BRANCH), currently on $$current"; exit 1; \
72 + fi
73 + @if [ -n "$$(git status --short --untracked-files=all)" ]; then \
74 + echo "Working tree must be clean before tagging"; exit 1; \
75 + fi
76 + @release_version="$$($(SYNC) --print)"; \
77 + if git rev-parse -q --verify "refs/tags/$$release_version" >/dev/null; then \
78 + echo "Tag $$release_version already exists — registries are immutable, bump instead"; \
79 + exit 1; \
80 + fi; \
81 + git tag "$$release_version"; \
82 + echo "Tagged $$release_version (bare semver — a 'v' prefix will not trigger CI)."; \
83 + echo "Next: git push <remote> development $(RELEASE_BRANCH) $$release_version"
84 +
85 +sync-version:
86 + @$(SYNC)
87 +
88 +verify:
89 + @$(SYNC) --check
90 +
91 +# A version bump patches the lockfile version strings in-process, so it needs
92 +# no cargo and no network. Use this only when dependencies actually changed:
93 +# cargo metadata re-resolves (no compile, unlike `cargo check`), but each
94 +# manifest costs ~72s of network because the [patch.crates-io] sysctl git
95 +# dependency is refetched on every resolve.
96 +relock:
97 + @echo "Re-resolving lockfiles (network-bound, ~3min)..."
98 + @cargo metadata --format-version 1 --quiet >/dev/null
99 + @cargo metadata --format-version 1 --quiet \
100 + --manifest-path sdk/dart/rust/Cargo.toml >/dev/null
101 + @cargo metadata --format-version 1 --quiet \
102 + --manifest-path sdk/react-native/rust/Cargo.toml >/dev/null
103 + @echo " Cargo.lock, sdk/dart/rust/Cargo.lock, sdk/react-native/rust/Cargo.lock"
scripts/sync-release-version.py new
+365
@@ -0,0 +1,365 @@
1 +#!/usr/bin/env python3
2 +"""Propagate the release version from Cargo.toml to every other version source.
3 +
4 +The root Cargo.toml `[package] version` is the single source of truth. Every
5 +other manifest in the repo is derived from it by this script, so a release bump
6 +is one command instead of the ten-row manual checklist in
7 +.agents/skills/sdk-releases/SKILL.md.
8 +
9 +Every substitution fails loudly if its pattern stops matching, so a manifest
10 +that gets restructured breaks the release instead of silently keeping a stale
11 +version. That is how sdk/react-native/package-lock.json drifted four releases
12 +behind (1.1.2 while package.json said 1.2.1) without anything noticing.
13 +
14 +Usage:
15 + sync-release-version.py # re-propagate current version
16 + sync-release-version.py --bump patch # 1.2.2 -> 1.2.3, then propagate
17 + sync-release-version.py --set 1.3.0 # set explicitly, then propagate
18 + sync-release-version.py --check # verify only, no writes (CI gate)
19 + sync-release-version.py --print # print current version, no writes
20 +"""
21 +
22 +import argparse
23 +import json
24 +import re
25 +import sys
26 +from pathlib import Path
27 +
28 +REPO_ROOT = Path(__file__).resolve().parent.parent
29 +
30 +CARGO_TOML = REPO_ROOT / "Cargo.toml"
31 +DART_PUBSPEC = REPO_ROOT / "sdk/dart/pubspec.yaml"
32 +DART_CHANGELOG = REPO_ROOT / "sdk/dart/CHANGELOG.md"
33 +RN_PACKAGE_JSON = REPO_ROOT / "sdk/react-native/package.json"
34 +RN_PACKAGE_LOCK = REPO_ROOT / "sdk/react-native/package-lock.json"
35 +RN_RUST_CARGO = REPO_ROOT / "sdk/react-native/rust/Cargo.toml"
36 +RN_CHANGELOG = REPO_ROOT / "sdk/react-native/CHANGELOG.md"
37 +KOTLIN_PROPS = REPO_ROOT / "sdk/kotlin/gradle.properties"
38 +
39 +CHANGELOGS = (DART_CHANGELOG, RN_CHANGELOG)
40 +
41 +# Cargo.lock entries carrying a release version, and the packages to patch in
42 +# each. A version bump only changes these strings, so they are rewritten
43 +# directly instead of shelling out to cargo: `cargo metadata` re-resolves over
44 +# the network (~72s per manifest here, because the [patch.crates-io] sysctl git
45 +# dependency is refetched on every resolve), which made the bump both slow and
46 +# dependent on the network. Run `make relock` when dependencies actually change.
47 +LOCKFILES = (
48 + (REPO_ROOT / "Cargo.lock", ("onde",)),
49 + (REPO_ROOT / "sdk/dart/rust/Cargo.lock", ("onde",)),
50 + (REPO_ROOT / "sdk/react-native/rust/Cargo.lock", ("onde", "onde-react-native")),
51 +)
52 +
53 +# A freshly bumped changelog section carries this placeholder. `--check` refuses
54 +# to pass while it is still there, so an unwritten changelog blocks the release
55 +# rather than shipping stale notes to pub.dev and npm.
56 +PLACEHOLDER = "_TODO: describe this release_"
57 +
58 +SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
59 +
60 +
61 +class SyncError(Exception):
62 + """A version source could not be read or updated."""
63 +
64 +
65 +def rel(path):
66 + return path.relative_to(REPO_ROOT).as_posix()
67 +
68 +
69 +def read_text(path):
70 + try:
71 + return path.read_text(encoding="utf-8")
72 + except FileNotFoundError:
73 + raise SyncError("missing file: {}".format(rel(path)))
74 +
75 +
76 +def write_text(path, content):
77 + path.write_text(content, encoding="utf-8")
78 +
79 +
80 +def cargo_package_version(path):
81 + """Read `version` from the [package] table, ignoring dependency versions."""
82 + section = None
83 + for line in read_text(path).splitlines():
84 + stripped = line.strip()
85 + if stripped.startswith("[") and stripped.endswith("]"):
86 + section = stripped[1:-1]
87 + continue
88 + if section == "package":
89 + match = re.match(r'version\s*=\s*"([^"]+)"', stripped)
90 + if match:
91 + return match.group(1)
92 + raise SyncError("no [package] version in {}".format(rel(path)))
93 +
94 +
95 +def set_cargo_package_version(path, version):
96 + """Rewrite only the [package] table's version line."""
97 + lines = read_text(path).splitlines(keepends=True)
98 + section = None
99 + for index, line in enumerate(lines):
100 + stripped = line.strip()
101 + if stripped.startswith("[") and stripped.endswith("]"):
102 + section = stripped[1:-1]
103 + continue
104 + if section == "package" and re.match(r'version\s*=\s*"[^"]+"', stripped):
105 + lines[index] = re.sub(
106 + r'(version\s*=\s*")[^"]+(")',
107 + r"\g<1>{}\g<2>".format(version),
108 + line,
109 + count=1,
110 + )
111 + write_text(path, "".join(lines))
112 + return
113 + raise SyncError("no [package] version in {}".format(rel(path)))
114 +
115 +
116 +def lock_version_pattern(name):
117 + """Match the version line of a [[package]] entry by package name."""
118 + return re.compile(
119 + r'^(name = "{}"\nversion = ")([^"]+)(")'.format(re.escape(name)), re.MULTILINE
120 + )
121 +
122 +
123 +def read_lock_version(path, name):
124 + match = lock_version_pattern(name).search(read_text(path))
125 + if match is None:
126 + raise SyncError("no '{}' package entry in {}".format(name, rel(path)))
127 + return match.group(2)
128 +
129 +
130 +def set_lock_version(path, name, version):
131 + content = read_text(path)
132 + updated, count = lock_version_pattern(name).subn(
133 + r"\g<1>{}\g<3>".format(version), content, count=1
134 + )
135 + if count == 0:
136 + raise SyncError("no '{}' package entry in {}".format(name, rel(path)))
137 + if updated != content:
138 + write_text(path, updated)
139 + return True
140 + return False
141 +
142 +
143 +def sub_or_raise(content, pattern, replacement, path, description):
144 + updated, count = pattern.subn(replacement, content, count=1)
145 + if count == 0:
146 + raise SyncError(
147 + "could not update {} in {} — the file layout changed, "
148 + "fix the pattern in scripts/sync-release-version.py".format(
149 + description, rel(path)
150 + )
151 + )
152 + return updated
153 +
154 +
155 +def update_json_versions(path, version, keys):
156 + """Rewrite named version fields, preserving npm's on-disk JSON formatting."""
157 + data = json.loads(read_text(path))
158 + for key_path in keys:
159 + node = data
160 + for key in key_path[:-1]:
161 + if key not in node:
162 + raise SyncError(
163 + "missing key {} in {}".format(
164 + ".".join(str(k) for k in key_path), rel(path)
165 + )
166 + )
167 + node = node[key]
168 + node[key_path[-1]] = version
169 + write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
170 +
171 +
172 +def propagate(version):
173 + """Write `version` into every derived manifest. Returns changed paths."""
174 + before = {
175 + path: read_text(path)
176 + for path in (
177 + DART_PUBSPEC,
178 + RN_PACKAGE_JSON,
179 + RN_PACKAGE_LOCK,
180 + RN_RUST_CARGO,
181 + KOTLIN_PROPS,
182 + )
183 + }
184 +
185 + write_text(
186 + DART_PUBSPEC,
187 + sub_or_raise(
188 + before[DART_PUBSPEC],
189 + re.compile(r"^version:.*$", re.MULTILINE),
190 + "version: {}".format(version),
191 + DART_PUBSPEC,
192 + "pubspec version",
193 + ),
194 + )
195 +
196 + write_text(
197 + KOTLIN_PROPS,
198 + sub_or_raise(
199 + before[KOTLIN_PROPS],
200 + re.compile(r"^VERSION_NAME=.*$", re.MULTILINE),
201 + "VERSION_NAME={}".format(version),
202 + KOTLIN_PROPS,
203 + "VERSION_NAME",
204 + ),
205 + )
206 +
207 + set_cargo_package_version(RN_RUST_CARGO, version)
208 +
209 + update_json_versions(RN_PACKAGE_JSON, version, [("version",)])
210 + # lockfileVersion 3 records the root package version twice.
211 + update_json_versions(
212 + RN_PACKAGE_LOCK, version, [("version",), ("packages", "", "version")]
213 + )
214 +
215 + changed = []
216 + for path, original in before.items():
217 + if read_text(path) != original:
218 + changed.append(path)
219 +
220 + for lock_path, names in LOCKFILES:
221 + # List, not a generator: any() short-circuits and would skip the
222 + # remaining packages in a lockfile that carries more than one.
223 + if any([set_lock_version(lock_path, name, version) for name in names]):
224 + changed.append(lock_path)
225 +
226 + return changed
227 +
228 +
229 +def ensure_changelog_section(path, version):
230 + """Prepend a stub section for `version` if the changelog lacks one."""
231 + content = read_text(path)
232 + if re.search(r"^##\s+{}\s*$".format(re.escape(version)), content, re.MULTILINE):
233 + return False
234 + write_text(path, "## {}\n\n- {}\n\n{}".format(version, PLACEHOLDER, content))
235 + return True
236 +
237 +
238 +def check(version):
239 + """Verify every version source agrees with Cargo.toml. Returns problems."""
240 + problems = []
241 +
242 + def expect(path, actual, label):
243 + if actual != version:
244 + problems.append(
245 + "{}: {} is {}, expected {}".format(rel(path), label, actual, version)
246 + )
247 +
248 + pubspec = re.search(r"^version:\s*(\S+)\s*$", read_text(DART_PUBSPEC), re.MULTILINE)
249 + if pubspec is None:
250 + problems.append("{}: no version line".format(rel(DART_PUBSPEC)))
251 + else:
252 + expect(DART_PUBSPEC, pubspec.group(1), "version")
253 +
254 + props = re.search(r"^VERSION_NAME=(.*)$", read_text(KOTLIN_PROPS), re.MULTILINE)
255 + if props is None:
256 + problems.append("{}: no VERSION_NAME".format(rel(KOTLIN_PROPS)))
257 + else:
258 + expect(KOTLIN_PROPS, props.group(1).strip(), "VERSION_NAME")
259 +
260 + expect(RN_RUST_CARGO, cargo_package_version(RN_RUST_CARGO), "[package] version")
261 +
262 + expect(
263 + RN_PACKAGE_JSON, json.loads(read_text(RN_PACKAGE_JSON)).get("version"), "version"
264 + )
265 +
266 + lock = json.loads(read_text(RN_PACKAGE_LOCK))
267 + expect(RN_PACKAGE_LOCK, lock.get("version"), "version")
268 + expect(
269 + RN_PACKAGE_LOCK,
270 + lock.get("packages", {}).get("", {}).get("version"),
271 + 'packages[""].version',
272 + )
273 +
274 + for lock_path, names in LOCKFILES:
275 + for name in names:
276 + expect(lock_path, read_lock_version(lock_path, name), "{} version".format(name))
277 +
278 + for changelog in CHANGELOGS:
279 + content = read_text(changelog)
280 + section = re.search(
281 + r"^##\s+{}\s*$(.*?)(?=^##\s|\Z)".format(re.escape(version)),
282 + content,
283 + re.MULTILINE | re.DOTALL,
284 + )
285 + if section is None:
286 + problems.append("{}: no '## {}' section".format(rel(changelog), version))
287 + elif PLACEHOLDER in section.group(1):
288 + problems.append(
289 + "{}: '## {}' still has the {} placeholder".format(
290 + rel(changelog), version, PLACEHOLDER
291 + )
292 + )
293 +
294 + return problems
295 +
296 +
297 +def bump(version, part):
298 + major, minor, patch = (int(piece) for piece in version.split("."))
299 + if part == "major":
300 + return "{}.0.0".format(major + 1)
301 + if part == "minor":
302 + return "{}.{}.0".format(major, minor + 1)
303 + return "{}.{}.{}".format(major, minor, patch + 1)
304 +
305 +
306 +def main():
307 + parser = argparse.ArgumentParser(description=__doc__)
308 + group = parser.add_mutually_exclusive_group()
309 + group.add_argument("--bump", choices=("major", "minor", "patch"))
310 + group.add_argument("--set", dest="explicit", metavar="X.Y.Z")
311 + group.add_argument("--check", action="store_true")
312 + group.add_argument("--print", dest="print_only", action="store_true")
313 + args = parser.parse_args()
314 +
315 + try:
316 + current = cargo_package_version(CARGO_TOML)
317 +
318 + if args.print_only:
319 + print(current)
320 + return 0
321 +
322 + if args.check:
323 + problems = check(current)
324 + if problems:
325 + print("Version sources disagree with Cargo.toml ({}):".format(current))
326 + for problem in problems:
327 + print(" - {}".format(problem))
328 + return 1
329 + print("All version sources agree: {}".format(current))
330 + return 0
331 +
332 + version = current
333 + if args.explicit:
334 + if not SEMVER.match(args.explicit):
335 + raise SyncError(
336 + "--set expects bare semver X.Y.Z, got '{}'".format(args.explicit)
337 + )
338 + version = args.explicit
339 + elif args.bump:
340 + version = bump(current, args.bump)
341 +
342 + if version != current:
343 + set_cargo_package_version(CARGO_TOML, version)
344 + print("Cargo.toml: {} -> {}".format(current, version))
345 +
346 + changed = propagate(version)
347 + stubbed = [c for c in CHANGELOGS if ensure_changelog_section(c, version)]
348 +
349 + print("Release version: {}".format(version))
350 + if changed:
351 + for path in changed:
352 + print(" updated {}".format(rel(path)))
353 + else:
354 + print(" all manifests already in sync")
355 + for path in stubbed:
356 + print(" added '## {}' stub to {} — fill it in".format(version, rel(path)))
357 +
358 + return 0
359 + except SyncError as error:
360 + print("error: {}".format(error), file=sys.stderr)
361 + return 1
362 +
363 +
364 +if __name__ == "__main__":
365 + sys.exit(main())