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 lint_publishable():
298 """Find git dependencies, which `cargo publish` rejects outright.
299
300 This is the 1.2.0 crates.io failure: the mistralrs deps were pointed at the
301 fork's git branch, and the release burned 13 minutes of macOS runner time to
302 surface an error the manifest alone proves in milliseconds. Cargo.toml
303 documents pointing them back at git for local fork work, so this is a
304 release gate rather than part of --check.
305
306 [patch.crates-io] is exempt: crates.io consumers ignore it entirely.
307 """
308 problems = []
309 section = None
310
311 for number, line in enumerate(read_text(CARGO_TOML).splitlines(), start=1):
312 stripped = line.strip()
313 if stripped.startswith("#"):
314 continue
315 if stripped.startswith("[") and stripped.endswith("]"):
316 section = stripped[1:-1]
317 continue
318 if section is None or section.startswith("patch."):
319 continue
320
321 is_dependency_table = (
322 section in ("dependencies", "build-dependencies", "dev-dependencies")
323 or section.startswith(
324 ("dependencies.", "build-dependencies.", "dev-dependencies.")
325 )
326 or section.endswith(".dependencies")
327 )
328 if not is_dependency_table:
329 continue
330
331 if re.search(r"\bgit\s*=", stripped):
332 problems.append(
333 "Cargo.toml:{}: git dependency in [{}] — cargo publish "
334 "rejects it: {}".format(number, section, stripped)
335 )
336
337 return problems
338
339
340 def bump(version, part):
341 major, minor, patch = (int(piece) for piece in version.split("."))
342 if part == "major":
343 return "{}.0.0".format(major + 1)
344 if part == "minor":
345 return "{}.{}.0".format(major, minor + 1)
346 return "{}.{}.{}".format(major, minor, patch + 1)
347
348
349 def main():
350 parser = argparse.ArgumentParser(description=__doc__)
351 group = parser.add_mutually_exclusive_group()
352 group.add_argument("--bump", choices=("major", "minor", "patch"))
353 group.add_argument("--set", dest="explicit", metavar="X.Y.Z")
354 group.add_argument("--check", action="store_true")
355 group.add_argument("--print", dest="print_only", action="store_true")
356 group.add_argument("--lint-publish", dest="lint_publish", action="store_true")
357 args = parser.parse_args()
358
359 try:
360 current = cargo_package_version(CARGO_TOML)
361
362 if args.print_only:
363 print(current)
364 return 0
365
366 if args.lint_publish:
367 problems = lint_publishable()
368 if problems:
369 print("Cargo.toml is not publishable to crates.io:")
370 for problem in problems:
371 print(" - {}".format(problem))
372 return 1
373 print("Cargo.toml is publishable: no git dependencies.")
374 return 0
375
376 if args.check:
377 problems = check(current)
378 if problems:
379 print("Version sources disagree with Cargo.toml ({}):".format(current))
380 for problem in problems:
381 print(" - {}".format(problem))
382 return 1
383 print("All version sources agree: {}".format(current))
384 return 0
385
386 version = current
387 if args.explicit:
388 if not SEMVER.match(args.explicit):
389 raise SyncError(
390 "--set expects bare semver X.Y.Z, got '{}'".format(args.explicit)
391 )
392 version = args.explicit
393 elif args.bump:
394 version = bump(current, args.bump)
395
396 if version != current:
397 set_cargo_package_version(CARGO_TOML, version)
398 print("Cargo.toml: {} -> {}".format(current, version))
399
400 changed = propagate(version)
401 stubbed = [c for c in CHANGELOGS if ensure_changelog_section(c, version)]
402
403 print("Release version: {}".format(version))
404 if changed:
405 for path in changed:
406 print(" updated {}".format(rel(path)))
407 else:
408 print(" all manifests already in sync")
409 for path in stubbed:
410 print(" added '## {}' stub to {} — fill it in".format(version, rel(path)))
411
412 return 0
413 except SyncError as error:
414 print("error: {}".format(error), file=sys.stderr)
415 return 1
416
417
418 if __name__ == "__main__":
419 sys.exit(main())