Files
fdroid-repo-ci/test_next_version_code.py
T
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

273 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline tests for next_version_code.py (fdroid-repo-ci#6, including its
"Superseded" sections: the candidate is derived from the COMMITTER date of
the commit being built, clamped on both sides, not from the build clock).
Standard library only, no network, no nix -- like test_check_zip_alignment.py,
this is the piece of the change that needs none of the pinned toolchain.
Runs next_version_code.py as a *subprocess* against fixture index-v2.json
files written to a temp dir, the same reasoning test_check_zip_alignment.py
gives: a test that only imports the function would miss an argv/exit-code/
stdout-pollution regression, and "prints the result; do nothing else" is
itself part of the contract (README/issue #6: a caller does
`versionCode=$(next-version-code.sh ...)`, so stray stdout output would be
silently captured as part of the number).
Two clocks are pinned independently, deliberately kept distinct so a test
can move either one without the other:
* the COMMIT's committer date -- the third CLI argument, exactly as a real
caller passes `git log -1 --format=%ct`;
* "now" -- the runner's clock, used only for the forwards clamp, pinned via
the NEXT_VERSION_CODE_NOW testing seam (see the script's main()).
The property that actually matters -- and the one a future refactor could
quietly break while still "looking" correct -- is that the result is
*strictly greater than every versionCode already in the fixture*, not
merely equal to some formula. Every clamp case below asserts that directly,
not just the arithmetic, and test_property_result_exceeds_every_published_code
checks it again across every scenario in one place.
"""
import datetime
import json
import os
import subprocess
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "next_version_code.py")
APP_ID = "de.example.app"
EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
TOLERANCE_MINUTES = 6 * 60 # must track next_version_code.py's TOLERANCE_MINUTES
INT32_MAX = 2**31 - 1
# "Now" is pinned at minute 100 after the epoch throughout, unless a test
# overrides it -- an arbitrary, fixed instant so expected candidates are
# known integers instead of whatever minute the suite happens to run in.
NOW_MINUTES = 100
NOW_ISO = (EPOCH + datetime.timedelta(minutes=NOW_MINUTES)).isoformat()
def minutes_to_unix_seconds(minutes):
"""Convert "minutes since the 2026 epoch" (what the script's candidate
is expressed in) to Unix seconds (what its CLI argument, and real
`git log -1 --format=%ct`, actually take)."""
return int((EPOCH + datetime.timedelta(minutes=minutes)).timestamp())
def index_with_versions(application_id, version_codes):
"""Build an index-v2.json fixture with one package with each versionCode
in a separate version entry, matching fdroidserver's own shape:
packages.<id>.versions.<sha256>.manifest.versionCode."""
versions = {}
for i, vc in enumerate(version_codes):
versions[f"fakehash{i}"] = {"manifest": {"versionCode": vc}}
return {"packages": {application_id: {"versions": versions}}}
class NextVersionCodeTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def _write_index(self, name, obj_or_text):
path = os.path.join(self.tmp.name, name)
with open(path, "w") as f:
if isinstance(obj_or_text, str):
f.write(obj_or_text)
else:
json.dump(obj_or_text, f)
return path
def _run(self, index_path, committer_minutes, application_id=APP_ID,
now_iso=NOW_ISO):
env = dict(os.environ)
if now_iso is not None:
env["NEXT_VERSION_CODE_NOW"] = now_iso
else:
env.pop("NEXT_VERSION_CODE_NOW", None)
committer_ts = str(minutes_to_unix_seconds(committer_minutes))
return subprocess.run(
[sys.executable, SCRIPT, index_path, application_id, committer_ts],
capture_output=True, text=True, env=env,
)
# -- a normal commit yields its own minute -----------------------------
def test_normal_commit_yields_its_own_minute(self):
# Committer date deliberately different from "now" (250 vs 100), and
# well inside tolerance, to prove the result tracks the COMMIT, not
# the clock the script happens to run under.
path = self._write_index("empty.json", {"packages": {}})
result = self._run(path, committer_minutes=250)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "250\n")
self.assertEqual(result.stderr, "") # no warning: well within tolerance
def test_application_id_missing_entirely_is_not_an_error(self):
path = self._write_index(
"other-app.json", index_with_versions("some.other.app", [5]))
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "100\n")
# -- backwards: a commit older than published still exceeds it ---------
def test_commit_older_than_published_max_still_yields_published_plus_one(self):
# The rebase/amend/cherry-pick case: committer date (10) is far
# older than what is already published (500). Fault this catches if
# removed: the script would print 10, a number every device that
# saw 500 has already rejected as a downgrade.
path = self._write_index("higher.json", index_with_versions(APP_ID, [500]))
result = self._run(path, committer_minutes=10)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertEqual(got, 501)
self.assertGreater(got, 500)
def test_equal_prior_is_bumped_by_one(self):
# committer minute == published: the same-minute collision case.
# The clamp must still move strictly past it.
path = self._write_index("equal.json", index_with_versions(APP_ID, [100]))
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertEqual(got, 101)
def test_highest_of_several_versions_is_the_one_that_matters(self):
path = self._write_index(
"several.json", index_with_versions(APP_ID, [10, 999, 50]))
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertGreater(got, 999)
def test_version_entry_without_a_versionCode_is_ignored_not_fatal(self):
# A version whose manifest lacks versionCode (or has no manifest at
# all) must not crash the read -- it is simply not a candidate for
# "published".
obj = index_with_versions(APP_ID, [7])
obj["packages"][APP_ID]["versions"]["nomanifest"] = {}
obj["packages"][APP_ID]["versions"]["emptymanifest"] = {"manifest": {}}
path = self._write_index("partial.json", obj)
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertGreater(got, 7)
# -- forwards: a future-dated commit is clamped, and warns -------------
def test_future_dated_commit_within_tolerance_is_not_clamped(self):
# Negative control: honest clock skew, well inside TOLERANCE, must
# pass through untouched and silently -- proves the forwards clamp
# is not simply "always clamp to now".
just_ahead = NOW_MINUTES + TOLERANCE_MINUTES - 1
path = self._write_index("empty.json", {"packages": {}})
result = self._run(path, committer_minutes=just_ahead)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, f"{just_ahead}\n")
self.assertEqual(result.stderr, "")
def test_future_dated_commit_beyond_tolerance_is_clamped_and_warns(self):
# A commit dated far ahead of "now" (attacker- or typo-controlled,
# per the module docstring) must be clamped to roughly now, not
# trusted outright -- and the clamp must be visible, not silent.
far_ahead = NOW_MINUTES + TOLERANCE_MINUTES + 100_000
path = self._write_index("empty.json", {"packages": {}})
result = self._run(path, committer_minutes=far_ahead)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
expected_ceiling = NOW_MINUTES + TOLERANCE_MINUTES
self.assertEqual(got, expected_ceiling)
self.assertLess(got, far_ahead) # actually clamped, not passed through
self.assertIn("warning", result.stderr.lower())
self.assertIn("clamping", result.stderr.lower())
def test_commit_dated_near_int32_does_not_produce_a_near_int32_result(self):
# The unrecoverable failure mode from issue #6: a commit dated to
# land just below the int32 ceiling must NOT be allowed through,
# since that would publish a versionCode no later build could ever
# exceed. This uses a real distant year (2999) via a raw Unix
# timestamp, not a minutes-since-epoch shortcut, to exercise the
# actual conversion path a malicious or badly-typo'd commit date
# would take.
year_2999 = datetime.datetime(2999, 1, 1, tzinfo=datetime.timezone.utc)
ts = str(int(year_2999.timestamp()))
path = self._write_index("empty.json", {"packages": {}})
env = dict(os.environ)
env["NEXT_VERSION_CODE_NOW"] = NOW_ISO
result = subprocess.run(
[sys.executable, SCRIPT, path, APP_ID, ts],
capture_output=True, text=True, env=env,
)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertLess(got, INT32_MAX - 1_000_000)
# Tighter than "not overflowing": it must be near the clamp ceiling,
# i.e. near *now*, not merely somewhere short of int32.
self.assertEqual(got, NOW_MINUTES + TOLERANCE_MINUTES)
self.assertIn("warning", result.stderr.lower())
# -- the property that actually matters ---------------------------------
def test_property_result_exceeds_every_published_code(self):
"""Across a spread of scenarios -- ordinary, backwards, forwards,
far-forwards -- the result must always exceed every versionCode
already in the fixture. This is the property a future refactor
could quietly break while every individual arithmetic case still
happens to look right."""
scenarios = [
([], 5),
([1, 2, 3], 5),
([100], 100),
([500], 10), # backwards
([1], NOW_MINUTES + TOLERANCE_MINUTES - 1), # forwards, in tolerance
([1], NOW_MINUTES + TOLERANCE_MINUTES + 100_000), # forwards, clamped
]
for published, committer_minutes in scenarios:
with self.subTest(published=published, committer_minutes=committer_minutes):
path = self._write_index(
f"scenario-{committer_minutes}.json",
index_with_versions(APP_ID, published) if published
else {"packages": {}},
)
result = self._run(path, committer_minutes=committer_minutes)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
ceiling = max(published) if published else float("-inf")
self.assertGreater(got, ceiling)
# -- failure modes: fail loudly, never guess ---------------------------
def test_unreadable_index_fails_loudly(self):
missing = os.path.join(self.tmp.name, "does-not-exist.json")
result = self._run(missing, committer_minutes=100)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "") # never a guessed number on stdout
self.assertIn("could not read the index", result.stderr)
def test_malformed_index_fails_loudly(self):
path = self._write_index("malformed.json", "{not valid json")
result = self._run(path, committer_minutes=100)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertIn("could not read the index", result.stderr)
# -- output contract: nothing but the integer on stdout -----------------
def test_stdout_is_only_the_integer(self):
path = self._write_index("clean.json", {"packages": {}})
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
# Exactly one line, and it parses as an int with no surrounding text.
self.assertEqual(int(result.stdout.strip()), 100)
self.assertEqual(result.stdout.count("\n"), 1)
if __name__ == "__main__":
unittest.main()