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>
This commit is contained in:
+115
-25
@@ -1,19 +1,58 @@
|
||||
#!/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.
|
||||
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
|
||||
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)
|
||||
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. 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.
|
||||
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
|
||||
@@ -23,14 +62,14 @@ 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.
|
||||
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).
|
||||
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
|
||||
@@ -41,10 +80,16 @@ 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(now=None):
|
||||
now = now if now is not None else datetime.datetime.now(datetime.timezone.utc)
|
||||
return int((now - EPOCH).total_seconds() // 60)
|
||||
|
||||
def minutes_since_epoch(dt):
|
||||
return int((dt - EPOCH).total_seconds() // 60)
|
||||
|
||||
|
||||
def load_index(source, timeout=30):
|
||||
@@ -76,27 +121,70 @@ def highest_published_version_code(index, application_id):
|
||||
return max(codes) if codes else None
|
||||
|
||||
|
||||
def next_version_code(source, application_id, now=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 = minutes_since_epoch(now)
|
||||
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" 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.
|
||||
# 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:
|
||||
@@ -105,7 +193,9 @@ def main(argv):
|
||||
now = now.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
try:
|
||||
result = next_version_code(args.index_source, args.application_id, now=now)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user