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:
2026-09-12 06:29:20 +00:00
co-authored by bit
parent 760583b7e3
commit 2906f00564
7 changed files with 670 additions and 12 deletions
+98 -1
View File
@@ -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