Files
fdroid-repo-ci/next_version_code.py
claudeandbit e5d1f9a5bf next-version-code.sh: derive the candidate from the commit, not the build clock
fdroid-repo-ci#6's "Superseded" sections change the rule: the candidate is
now the committer date of the commit being built, clamped on both sides,
rather than whatever minute the runner happened to pick the job up in.

Backwards: rebase, amend and cherry-pick can produce a commit older than
one already published, so the clamp keys off the committer date, not the
author date -- author date survives a rebase unchanged and would replay a
stale, already-superseded number.

Forwards: a commit's timestamp is attacker- and typo-controlled and the
number only ever rises, so a bad one can never be walked back. A value
placed just below the int32 ceiling would still publish and then no later
build could ever exceed it -- the app becomes permanently un-updatable.
TOLERANCE absorbs honest clock skew and the clamp warns loudly rather than
rejecting, since rejecting would block a legitimate build over a few
minutes of skew while buying nothing the clamp doesn't already cover.

next_version_code.py now takes the committer timestamp as a third
argument (Unix seconds, what `git log -1 --format=%ct` prints) instead of
reading the system clock, keeping it pure and testable without a git
checkout. next-version-code.sh, README.md and action.yml updated to match.

Granularity stays minutes: concurrency is handled separately (a
workflow-level concurrency group, plus publish.sh's refuse-on-existing),
so it is now a pure headroom choice.

Tests (test_next_version_code.py, test_next_version_code.sh): a normal
commit yields its own minute, a commit older than the published maximum
still yields published+1, a future-dated commit within tolerance passes
through untouched while one beyond it is clamped and warns, a commit
dated near the int32 ceiling does not produce a near-int32 result, and a
dedicated property test checks the result always strictly exceeds every
versionCode in the fixture across all of the above. Each was verified by
injecting the corresponding fault (removing the backwards clamp, removing
the forwards clamp, and ignoring the commit's date entirely) and
confirming the relevant test failed, then reverting.

98 assertions offline (0 failed, 4 skipped -- pre-existing, live-bucket
only), up from 91.

Co-authored-by: bit <bit@das-labor.org>
2026-09-09 09:46:00 +00:00

215 lines
9.6 KiB
Python
Executable File

#!/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.<applicationId>.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:]))