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:
2026-09-09 09:46:00 +00:00
co-authored by bit
parent fc2735624e
commit e5d1f9a5bf
6 changed files with 406 additions and 121 deletions
+150 -52
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
"""Offline tests for next_version_code.py (fdroid-repo-ci#6).
"""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.
@@ -12,15 +14,20 @@ 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).
"now" is pinned via the NEXT_VERSION_CODE_NOW testing seam (see the script's
main()) so `candidate` is a fixed, known integer instead of whatever minute
the suite happens to run in.
Two clocks are pinned independently, deliberately kept distinct so a test
can move either one without the other:
The property every case after the first two actually defends -- 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. Each clamp case asserts that
directly, not just the arithmetic.
* 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
@@ -35,11 +42,21 @@ SCRIPT = os.path.join(HERE, "next_version_code.py")
APP_ID = "de.example.app"
EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
# An arbitrary, fixed "now": 100 minutes after the epoch this script uses,
# so the expected candidate (100) is known without re-deriving the formula
# under test.
FIXED_NOW = (EPOCH + datetime.timedelta(minutes=100)).isoformat()
CANDIDATE_AT_FIXED_NOW = 100
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):
@@ -66,66 +83,65 @@ class NextVersionCodeTest(unittest.TestCase):
json.dump(obj_or_text, f)
return path
def _run(self, index_path, application_id=APP_ID, now=FIXED_NOW):
def _run(self, index_path, committer_minutes, application_id=APP_ID,
now_iso=NOW_ISO):
env = dict(os.environ)
if now is not None:
env["NEXT_VERSION_CODE_NOW"] = now
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],
[sys.executable, SCRIPT, index_path, application_id, committer_ts],
capture_output=True, text=True, env=env,
)
# -- no prior build: the first-ever publish for a channel -------------
# -- a normal commit yields its own minute -----------------------------
def test_no_prior_build_yields_bare_candidate(self):
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)
result = self._run(path, committer_minutes=250)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, f"{CANDIDATE_AT_FIXED_NOW}\n")
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)
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, f"{CANDIDATE_AT_FIXED_NOW}\n")
self.assertEqual(result.stdout, "100\n")
# -- the clamp: result must exceed every published code ----------------
# -- backwards: a commit older than published still exceeds it ---------
def test_lower_prior_is_ignored_in_favour_of_the_clock(self):
path = self._write_index("lower.json", index_with_versions(APP_ID, [1, 2, 3]))
result = self._run(path)
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, CANDIDATE_AT_FIXED_NOW)
self.assertGreater(got, 3)
self.assertEqual(got, 501)
self.assertGreater(got, 500)
def test_equal_prior_is_bumped_by_one(self):
# published == candidate: the same-minute collision case. The clamp
# must still move strictly past it.
path = self._write_index(
"equal.json", index_with_versions(APP_ID, [CANDIDATE_AT_FIXED_NOW]))
result = self._run(path)
# 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.assertGreater(got, CANDIDATE_AT_FIXED_NOW)
self.assertEqual(got, CANDIDATE_AT_FIXED_NOW + 1)
def test_higher_prior_wins_the_clamp_clock_went_backwards(self):
higher = CANDIDATE_AT_FIXED_NOW + 500
path = self._write_index("higher.json", index_with_versions(APP_ID, [higher]))
result = self._run(path)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertGreater(got, higher)
self.assertEqual(got, higher + 1)
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)
result = self._run(path, committer_minutes=100)
self.assertEqual(result.returncode, 0, result.stderr)
got = int(result.stdout)
self.assertGreater(got, 999)
@@ -138,23 +154,105 @@ class NextVersionCodeTest(unittest.TestCase):
obj["packages"][APP_ID]["versions"]["nomanifest"] = {}
obj["packages"][APP_ID]["versions"]["emptymanifest"] = {"manifest": {}}
path = self._write_index("partial.json", obj)
result = self._run(path)
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)
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)
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)
@@ -163,10 +261,10 @@ class NextVersionCodeTest(unittest.TestCase):
def test_stdout_is_only_the_integer(self):
path = self._write_index("clean.json", {"packages": {}})
result = self._run(path)
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()), CANDIDATE_AT_FIXED_NOW)
self.assertEqual(int(result.stdout.strip()), 100)
self.assertEqual(result.stdout.count("\n"), 1)