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 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"
35 RN_PACKAGE_LOCK = REPO_ROOT / "sdk/react-native/package-lock.json"
36 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
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
47 # directly instead of shelling out to cargo: `cargo metadata` re-resolves over
48 # the network (~72s per manifest here, because the [patch.crates-io] sysctl git
49 # dependency is refetched on every resolve), which made the bump both slow and
50 # dependent on the network. Run `make relock` when dependencies actually change.
51 LOCKFILES = (
52 (REPO_ROOT / "Cargo.lock", ("onde",)),
53 (REPO_ROOT / "sdk/dart/rust/Cargo.lock", ("onde",)),
54 (REPO_ROOT / "sdk/react-native/rust/Cargo.lock", ("onde", "onde-react-native")),
55 )
56
57 # A freshly bumped changelog section carries this placeholder. `--check` refuses
58 # to pass while it is still there, so an unwritten changelog blocks the release
59 # rather than shipping stale notes to pub.dev and npm.
60 PLACEHOLDER = "_TODO: describe this release_"
61
62 SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
63
64
65 class SyncError(Exception):
66 """A version source could not be read or updated."""
67
68
69 def rel(path):
70 return path.relative_to(REPO_ROOT).as_posix()
71
72
73 def read_text(path):
74 try:
75 return path.read_text(encoding="utf-8")
76 except FileNotFoundError:
77 raise SyncError("missing file: {}".format(rel(path)))
78
79
80 def write_text(path, content):
81 path.write_text(content, encoding="utf-8")
82
83
84 def cargo_package_version(path):
85 """Read `version` from the [package] table, ignoring dependency versions."""
86 section = None
87 for line in read_text(path).splitlines():
88 stripped = line.strip()
89 if stripped.startswith("[") and stripped.endswith("]"):
90 section = stripped[1:-1]
91 continue
92 if section == "package":
93 match = re.match(r'version\s*=\s*"([^"]+)"', stripped)
94 if match:
95 return match.group(1)
96 raise SyncError("no [package] version in {}".format(rel(path)))
97
98
99 def set_cargo_package_version(path, version):
100 """Rewrite only the [package] table's version line."""
101 lines = read_text(path).splitlines(keepends=True)
102 section = None
103 for index, line in enumerate(lines):
104 stripped = line.strip()
105 if stripped.startswith("[") and stripped.endswith("]"):
106 section = stripped[1:-1]
107 continue
108 if section == "package" and re.match(r'version\s*=\s*"[^"]+"', stripped):
109 lines[index] = re.sub(
110 r'(version\s*=\s*")[^"]+(")',
111 r"\g<1>{}\g<2>".format(version),
112 line,
113 count=1,
114 )
115 write_text(path, "".join(lines))
116 return
117 raise SyncError("no [package] version in {}".format(rel(path)))
118
119
120 def lock_version_pattern(name):
121 """Match the version line of a [[package]] entry by package name."""
122 return re.compile(
123 r'^(name = "{}"\nversion = ")([^"]+)(")'.format(re.escape(name)), re.MULTILINE
124 )
125
126
127 def read_lock_version(path, name):
128 match = lock_version_pattern(name).search(read_text(path))
129 if match is None:
130 raise SyncError("no '{}' package entry in {}".format(name, rel(path)))
131 return match.group(2)
132
133
134 def set_lock_version(path, name, version):
135 content = read_text(path)
136 updated, count = lock_version_pattern(name).subn(
137 r"\g<1>{}\g<3>".format(version), content, count=1
138 )
139 if count == 0:
140 raise SyncError("no '{}' package entry in {}".format(name, rel(path)))
141 if updated != content:
142 write_text(path, updated)
143 return True
144 return False
145
146
147 def sub_or_raise(content, pattern, replacement, path, description):
148 updated, count = pattern.subn(replacement, content, count=1)
149 if count == 0:
150 raise SyncError(
151 "could not update {} in {} — the file layout changed, "
152 "fix the pattern in scripts/sync-release-version.py".format(
153 description, rel(path)
154 )
155 )
156 return updated
157
158
159 def update_json_versions(path, version, keys):
160 """Rewrite named version fields, preserving npm's on-disk JSON formatting."""
161 data = json.loads(read_text(path))
162 for key_path in keys:
163 node = data
164 for key in key_path[:-1]:
165 if key not in node:
166 raise SyncError(
167 "missing key {} in {}".format(
168 ".".join(str(k) for k in key_path), rel(path)
169 )
170 )
171 node = node[key]
172 node[key_path[-1]] = version
173 write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
174
175
176 def propagate(version):
177 """Write `version` into every derived manifest. Returns changed paths."""
178 before = {
179 path: read_text(path)
180 for path in (
181 DART_PUBSPEC,
182 RN_PACKAGE_JSON,
183 RN_PACKAGE_LOCK,
184 RN_RUST_CARGO,
185 KOTLIN_PROPS,
186 )
187 }
188
189 write_text(
190 DART_PUBSPEC,
191 sub_or_raise(
192 before[DART_PUBSPEC],
193 re.compile(r"^version:.*$", re.MULTILINE),
194 "version: {}".format(version),
195 DART_PUBSPEC,
196 "pubspec version",
197 ),
198 )
199
200 write_text(
201 KOTLIN_PROPS,
202 sub_or_raise(
203 before[KOTLIN_PROPS],
204 re.compile(r"^VERSION_NAME=.*$", re.MULTILINE),
205 "VERSION_NAME={}".format(version),
206 KOTLIN_PROPS,
207 "VERSION_NAME",
208 ),
209 )
210
211 set_cargo_package_version(RN_RUST_CARGO, version)
212
213 update_json_versions(RN_PACKAGE_JSON, version, [("version",)])
214 # lockfileVersion 3 records the root package version twice.
215 update_json_versions(
216 RN_PACKAGE_LOCK, version, [("version",), ("packages", "", "version")]
217 )
218
219 changed = []
220 for path, original in before.items():
221 if read_text(path) != original:
222 changed.append(path)
223
224 for lock_path, names in LOCKFILES:
225 # List, not a generator: any() short-circuits and would skip the
226 # remaining packages in a lockfile that carries more than one.
227 if any([set_lock_version(lock_path, name, version) for name in names]):
228 changed.append(lock_path)
229
230 return changed
231
232
233 def ensure_changelog_section(path, version):
234 """Prepend a stub section for `version` if the changelog lacks one."""
235 content = read_text(path)
236 if re.search(r"^##\s+{}\s*$".format(re.escape(version)), content, re.MULTILINE):
237 return False
238 write_text(path, "## {}\n\n- {}\n\n{}".format(version, PLACEHOLDER, content))
239 return True
240
241
242 def check(version):
243 """Verify every version source agrees with Cargo.toml. Returns problems."""
244 problems = []
245
246 def expect(path, actual, label):
247 if actual != version:
248 problems.append(
249 "{}: {} is {}, expected {}".format(rel(path), label, actual, version)
250 )
251
252 pubspec = re.search(r"^version:\s*(\S+)\s*$", read_text(DART_PUBSPEC), re.MULTILINE)
253 if pubspec is None:
254 problems.append("{}: no version line".format(rel(DART_PUBSPEC)))
255 else:
256 expect(DART_PUBSPEC, pubspec.group(1), "version")
257
258 props = re.search(r"^VERSION_NAME=(.*)$", read_text(KOTLIN_PROPS), re.MULTILINE)
259 if props is None:
260 problems.append("{}: no VERSION_NAME".format(rel(KOTLIN_PROPS)))
261 else:
262 expect(KOTLIN_PROPS, props.group(1).strip(), "VERSION_NAME")
263
264 expect(RN_RUST_CARGO, cargo_package_version(RN_RUST_CARGO), "[package] version")
265
266 expect(
267 RN_PACKAGE_JSON, json.loads(read_text(RN_PACKAGE_JSON)).get("version"), "version"
268 )
269
270 lock = json.loads(read_text(RN_PACKAGE_LOCK))
271 expect(RN_PACKAGE_LOCK, lock.get("version"), "version")
272 expect(
273 RN_PACKAGE_LOCK,
274 lock.get("packages", {}).get("", {}).get("version"),
275 'packages[""].version',
276 )
277
278 for lock_path, names in LOCKFILES:
279 for name in names:
280 expect(lock_path, read_lock_version(lock_path, name), "{} version".format(name))
281
282 for changelog in CHANGELOGS:
283 content = read_text(changelog)
284 section = re.search(
285 r"^##\s+{}\s*$(.*?)(?=^##\s|\Z)".format(re.escape(version)),
286 content,
287 re.MULTILINE | re.DOTALL,
288 )
289 if section is None:
290 problems.append("{}: no '## {}' section".format(rel(changelog), version))
291 elif PLACEHOLDER in section.group(1):
292 problems.append(
293 "{}: '## {}' still has the {} placeholder".format(
294 rel(changelog), version, PLACEHOLDER
295 )
296 )
297
298 return problems
299
300
301 def lint_publishable():
302 """Find git dependencies, which `cargo publish` rejects outright.
303
304 This is the 1.2.0 crates.io failure: the mistralrs deps were pointed at the
305 fork's git branch, and the release burned 13 minutes of macOS runner time to
306 surface an error the manifest alone proves in milliseconds. Cargo.toml
307 documents pointing them back at git for local fork work, so this is a
308 release gate rather than part of --check.
309
310 [patch.crates-io] is exempt: crates.io consumers ignore it entirely.
311 """
312 problems = []
313 section = None
314
315 for number, line in enumerate(read_text(CARGO_TOML).splitlines(), start=1):
316 stripped = line.strip()
317 if stripped.startswith("#"):
318 continue
319 if stripped.startswith("[") and stripped.endswith("]"):
320 section = stripped[1:-1]
321 continue
322 if section is None or section.startswith("patch."):
323 continue
324
325 is_dependency_table = (
326 section in ("dependencies", "build-dependencies", "dev-dependencies")
327 or section.startswith(
328 ("dependencies.", "build-dependencies.", "dev-dependencies.")
329 )
330 or section.endswith(".dependencies")
331 )
332 if not is_dependency_table:
333 continue
334
335 if re.search(r"\bgit\s*=", stripped):
336 problems.append(
337 "Cargo.toml:{}: git dependency in [{}] — cargo publish "
338 "rejects it: {}".format(number, section, stripped)
339 )
340
341 return problems
342
343
344 def bump(version, part):
345 major, minor, patch = (int(piece) for piece in version.split("."))
346 if part == "major":
347 return "{}.0.0".format(major + 1)
348 if part == "minor":
349 return "{}.{}.0".format(major, minor + 1)
350 return "{}.{}.{}".format(major, minor, patch + 1)
351
352
353 def main():
354 parser = argparse.ArgumentParser(description=__doc__)
355 group = parser.add_mutually_exclusive_group()
356 group.add_argument("--bump", choices=("major", "minor", "patch"))
357 group.add_argument("--set", dest="explicit", metavar="X.Y.Z")
358 group.add_argument("--check", action="store_true")
359 group.add_argument("--print", dest="print_only", action="store_true")
360 group.add_argument("--lint-publish", dest="lint_publish", action="store_true")
361 args = parser.parse_args()
362
363 try:
364 current = cargo_package_version(CARGO_TOML)
365
366 if args.print_only:
367 print(current)
368 return 0
369
370 if args.lint_publish:
371 problems = lint_publishable()
372 if problems:
373 print("Cargo.toml is not publishable to crates.io:")
374 for problem in problems:
375 print(" - {}".format(problem))
376 return 1
377 print("Cargo.toml is publishable: no git dependencies.")
378 return 0
379
380 if args.check:
381 problems = check(current)
382 if problems:
383 print("Version sources disagree with Cargo.toml ({}):".format(current))
384 for problem in problems:
385 print(" - {}".format(problem))
386 return 1
387 print("All version sources agree: {}".format(current))
388 return 0
389
390 version = current
391 if args.explicit:
392 if not SEMVER.match(args.explicit):
393 raise SyncError(
394 "--set expects bare semver X.Y.Z, got '{}'".format(args.explicit)
395 )
396 version = args.explicit
397 elif args.bump:
398 version = bump(current, args.bump)
399
400 if version != current:
401 set_cargo_package_version(CARGO_TOML, version)
402 print("Cargo.toml: {} -> {}".format(current, version))
403
404 changed = propagate(version)
405 stubbed = [c for c in CHANGELOGS if ensure_changelog_section(c, version)]
406
407 print("Release version: {}".format(version))
408 if changed:
409 for path in changed:
410 print(" updated {}".format(rel(path)))
411 else:
412 print(" all manifests already in sync")
413 for path in stubbed:
414 print(" added '## {}' stub to {} — fill it in".format(version, rel(path)))
415
416 return 0
417 except SyncError as error:
418 print("error: {}".format(error), file=sys.stderr)
419 return 1
420
421
422 if __name__ == "__main__":
423 sys.exit(main())