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:
@@ -48,6 +48,7 @@ here.
|
||||
| `with-nixpkgs.sh` | run a command with one or more nixpkgs packages on PATH, pinned to the revision in `nixpkgs-pin` |
|
||||
| `check_zip_alignment.py` | the alignment check `sign-apk.sh` runs before signing (see "Align before sign" below) |
|
||||
| `next_version_code.py` | the stdlib-only implementation `next-version-code.sh` wraps (index read, arithmetic) |
|
||||
| `preserve_added.py` | stage last publish's `index-v2.json` so `fdroid update` reuses its per-build `added` dates, and check afterwards that it did (see "Per-build `added` timestamps" below) |
|
||||
| `action.yml` | the composite action a consumer includes to get all of the above |
|
||||
|
||||
Deliberately **not** in scope: fetching the APK. That needs an upstream
|
||||
@@ -82,6 +83,10 @@ only the repo index key, in the `fdroid-update.sh` step.
|
||||
FDROID_REPO_URL: https://fdroid-weblib.s3w.dev.chaosbit.de/repo
|
||||
FDROID_REPO_NAME: weblib
|
||||
FDROID_REPO_DESCRIPTION: "weblib's F-Droid repository"
|
||||
# Last publish's index, fetched by the consumer (only it knows the
|
||||
# bucket). Leave UNSET for a channel's first-ever publish; see
|
||||
# "Per-build `added` timestamps" below.
|
||||
FDROID_PREVIOUS_INDEX: /tmp/previous-index-v2.json
|
||||
run: |
|
||||
bash "${{ steps.ci.outputs.path }}/fdroid-update.sh" \
|
||||
"$FDROID_REPO_ROOT" "$INDEX_KEYSTORE" repo-key INDEX_KEYSTORE_PASSWORD
|
||||
@@ -151,6 +156,79 @@ serves (and a client's "Add to F-Droid" 404s on `index-v2.json`) -- the
|
||||
`/repo` prefix is not optional decoration, it is what makes `repo_url`
|
||||
and the published tree agree at all.
|
||||
|
||||
### Per-build `added` timestamps (`FDROID_PREVIOUS_INDEX`, weblib/weblib-viewer#95)
|
||||
|
||||
**A repo that rebuilds `repo/` from scratch on every run must hand
|
||||
`fdroid-update.sh` the index it published last time, or every build in it
|
||||
gets re-stamped with the current wall clock.** That is
|
||||
weblib/weblib-viewer#95: an F-Droid client's version list showed five
|
||||
separate builds all "added" within seconds of each other, all at the moment
|
||||
of the most recent publish.
|
||||
|
||||
It is not an mtime problem and `--use-date-from-apk` does not fix it.
|
||||
fdroidserver takes the date from `common.KnownApks`, which loads it out of
|
||||
an *already existing index*:
|
||||
|
||||
```python
|
||||
for part in ('repo', 'archive'):
|
||||
path = os.path.join(part, 'index-v2.json')
|
||||
if os.path.isfile(path):
|
||||
...
|
||||
filename = version["file"]["name"][1:]
|
||||
date = datetime.fromtimestamp(version["added"] // 1000, tz=timezone.utc)
|
||||
self.apks[filename] = date
|
||||
```
|
||||
|
||||
and falls back to the wall clock for any filename it has not seen:
|
||||
|
||||
```python
|
||||
if apkName not in self.apks:
|
||||
if default_date is None:
|
||||
default_date = datetime.now(timezone.utc)
|
||||
self.apks[apkName] = default_date
|
||||
return self.apks[apkName]
|
||||
```
|
||||
|
||||
`KnownApks`' own docstring says why there is nowhere else to look: the index
|
||||
is "currently the only way to permanently store the 'updated' date of APKs".
|
||||
A CI job on a fresh runner starts with no `repo/index-v2.json`, so
|
||||
`KnownApks` starts empty and *everything*, including APKs re-downloaded by a
|
||||
retention scheme, counts as new. (`--use-date-from-apk` only changes the
|
||||
fallback from `now()` to the local file's mtime, which for a re-downloaded
|
||||
file is the time it was downloaded -- no better.)
|
||||
|
||||
So: fetch the previously published `index-v2.json` and point
|
||||
`FDROID_PREVIOUS_INDEX` at the local copy. `fdroid-update.sh` stages it
|
||||
where `KnownApks` looks, runs the update, and then **checks** that every
|
||||
build present in both indexes really did keep its date, failing the job if
|
||||
not -- fdroidserver's own `read_added_date_from_all_apks` carries a TODO
|
||||
about reading these dates differently, so this is verified rather than
|
||||
assumed.
|
||||
|
||||
- **Unset it for a first-ever publish.** A path that is set but does not
|
||||
exist is a hard failure, deliberately: a consumer whose fetch silently
|
||||
produced nothing would otherwise flatten its entire history on every
|
||||
publish and look perfectly healthy doing it.
|
||||
- A file that is not a parseable `index-v2.json` (a truncated download, an
|
||||
S3 `NoSuchKey` error document) is refused **before** `fdroid update`
|
||||
starts, naming the path -- `KnownApks` has no error handling of its own
|
||||
and would abort mid-library with a traceback that never mentions the file.
|
||||
- A genuinely new build is untouched and still gets "now"; that is the one
|
||||
date that *should* be the current time.
|
||||
- `lastUpdated` keeps meaning the real last update: fdroidserver derives it
|
||||
as the newest `added` across the app's builds.
|
||||
- Fetching it is the consumer's job, not this repo's, for the same reason
|
||||
fetching the APK is: only the consumer knows the bucket, and per
|
||||
fdroid-repo-ci#1 this repo must not come to know. `fdroid-update.sh`
|
||||
itself still needs no network.
|
||||
|
||||
One caveat worth stating rather than discovering: the *app-level*
|
||||
`metadata.added` is derived by fdroidserver as the oldest `added` among the
|
||||
builds currently in the index, so it moves forward as old APKs age out of a
|
||||
retention window. That is inherent to retention -- the index cannot
|
||||
reference an APK that is no longer published -- and is not what #95 is
|
||||
about.
|
||||
|
||||
### The exception: a consumer whose build does not already sign
|
||||
|
||||
`sign-apk.sh` is **not part of the flow above** and `action.yml` does not
|
||||
@@ -438,7 +516,7 @@ here needs, resolved through the same pin.
|
||||
bash run_tests.sh
|
||||
```
|
||||
|
||||
98 assertions across seven suites offline (~20s on a warm nix store, 4
|
||||
123 assertions across eight suites offline (~20s on a warm nix store, 4
|
||||
deliberately skipped -- see below), plus 13 more that only run against a
|
||||
real bucket:
|
||||
|
||||
@@ -465,6 +543,25 @@ real bucket:
|
||||
the forwards clamp) are pinned independently, the latter via an
|
||||
undocumented env var seam, so every expected number is fixed rather than
|
||||
depending on whatever minute the suite happens to run in.
|
||||
* `test_preserve_added.py` -- standard library too, running
|
||||
`preserve_added.py` as a subprocess against synthetic index-v2.json
|
||||
fixtures: staging puts the file at exactly `repo/index-v2.json` (the only
|
||||
path `common.KnownApks` looks at), a missing/truncated/not-an-index
|
||||
previous index is refused with the path named and nothing staged, and
|
||||
verification passes when every shared build kept its date while **failing**
|
||||
when one drifted -- the negative control, since a checker that only ever
|
||||
passes is worth nothing. Also: a build that aged out of the retention
|
||||
window is not drift, a leading `/` on `file.name` (present in the index,
|
||||
stripped by `KnownApks`) must not make every build look new, a version
|
||||
entry with no `added` at all is skipped rather than defaulted, and the
|
||||
sub-second tolerance is exactly one second wide -- fdroidserver only ever
|
||||
emits whole seconds, but a whole second of drift still fails.
|
||||
`test_fdroid_update.sh` covers the same feature's shell-level contract
|
||||
(`FDROID_PREVIOUS_INDEX` set but missing, or set to garbage, aborts before
|
||||
`fdroid update` runs); the preservation *itself*, end to end against real
|
||||
APKs, was measured against a copy of the live dev index rather than in the
|
||||
suite, because this repo's fixture APK is deliberately not parseable by
|
||||
androguard and so never reaches the index at all.
|
||||
* `test_with_nixpkgs.sh` -- offline; every way the pinning wrapper is
|
||||
supposed to refuse rather than silently degrade (missing pin, malformed
|
||||
pin, too few arguments). Does not exercise the happy path of actually
|
||||
|
||||
Reference in New Issue
Block a user