Add tool to automate bazel/robolectric.bzl updates (#143)
* Add tool to automate bazel/robolectric.bzl updates
* Formatting
* Add tests
* Forgot
* Formatting
diff --git a/BUILD.bazel b/BUILD.bazel
new file mode 100644
index 0000000..a20e6de
--- /dev/null
+++ b/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@rules_python//python:py_binary.bzl", "py_binary")
+load("@rules_python//python:py_library.bzl", "py_library")
+
+py_library(
+ name = "update_versions_lib",
+ srcs = ["update_versions.py"],
+ visibility = ["//tests:__pkg__"],
+)
+
+py_binary(
+ name = "update-versions",
+ srcs = ["update_versions.py"],
+ main = "update_versions.py",
+)
diff --git a/README.md b/README.md
index 6be7378..15107b2 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,18 @@
)
```
+## Updating Android Versions
+
+`update-versions.py` walks `org.robolectric:android-all-instrumented` on Maven
+Central and rewrites `bazel/robolectric.bzl` in place: it bumps the version and
+sha256 of any Android API group that has a newer release, and inserts new
+entries for any numeric API newer than the highest one currently listed.
+
+```console
+./update-versions.py # apply updates
+./update-versions.py --dry-run # preview without writing
+```
+
## Publishing Releases
A new release can be published by just pushing a tag.
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index d0507af..c543ed0 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -1,4 +1,5 @@
load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("@rules_python//python:py_test.bzl", "py_test")
build_test(
name = "test",
@@ -9,3 +10,9 @@
"//bazel:gen-deps",
],
)
+
+py_test(
+ name = "update_versions_test",
+ srcs = ["update_versions_test.py"],
+ deps = ["//:update_versions_lib"],
+)
diff --git a/tests/update_versions_test.py b/tests/update_versions_test.py
new file mode 100644
index 0000000..eab5588
--- /dev/null
+++ b/tests/update_versions_test.py
@@ -0,0 +1,242 @@
+"""Tests for update-versions.py."""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import update_versions
+
+
+class ParseTest(unittest.TestCase):
+ def test_modern_build_number(self):
+ self.assertEqual(
+ update_versions.parse("16-robolectric-13921718-i7"),
+ ("16", (7, (1, 13921718), 0)),
+ )
+
+ def test_rev_suffix(self):
+ self.assertEqual(
+ update_versions.parse("9-robolectric-4913185-2-i7"),
+ ("9", (7, (1, 4913185), 2)),
+ )
+
+ def test_legacy_r_build(self):
+ self.assertEqual(
+ update_versions.parse("8.0.0_r4-robolectric-r1-i7"),
+ ("8.0.0_r4", (7, (0, 1), 0)),
+ )
+
+ def test_decimal_api(self):
+ api, _ = update_versions.parse("12.1-robolectric-8229987-i7")
+ self.assertEqual(api, "12.1")
+
+ def test_malformed_returns_none(self):
+ self.assertIsNone(update_versions.parse("not-a-version"))
+ self.assertIsNone(update_versions.parse("16-foo-1-i1"))
+ self.assertIsNone(update_versions.parse("16-robolectric-123"))
+
+
+class NumericApiKeyTest(unittest.TestCase):
+ def test_integer(self):
+ self.assertEqual(update_versions.numeric_api_key("16"), (16,))
+
+ def test_decimal(self):
+ self.assertEqual(update_versions.numeric_api_key("12.1"), (12, 1))
+
+ def test_three_part(self):
+ self.assertEqual(update_versions.numeric_api_key("8.1.0"), (8, 1, 0))
+
+ def test_legacy_r_suffixed(self):
+ self.assertIsNone(update_versions.numeric_api_key("8.0.0_r4"))
+ self.assertIsNone(update_versions.numeric_api_key("5.0.2_r3"))
+ self.assertIsNone(update_versions.numeric_api_key("7.1.0_r7"))
+
+ def test_ordering(self):
+ labels = ["17", "16", "12.1", "12", "8.1.0", "8"]
+ keys = [update_versions.numeric_api_key(a) for a in labels]
+ self.assertEqual(keys, sorted(keys, reverse=True))
+
+
+class LatestPerApiTest(unittest.TestCase):
+ def test_picks_highest_instrumentation(self):
+ versions = [
+ "16-robolectric-13921718-i5",
+ "16-robolectric-13921718-i7",
+ "16-robolectric-13921718-i6",
+ ]
+ self.assertEqual(
+ update_versions.latest_per_api(versions),
+ {"16": "16-robolectric-13921718-i7"},
+ )
+
+ def test_picks_highest_build_within_same_instrumentation(self):
+ versions = [
+ "15-robolectric-12543294-i7",
+ "15-robolectric-13954326-i7",
+ "15-robolectric-12714715-i7",
+ ]
+ self.assertEqual(
+ update_versions.latest_per_api(versions),
+ {"15": "15-robolectric-13954326-i7"},
+ )
+
+ def test_groups_by_api(self):
+ versions = [
+ "16-robolectric-100-i7",
+ "15-robolectric-200-i7",
+ "16-robolectric-300-i7",
+ ]
+ self.assertEqual(
+ update_versions.latest_per_api(versions),
+ {
+ "16": "16-robolectric-300-i7",
+ "15": "15-robolectric-200-i7",
+ },
+ )
+
+ def test_ignores_unparseable(self):
+ self.assertEqual(update_versions.latest_per_api(["nope"]), {})
+
+
+_BZL_FIXTURE = """\
+DEFAULT_AVAILABLE_VERSIONS = [
+ robolectric_version(
+ version = "15-robolectric-12543294-i7",
+ sha256 = "deadbeef00000000000000000000000000000000000000000000000000000000",
+ ),
+ robolectric_version(
+ version = "5.0.2_r3-robolectric-r0-i7",
+ sha256 = "1111111111111111111111111111111111111111111111111111111111111111",
+ ),
+]
+"""
+
+_METADATA_FIXTURE = b"""<?xml version="1.0" encoding="UTF-8"?>
+<metadata>
+ <versioning>
+ <versions>
+ <version>15-robolectric-12543294-i7</version>
+ <version>15-robolectric-13954326-i7</version>
+ <version>16-robolectric-13921718-i7</version>
+ <version>5.0.2_r3-robolectric-r0-i7</version>
+ <version>4.4_r1-robolectric-r2-i7</version>
+ </versions>
+ </versioning>
+</metadata>
+"""
+
+_FAKE_SHAS = {
+ "15-robolectric-13954326-i7": "a" * 64,
+ "16-robolectric-13921718-i7": "b" * 64,
+}
+
+
+def _fake_fetch(url: str) -> bytes:
+ if url == update_versions.METADATA_URL:
+ return _METADATA_FIXTURE
+ for version, sha in _FAKE_SHAS.items():
+ suffix = f"/{version}/android-all-instrumented-{version}.jar.sha256"
+ if url.endswith(suffix):
+ return f"{sha} android-all-instrumented-{version}.jar\n".encode("ascii")
+ raise AssertionError(f"Unexpected fetch URL: {url}")
+
+
+class EndToEndTest(unittest.TestCase):
+ def _run_main(self, bzl_text: str) -> tuple[int, str]:
+ with tempfile.NamedTemporaryFile("w", suffix=".bzl", delete=False) as f:
+ f.write(bzl_text)
+ bzl_path = Path(f.name)
+ try:
+ with (
+ mock.patch.object(update_versions, "fetch", side_effect=_fake_fetch),
+ mock.patch.object(
+ sys, "argv", ["update-versions.py", "--bzl", str(bzl_path)]
+ ),
+ ):
+ rc = update_versions.main()
+ return rc, bzl_path.read_text()
+ finally:
+ bzl_path.unlink()
+
+ def test_updates_existing_and_inserts_new(self):
+ rc, result = self._run_main(_BZL_FIXTURE)
+ self.assertEqual(rc, 0)
+
+ # API 16 inserted at the top with the correct sha and indentation.
+ self.assertIn(
+ " robolectric_version(\n"
+ ' version = "16-robolectric-13921718-i7",\n'
+ f' sha256 = "{"b" * 64}",\n'
+ " ),\n",
+ result,
+ )
+ # API 15 rewritten in place with the new build + sha.
+ self.assertIn(
+ " robolectric_version(\n"
+ ' version = "15-robolectric-13954326-i7",\n'
+ f' sha256 = "{"a" * 64}",\n'
+ " ),\n",
+ result,
+ )
+ # Legacy 5.0.2_r3 untouched.
+ self.assertIn(
+ " robolectric_version(\n"
+ ' version = "5.0.2_r3-robolectric-r0-i7",\n'
+ ' sha256 = "1111111111111111111111111111111111111111111111111111111111111111",\n'
+ " ),\n",
+ result,
+ )
+ # Stale 15 build is gone.
+ self.assertNotIn("12543294", result)
+ # 4.4_r1 was on Maven but legacy-r-suffixed, so it must NOT be added.
+ self.assertNotIn("4.4_r1", result)
+ # New entry comes before the existing first entry.
+ self.assertLess(result.index("16-robolectric"), result.index("15-robolectric"))
+
+ def test_no_op_when_already_current(self):
+ already_current = (
+ "DEFAULT_AVAILABLE_VERSIONS = [\n"
+ " robolectric_version(\n"
+ ' version = "16-robolectric-13921718-i7",\n'
+ f' sha256 = "{"b" * 64}",\n'
+ " ),\n"
+ " robolectric_version(\n"
+ ' version = "15-robolectric-13954326-i7",\n'
+ f' sha256 = "{"a" * 64}",\n'
+ " ),\n"
+ " robolectric_version(\n"
+ ' version = "5.0.2_r3-robolectric-r0-i7",\n'
+ ' sha256 = "1111111111111111111111111111111111111111111111111111111111111111",\n'
+ " ),\n"
+ "]\n"
+ )
+ rc, result = self._run_main(already_current)
+ self.assertEqual(rc, 0)
+ self.assertEqual(result, already_current)
+
+ def test_dry_run_does_not_write(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".bzl", delete=False) as f:
+ f.write(_BZL_FIXTURE)
+ bzl_path = Path(f.name)
+ try:
+ with (
+ mock.patch.object(update_versions, "fetch", side_effect=_fake_fetch),
+ mock.patch.object(
+ sys,
+ "argv",
+ ["update-versions.py", "--bzl", str(bzl_path), "--dry-run"],
+ ),
+ ):
+ rc = update_versions.main()
+ self.assertEqual(rc, 0)
+ self.assertEqual(bzl_path.read_text(), _BZL_FIXTURE)
+ finally:
+ bzl_path.unlink()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/update_versions.py b/update_versions.py
new file mode 100755
index 0000000..894c598
--- /dev/null
+++ b/update_versions.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+"""Update DEFAULT_AVAILABLE_VERSIONS in bazel/robolectric.bzl.
+
+Walks the Maven Central artifact
+ org.robolectric:android-all-instrumented
+and updates bazel/robolectric.bzl with both:
+
+ 1. New releases of Android APIs already in the list — the version and sha256
+ are rewritten in place (highest instrumentation version, then highest
+ robolectric build number).
+ 2. Entirely new Android APIs (numeric ones newer than the highest currently
+ in the list) — a new robolectric_version(...) entry is inserted at the
+ top with its sha256.
+
+Legacy r-suffixed APIs (e.g. 4.4_r1) that are not already present are never
+auto-added; removal is treated as intentional.
+
+Usage:
+ bazel run //:update-versions -- [--bzl PATH] [--dry-run]
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+import urllib.request
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+MAVEN_BASE = "https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented"
+METADATA_URL = f"{MAVEN_BASE}/maven-metadata.xml"
+
+# Matches a single robolectric_version(...) block, capturing the version and sha256.
+ENTRY_RE = re.compile(
+ r'(?P<head>robolectric_version\(\s*\n\s*version\s*=\s*")'
+ r'(?P<version>[^"]+)'
+ r'(?P<mid>"\s*,\s*\n\s*sha256\s*=\s*")'
+ r"(?P<sha>[0-9a-fA-F]+)"
+ r'(?P<tail>")'
+)
+
+# Parses e.g. "16-robolectric-13921718-i7" or "9-robolectric-4913185-2-i7".
+VERSION_RE = re.compile(
+ r"^(?P<api>.+?)-robolectric-(?P<build>[^-]+)(?:-(?P<rev>\d+))?-i(?P<inst>\d+)$"
+)
+
+
+def fetch(url: str) -> bytes:
+ req = urllib.request.Request(url, headers={"User-Agent": "robolectric-bzl-updater"})
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return resp.read()
+
+
+def all_versions() -> list[str]:
+ tree = ET.fromstring(fetch(METADATA_URL))
+ return [v.text for v in tree.findall("./versioning/versions/version") if v.text]
+
+
+def parse(version: str):
+ m = VERSION_RE.match(version)
+ if not m:
+ return None
+ api = m.group("api")
+ build = m.group("build")
+ rev = int(m.group("rev")) if m.group("rev") else 0
+ inst = int(m.group("inst"))
+ # Build can be a CI number like "13921718" or an "rN" tag for older releases.
+ if build.isdigit():
+ build_key = (1, int(build))
+ elif build.startswith("r") and build[1:].isdigit():
+ build_key = (0, int(build[1:]))
+ else:
+ build_key = (-1, 0)
+ return api, (inst, build_key, rev)
+
+
+def latest_per_api(versions: list[str]) -> dict[str, str]:
+ groups: dict[str, list[tuple]] = {}
+ for v in versions:
+ p = parse(v)
+ if p is None:
+ continue
+ api, key = p
+ groups.setdefault(api, []).append((key, v))
+ return {api: max(items)[1] for api, items in groups.items()}
+
+
+def numeric_api_key(api: str) -> tuple[int, ...] | None:
+ """Parse a purely-numeric Android API string into a sortable tuple.
+
+ Returns None for legacy r-suffixed APIs like "8.0.0_r4" so they're not
+ auto-added when missing.
+ """
+ if re.fullmatch(r"\d+(?:\.\d+)*", api):
+ return tuple(int(p) for p in api.split("."))
+ return None
+
+
+def sha256_for(version: str) -> str:
+ url = f"{MAVEN_BASE}/{version}/android-all-instrumented-{version}.jar.sha256"
+ return fetch(url).decode("ascii").strip().split()[0].lower()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Update DEFAULT_AVAILABLE_VERSIONS in robolectric.bzl."
+ )
+ default_bzl = Path(__file__).resolve().parent / "bazel" / "robolectric.bzl"
+ parser.add_argument(
+ "--bzl",
+ type=Path,
+ default=default_bzl,
+ help=f"path to robolectric.bzl (default: {default_bzl})",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="print planned changes without writing the file",
+ )
+ args = parser.parse_args()
+
+ text = args.bzl.read_text()
+ entries = list(ENTRY_RE.finditer(text))
+ if not entries:
+ print(f"No robolectric_version() entries found in {args.bzl}", file=sys.stderr)
+ return 1
+
+ print(f"Fetching {METADATA_URL}", file=sys.stderr)
+ latest = latest_per_api(all_versions())
+
+ # Plan in-place updates for entries already in the bzl. Building against
+ # the original text, then applying in reverse, keeps earlier spans valid.
+ updates = []
+ existing_apis: set[str] = set()
+ for m in entries:
+ old_version = m.group("version")
+ parsed = parse(old_version)
+ if parsed is None:
+ print(f"[skip] cannot parse {old_version!r}", file=sys.stderr)
+ continue
+ api = parsed[0]
+ existing_apis.add(api)
+ new_version = latest.get(api)
+ if new_version is None:
+ print(f"[skip] no remote versions for API {api}", file=sys.stderr)
+ continue
+ if new_version == old_version:
+ print(f"[ ok ] {api}: {old_version}", file=sys.stderr)
+ continue
+ print(f"[ up ] {api}: {old_version} -> {new_version}", file=sys.stderr)
+ updates.append((m, new_version, sha256_for(new_version)))
+
+ # Find numeric APIs on Maven Central that are newer than the highest
+ # numeric API already present, and queue them as new entries.
+ max_existing = max(
+ (k for k in (numeric_api_key(a) for a in existing_apis) if k is not None),
+ default=(),
+ )
+ additions = []
+ for api, version in latest.items():
+ if api in existing_apis:
+ continue
+ key = numeric_api_key(api)
+ if key is None or key <= max_existing:
+ continue
+ additions.append((key, api, version))
+ additions.sort(reverse=True)
+ additions = [(api, version, sha256_for(version)) for _, api, version in additions]
+ for api, version, _ in additions:
+ print(f"[ new] {api}: {version}", file=sys.stderr)
+
+ if not updates and not additions:
+ print("No updates needed.", file=sys.stderr)
+ return 0
+
+ new_text = text
+ for m, new_version, new_sha in reversed(updates):
+ replacement = (
+ f"{m.group('head')}{new_version}{m.group('mid')}{new_sha}{m.group('tail')}"
+ )
+ new_text = new_text[: m.start()] + replacement + new_text[m.end() :]
+
+ if additions:
+ insertion = "".join(
+ f"robolectric_version(\n"
+ f' version = "{v}",\n'
+ f' sha256 = "{s}",\n'
+ f" ),\n"
+ f" "
+ for _, v, s in additions
+ )
+ insert_at = entries[0].start()
+ new_text = new_text[:insert_at] + insertion + new_text[insert_at:]
+
+ total = len(updates) + len(additions)
+ if args.dry_run:
+ print(f"\n[dry-run] {total} change(s) not written.", file=sys.stderr)
+ return 0
+
+ args.bzl.write_text(new_text)
+ print(f"Wrote {total} change(s) to {args.bzl}", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())