#!/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 (including the "Superseded" sections, which change the original rule) for the full reasoning; this is only the arithmetic and the index read. candidate = whole minutes since 2026-01-01T00:00:00Z, of the COMMITTER date of the commit being built candidate = min(candidate, minutes(now) + TOLERANCE) -- forwards published = highest versionCode already in the index for this app id result = max(candidate, published + 1) -- backwards 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. Granularity is not what keeps two builds of the same commit from colliding any more -- that is now a concurrency-group-plus-refuse-on-existing problem, handled elsewhere (see the README) -- so minutes stand purely as a wide, cheap headroom choice. The candidate is derived from the COMMIT being built, not the build clock, so the result is a function of the commit rather than of when a runner happened to pick the job up: rebuilding the identical commit twice (e.g. a retry) yields the identical number. That determinism is what makes the two clamps below necessary, one per direction: BACKWARDS -- rebase, amend and cherry-pick can all produce a commit that is older than one already published, and it must not be allowed to re-emit a number a device has already seen a higher one for. This is why the COMMITTER date is used and not the author date: author date is preserved across a rebase, so a rebased branch would keep replaying its original (now stale) timestamp and defeat this exact clamp. Committer date changes every time the commit is rewritten, which is what makes "published + 1" actually catch it. FORWARDS -- a commit's timestamp is attacker- and typo-controlled (it is just a field in a commit object anyone with push access can set), and the version code this produces only ever goes up, so a bad one can never be walked back down. A commit dated a century in the future would burn decades of headroom permanently; worse, a value placed just *below* the int32 ceiling would still be accepted by Android, would still publish, and then NO LATER BUILD COULD EVER EXCEED IT -- the application becomes permanently un-updatable, recoverable only by shipping a new applicationId and asking every user to reinstall by hand. Do not remove this clamp to "simplify" the arithmetic; it is the one guarding against unrecoverable, not merely annoying, breakage. TOLERANCE exists only to absorb honest clock skew between the committer's machine and the runner that builds it -- a few hours is generous for that, a full day would still be harmless, since its only job is keeping the number near the present, not pinning it exactly. A future-dated commit is therefore CLAMPED AND A WARNING IS PRINTED, never rejected outright: rejecting would block a legitimate build over a few minutes of innocent skew, and the clamp already removes the actual harm (a permanently reserved version code range), so there is nothing left for a hard failure to buy. 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 (clamped) 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) # Clock skew between the committer's machine and the runner, absorbed # without complaint. Not tight by design -- see the FORWARDS section of the # module docstring for why a generous value here is fine: this only has to # keep a well-formed commit's number near the present, not pin it exactly, # and everything past it is clamped (and warned about) regardless of size. TOLERANCE_MINUTES = 6 * 60 # 6 hours def minutes_since_epoch(dt): return int((dt - 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 clamped_candidate(committer_time, now, tolerance_minutes=TOLERANCE_MINUTES): """The forwards clamp: minutes-since-epoch of the commit's committer date, capped at minutes-since-epoch of "now" plus TOLERANCE. Warns on stderr (never fails) when the cap actually bites, since that is either a broken clock or someone probing, and both are worth seeing in the log -- see the FORWARDS section of the module docstring for why this is a clamp-and-warn rather than a rejection.""" candidate = minutes_since_epoch(committer_time) ceiling = minutes_since_epoch(now) + tolerance_minutes if candidate > ceiling: print( f"next_version_code.py: warning: committer date " f"{committer_time.isoformat()} is {candidate - ceiling} minute(s) " f"beyond now+tolerance; clamping the version code candidate to " f"{ceiling} instead of {candidate}", file=sys.stderr, ) return ceiling return candidate def next_version_code(source, application_id, committer_time, now=None): now = now if now is not None else datetime.datetime.now(datetime.timezone.utc) index = load_index(source) candidate = clamped_candidate(committer_time, now) published = highest_published_version_code(index, application_id) if published is None: return candidate return max(candidate, published + 1) def _parse_committer_timestamp(value): # Unix seconds, exactly what `git log -1 --format=%ct` prints -- the # caller already has the commit, so this script takes it as an argument # rather than shelling out to git itself (fdroid-repo-ci#6: keeps this # script pure and testable). try: seconds = int(value) except ValueError: raise argparse.ArgumentTypeError( f"expected Unix seconds (e.g. `git log -1 --format=%ct`), got {value!r}" ) return datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc) 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") parser.add_argument( "committer_timestamp", type=_parse_committer_timestamp, help="committer date of the commit being built, as Unix seconds " "(`git log -1 --format=%%ct`)", ) args = parser.parse_args(argv) # Testing seam only, deliberately undocumented in the usage banner: lets # the offline suite pin "now" -- the runner's clock, used only for the # forwards clamp -- to a fixed, known value 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, args.committer_timestamp, 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:]))