#!/usr/bin/env python3 """Compute the next monotonic Android versionCode for an application id. Implements the rule from fdroid-repo-ci#6 -- read that issue for the reasoning behind each part; this is only the arithmetic and the index read. candidate = whole minutes since 2026-01-01T00:00:00Z published = highest versionCode already in the index for this app id result = max(candidate, published + 1) Minutes, not seconds or raw Unix time: versionCode is a signed 32-bit int, and minutes-since-2026 does not exhaust it for 4,082 years. The clamp against `published` is not optional -- two builds inside the same minute would otherwise compute the same candidate, and it also covers a clock stepping backwards or an old commit being rebuilt, both of which would otherwise emit a number lower than one a device has already seen. Reads `packages..versions.*.manifest.versionCode`, the shape fdroidserver's own index.py actually writes (`packages` keyed by applicationId/packageName; `versions` a dict keyed by each APK's sha256, not a list; the versionCode nested under that version's `manifest`) -- verified against fdroidserver's source, not assumed from an older v1-shaped example. An application id absent from the index entirely is not an error: it is the first-ever publish for a channel, and "published" is then simply nobody, so the bare candidate is correct. An unreadable or unparseable index IS an error -- guessing a number when the published state is unknown is exactly how a duplicate or a decrease gets published. Standard library only, deliberately -- no third-party dependency for a script that reads a signed index. `urllib.request` handles the http(s) case; a local path is read directly, which is what makes the offline test suite possible (fixture index-v2.json files, no server, no network). """ import argparse import datetime import json import os import sys import urllib.request EPOCH = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) def minutes_since_epoch(now=None): now = now if now is not None else datetime.datetime.now(datetime.timezone.utc) return int((now - EPOCH).total_seconds() // 60) def load_index(source, timeout=30): """source is an http(s) URL or a local file path to index-v2.json.""" if source.startswith("http://") or source.startswith("https://"): with urllib.request.urlopen(source, timeout=timeout) as resp: data = resp.read() else: with open(source, "rb") as f: data = f.read() return json.loads(data) def highest_published_version_code(index, application_id): """The highest versionCode already published for application_id, or None if the index has no entry for it at all (a normal state: the first publish for this channel).""" packages = index.get("packages") or {} package = packages.get(application_id) if not package: return None versions = package.get("versions") or {} codes = [ manifest["versionCode"] for version in versions.values() if isinstance((manifest := (version or {}).get("manifest")), dict) and isinstance(manifest.get("versionCode"), int) ] return max(codes) if codes else None def next_version_code(source, application_id, now=None): index = load_index(source) candidate = minutes_since_epoch(now) published = highest_published_version_code(index, application_id) if published is None: return candidate return max(candidate, published + 1) def main(argv): parser = argparse.ArgumentParser( description="Print the next monotonic versionCode for an application id." ) parser.add_argument("index_source", help="index-v2.json URL or local path") parser.add_argument("application_id") args = parser.parse_args(argv) # Testing seam only, deliberately undocumented in the usage banner: lets # the offline suite pin "now" so `candidate` is a fixed, known number # instead of whatever minute the test happens to run in. Unset in every # real invocation, so production behaviour is untouched. now = None now_override = os.environ.get("NEXT_VERSION_CODE_NOW") if now_override: now = datetime.datetime.fromisoformat(now_override) if now.tzinfo is None: now = now.replace(tzinfo=datetime.timezone.utc) try: result = next_version_code(args.index_source, args.application_id, now=now) except (OSError, ValueError) as e: # OSError covers both a local file that can't be read and a network # failure (urllib.error.URLError is a subclass); ValueError covers # malformed JSON. Fail loudly on stderr, print nothing on stdout -- # a caller doing `versionCode=$(next-version-code.sh ...)` must never # capture a number that was actually an error message. print(f"next-version-code.py: could not read the index at " f"{args.index_source!r}: {e}", file=sys.stderr) return 1 print(result) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))