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())