fdroid-update.sh: carry each build's "added" date across a rebuild
weblib/weblib-viewer#95: every version in the published index claimed it was added at the moment of the *last* publish, so an F-Droid client's version list showed five separate builds all added within seconds of each other. Measured on the live dev repo: index-v2.json gave versionCodes 365139/365516/365666/365710/366099 the added dates 05:50:06/08/10/12/13 on 2026-09-12, while the bucket's own Last-Modified for those objects says they were actually published on 2026-09-11 at 13:53, 20:09, 22:37, 23:23 and 2026-09-12 05:50. It is not an mtime problem, and --use-date-from-apk does not fix it. fdroidserver takes the date from common.KnownApks, whose __init__ loads it out of an already existing repo/index-v2.json (and archive/index-v2.json), and whose recordapk falls back to datetime.now() for any filename it has not seen; --use-date-from-apk only changes that fallback to the local file's mtime, which for a re-downloaded APK is when it was downloaded. KnownApks' own docstring says there is nowhere else to look: the index is "currently the only way to permanently store the 'updated' date of APKs". A CI job that rebuilds repo/ on a fresh runner -- which is exactly what weblib-viewer#32's retention scheme does when it re-downloads the previous APKs -- therefore starts with an empty KnownApks, and every build in it counts as new. So hand fdroid-update.sh the previously published index, optionally, as FDROID_PREVIOUS_INDEX -- a local path, because fetching it needs to know the bucket and per fdroid-repo-ci#1 that stays on the consumer's side; this script still needs no network. preserve_added.py stages it where KnownApks looks, and afterwards *checks* that every build present in both indexes really did keep its date, failing the run if not. That is not belt-and-braces: fdroidserver's own read_added_date_from_all_apks carries a TODO about reading these dates differently, so a future version could drop the behaviour this depends on, and a silent return to flattened history is the failure mode that took a human noticing to catch the first time. Set-but-missing is a hard failure rather than a shrug, and so is a file that is not a parseable index-v2.json (a truncated download, an S3 NoSuchKey error document): KnownApks has no error handling of its own and would abort mid-library with a traceback that never names the file, and a consumer whose fetch silently produced nothing would otherwise flatten its whole history on every publish while looking healthy. A first-ever publish expresses "there is no previous index" by leaving the variable unset. Verified end to end without publishing anything, against the five real APKs and a copy of the live dev index-v2.json pulled over HTTPS: - no previous index (today's behaviour), all five re-stamped: 366099 06:23:19, 365710 06:23:18, 365666 06:23:17, 365516 06:23:15, 365139 06:23:14 -- a five-second spread, all of it "now". - previous index minus 366099, i.e. the real "four retained plus one genuinely new" case: 365139/365516/365666/365710 keep 05:50:06/08/10/12 exactly, 366099 gets 06:24:01, and lastUpdated follows the new build. - a second run on a fresh repo/ with the APK mtimes deliberately touched to now: all five unchanged, "5 already-published build(s) kept their original 'added' timestamp, 0 new". test_preserve_added.py (12 assertions, stdlib only) covers the drift detection in both directions, including the negative control that a drifted date fails; test_fdroid_update.sh covers the shell contract. run_tests.sh: 123 passed, 4 skipped, 0 failed. A weblib-viewer change is needed too -- its workflows have to fetch last publish's index and set FDROID_PREVIOUS_INDEX -- and is a separate PR in that repo. Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline tests for preserve_added.py (weblib/weblib-viewer#95).
|
||||
|
||||
Standard library only, no network, no nix -- like test_next_version_code.py
|
||||
and test_check_zip_alignment.py, this is the piece of the change that needs
|
||||
none of the pinned toolchain. `fdroid update` itself is exercised by
|
||||
test_fdroid_update.sh, which needs fdroidserver; what is tested here is the
|
||||
part that has to be right *before* fdroidserver is ever reached and the part
|
||||
that judges what fdroidserver produced.
|
||||
|
||||
Run as a *subprocess*, same reasoning the neighbouring suites give: the
|
||||
contract includes the exit code (fdroid-update.sh aborts a publish on a
|
||||
non-zero `verify`) and the messages, not just the return value of a function.
|
||||
|
||||
The one property that matters, and the one a refactor could quietly break
|
||||
while still looking correct: `verify` must FAIL when a build that exists in
|
||||
both indexes changed its date. A checker that only ever passes is worth
|
||||
nothing, so every "carried" assertion below is paired with a drift case.
|
||||
"""
|
||||
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, "preserve_added.py")
|
||||
APP_ID = "de.example.app"
|
||||
|
||||
|
||||
def index(entries, app_id=APP_ID):
|
||||
"""Build a minimal index-v2.json shaped exactly like fdroidserver's.
|
||||
|
||||
`entries` is {versionCode: added_ms}. The map key under `versions` is a
|
||||
sha256 in a real index; nothing here reads it, and using the versionCode
|
||||
keeps the fixtures readable -- what both sides key on is `file.name`,
|
||||
which is what `common.KnownApks` itself keys on.
|
||||
"""
|
||||
versions = {}
|
||||
for code, added in entries.items():
|
||||
versions["%064x" % code] = {
|
||||
"added": added,
|
||||
"file": {"name": "/%s_%d.apk" % (app_id, code), "size": 1},
|
||||
"manifest": {"versionCode": code, "versionName": "1.0"},
|
||||
}
|
||||
return {
|
||||
"repo": {"timestamp": 0, "address": "https://example.invalid/repo"},
|
||||
"packages": {app_id: {"metadata": {"added": 0}, "versions": versions}},
|
||||
}
|
||||
|
||||
|
||||
class PreserveAddedTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def write(self, name, obj):
|
||||
path = os.path.join(self.tmp.name, name)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
if isinstance(obj, str):
|
||||
f.write(obj)
|
||||
else:
|
||||
json.dump(obj, f)
|
||||
return path
|
||||
|
||||
def run_script(self, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# --- install ---------------------------------------------------------
|
||||
|
||||
def test_install_puts_the_index_where_knownapks_looks_for_it(self):
|
||||
"""fdroidserver reads repo/index-v2.json and nothing else.
|
||||
|
||||
common.KnownApks.__init__ globs exactly os.path.join(part,
|
||||
'index-v2.json') for part in ('repo', 'archive'), relative to the
|
||||
repo root. A file staged anywhere else, or under any other name, is
|
||||
invisible to it -- so the filename is the whole contract here.
|
||||
"""
|
||||
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
||||
repo = os.path.join(self.tmp.name, "repo")
|
||||
os.mkdir(repo)
|
||||
|
||||
result = self.run_script("install", prev, repo)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
staged = os.path.join(repo, "index-v2.json")
|
||||
self.assertTrue(os.path.isfile(staged))
|
||||
with open(staged, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f), index({1: 1000, 2: 2000}))
|
||||
self.assertIn("2 known build(s)", result.stdout)
|
||||
|
||||
def test_install_refuses_a_missing_repo_directory(self):
|
||||
prev = self.write("prev.json", index({1: 1000}))
|
||||
result = self.run_script("install", prev, os.path.join(self.tmp.name, "nope"))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("no such repo directory", result.stderr)
|
||||
|
||||
def test_install_refuses_a_missing_previous_index(self):
|
||||
repo = os.path.join(self.tmp.name, "repo")
|
||||
os.mkdir(repo)
|
||||
result = self.run_script(
|
||||
"install", os.path.join(self.tmp.name, "gone.json"), repo
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("no such previous index", result.stderr)
|
||||
|
||||
def test_install_refuses_a_truncated_index(self):
|
||||
"""KnownApks has no error handling at all around json.load().
|
||||
|
||||
A half-written download would abort `fdroid update` from inside a
|
||||
library, with a traceback that never names the file. Refuse it here,
|
||||
naming the path, before the run starts.
|
||||
"""
|
||||
prev = self.write("prev.json", '{"packages": {"a":')
|
||||
repo = os.path.join(self.tmp.name, "repo")
|
||||
os.mkdir(repo)
|
||||
result = self.run_script("install", prev, repo)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("not valid JSON", result.stderr)
|
||||
self.assertFalse(os.path.exists(os.path.join(repo, "index-v2.json")))
|
||||
|
||||
def test_install_refuses_valid_json_that_is_not_an_index(self):
|
||||
"""An S3 error document, or entry.json fetched by mistake, parses fine."""
|
||||
for payload in ('{"error": "NoSuchKey"}', "[]", '{"packages": []}'):
|
||||
with self.subTest(payload=payload):
|
||||
prev = self.write("prev.json", payload)
|
||||
repo = os.path.join(self.tmp.name, "repo")
|
||||
os.makedirs(repo, exist_ok=True)
|
||||
result = self.run_script("install", prev, repo)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("does not look like an index-v2.json", result.stderr)
|
||||
|
||||
# --- verify ----------------------------------------------------------
|
||||
|
||||
def test_verify_passes_when_every_shared_build_kept_its_date(self):
|
||||
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
||||
new = self.write("new.json", index({1: 1000, 2: 2000, 3: 9000}))
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("2 already-published build(s) kept", result.stdout)
|
||||
self.assertIn("1 new", result.stdout)
|
||||
self.assertIn("_3.apk is new", result.stdout)
|
||||
|
||||
def test_verify_fails_when_a_shared_build_drifted(self):
|
||||
"""The negative control: this is exactly weblib-viewer#95.
|
||||
|
||||
Both builds re-stamped with the current wall clock, which is what a
|
||||
run with no previous index produces.
|
||||
"""
|
||||
prev = self.write("prev.json", index({1: 1000, 2: 2000}))
|
||||
new = self.write("new.json", index({1: 1789194194000, 2: 1789194195000}))
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("lost their original 'added' timestamp", result.stderr)
|
||||
self.assertIn("_1.apk was added at 1000", result.stderr)
|
||||
self.assertIn("_2.apk was added at 2000", result.stderr)
|
||||
|
||||
def test_verify_ignores_a_build_that_aged_out_of_retention(self):
|
||||
"""Retention deletes old APKs on purpose; that is not drift.
|
||||
|
||||
Only builds present in BOTH indexes are judged.
|
||||
"""
|
||||
prev = self.write("prev.json", index({1: 1000, 2: 2000, 3: 3000}))
|
||||
new = self.write("new.json", index({3: 3000, 4: 4000}))
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("1 already-published build(s) kept", result.stdout)
|
||||
|
||||
def test_verify_accepts_a_first_publish_shaped_empty_index(self):
|
||||
prev = self.write("prev.json", {"packages": {}})
|
||||
new = self.write("new.json", index({1: 1000}))
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("0 already-published build(s) kept", result.stdout)
|
||||
|
||||
def test_verify_tolerates_sub_second_rounding_only(self):
|
||||
"""fdroidserver stores ms but only ever emits whole seconds.
|
||||
|
||||
index.py serialises a datetime as
|
||||
`int(calendar.timegm(obj.timetuple()) * 1000)` and KnownApks reads it
|
||||
back as `version["added"] // 1000`, so a hand-made previous index
|
||||
carrying a sub-second value comes back rounded through no fault of
|
||||
the mechanism. That must not fail a publish -- but one whole second
|
||||
of drift still must.
|
||||
"""
|
||||
prev = self.write("prev.json", index({1: 1789194194678}))
|
||||
result = self.run_script(
|
||||
"verify", prev, self.write("new.json", index({1: 1789194194000}))
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
result = self.run_script(
|
||||
"verify", prev, self.write("new2.json", index({1: 1789194195000}))
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
|
||||
def test_verify_ignores_a_version_entry_with_no_added(self):
|
||||
"""A missing date is not a date; do not default it to anything."""
|
||||
broken = index({1: 1000})
|
||||
del list(broken["packages"][APP_ID]["versions"].values())[0]["added"]
|
||||
prev = self.write("prev.json", broken)
|
||||
new = self.write("new.json", index({1: 5000}))
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_leading_slash_on_file_name_is_stripped_consistently(self):
|
||||
"""index-v2 writes "/name.apk"; KnownApks strips the slash ([1:]).
|
||||
|
||||
Both sides of verify have to agree, or every build looks new.
|
||||
"""
|
||||
prev_obj = index({1: 1000})
|
||||
for v in prev_obj["packages"][APP_ID]["versions"].values():
|
||||
v["file"]["name"] = v["file"]["name"].lstrip("/") # no leading slash
|
||||
prev = self.write("prev.json", prev_obj)
|
||||
new = self.write("new.json", index({1: 1000})) # with leading slash
|
||||
result = self.run_script("verify", prev, new)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("1 already-published build(s) kept", result.stdout)
|
||||
self.assertNotIn("is new", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user