publish.sh: verify credentials and upload the index last; add next-version-code.sh
Closes #5 Closes #6 An F-Droid repo is a signed index plus the files it names, and a partial upload could previously leave them disagreeing: aws s3 sync walks the tree in its own order, so an interrupted publish could land the index before the APKs it names, breaking the repo for every client until the upload finished or was rolled back. publish.sh now uploads in two passes, unconditionally -- every non-index file first, the index (index-v1.json(.asc), index-v2.json, entry.json, entry.jar) last -- so an interruption anywhere in pass 1 simply leaves the repo describing the old state a little longer, never a mixture. Credentials are also verified with one real authenticated call (head-bucket) before either pass, rather than merely checking the environment variables are non-empty, since an unusable-but-present credential failing halfway through is exactly how a partial upload happens. next-version-code.sh is the one implementation of the versionCode rule (candidate = minutes since 2026-01-01Z, clamped to strictly exceed whatever is already published for that applicationId), shared by CI and manual builds so there is exactly one place this can drift. bit pointed out (appended to #6) that the clamp is monotonic against *observed* state, not monotonic by construction: two jobs starting in the same minute can both read the same published value and race to publish the same versionCode on different APKs. Nothing can prevent that wasted build, so publish.sh now also refuses to overwrite an already-existing APK object before pass 1 -- turning the collision from a silent, permanent wrong state into a loud failure at the last moment the information is reliable. Tested offline (83 assertions: pure-stdlib suites for both scripts, the real CLIs through with-nixpkgs.sh, and a small local HEAD-only S3 stub proving the credential check is a real authenticated call and the APK-collision refusal fires only when it should) and live against fdroid-weblib-dev (7 more: a real publish observed mid-flight via a same-process synchronization hook to prove the index is genuinely absent while the file is already present, both objects reading back byte-identical, and a second publish refused against a real already-published APK with its bytes left unchanged). Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compute the next monotonic Android versionCode for an application id.
|
||||
|
||||
Implements the rule from fdroid-repo-ci#6 -- read that issue for the reasoning
|
||||
behind each part; this is only the arithmetic and the index read.
|
||||
|
||||
candidate = whole minutes since 2026-01-01T00:00:00Z
|
||||
published = highest versionCode already in the index for this app id
|
||||
result = max(candidate, published + 1)
|
||||
|
||||
Minutes, not seconds or raw Unix time: versionCode is a signed 32-bit int,
|
||||
and minutes-since-2026 does not exhaust it for 4,082 years. The clamp against
|
||||
`published` is not optional -- two builds inside the same minute would
|
||||
otherwise compute the same candidate, and it also covers a clock stepping
|
||||
backwards or an old commit being rebuilt, both of which would otherwise emit
|
||||
a number lower than one a device has already seen.
|
||||
|
||||
Reads `packages.<applicationId>.versions.*.manifest.versionCode`, the shape
|
||||
fdroidserver's own index.py actually writes (`packages` keyed by
|
||||
applicationId/packageName; `versions` a dict keyed by each APK's sha256, not
|
||||
a list; the versionCode nested under that version's `manifest`) -- verified
|
||||
against fdroidserver's source, not assumed from an older v1-shaped example.
|
||||
|
||||
An application id absent from the index entirely is not an error: it is the
|
||||
first-ever publish for a channel, and "published" is then simply nobody, so
|
||||
the bare candidate is correct. An unreadable or unparseable index IS an
|
||||
error -- guessing a number when the published state is unknown is exactly
|
||||
how a duplicate or a decrease gets published.
|
||||
|
||||
Standard library only, deliberately -- no third-party dependency for a script
|
||||
that reads a signed index. `urllib.request` handles the http(s) case; a local
|
||||
path is read directly, which is what makes the offline test suite possible
|
||||
(fixture index-v2.json files, no server, no network).
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
EPOCH = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def minutes_since_epoch(now=None):
|
||||
now = now if now is not None else datetime.datetime.now(datetime.timezone.utc)
|
||||
return int((now - EPOCH).total_seconds() // 60)
|
||||
|
||||
|
||||
def load_index(source, timeout=30):
|
||||
"""source is an http(s) URL or a local file path to index-v2.json."""
|
||||
if source.startswith("http://") or source.startswith("https://"):
|
||||
with urllib.request.urlopen(source, timeout=timeout) as resp:
|
||||
data = resp.read()
|
||||
else:
|
||||
with open(source, "rb") as f:
|
||||
data = f.read()
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def highest_published_version_code(index, application_id):
|
||||
"""The highest versionCode already published for application_id, or None
|
||||
if the index has no entry for it at all (a normal state: the first
|
||||
publish for this channel)."""
|
||||
packages = index.get("packages") or {}
|
||||
package = packages.get(application_id)
|
||||
if not package:
|
||||
return None
|
||||
versions = package.get("versions") or {}
|
||||
codes = [
|
||||
manifest["versionCode"]
|
||||
for version in versions.values()
|
||||
if isinstance((manifest := (version or {}).get("manifest")), dict)
|
||||
and isinstance(manifest.get("versionCode"), int)
|
||||
]
|
||||
return max(codes) if codes else None
|
||||
|
||||
|
||||
def next_version_code(source, application_id, now=None):
|
||||
index = load_index(source)
|
||||
candidate = minutes_since_epoch(now)
|
||||
published = highest_published_version_code(index, application_id)
|
||||
if published is None:
|
||||
return candidate
|
||||
return max(candidate, published + 1)
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Print the next monotonic versionCode for an application id."
|
||||
)
|
||||
parser.add_argument("index_source", help="index-v2.json URL or local path")
|
||||
parser.add_argument("application_id")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Testing seam only, deliberately undocumented in the usage banner: lets
|
||||
# the offline suite pin "now" so `candidate` is a fixed, known number
|
||||
# instead of whatever minute the test happens to run in. Unset in every
|
||||
# real invocation, so production behaviour is untouched.
|
||||
now = None
|
||||
now_override = os.environ.get("NEXT_VERSION_CODE_NOW")
|
||||
if now_override:
|
||||
now = datetime.datetime.fromisoformat(now_override)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
try:
|
||||
result = next_version_code(args.index_source, args.application_id, now=now)
|
||||
except (OSError, ValueError) as e:
|
||||
# OSError covers both a local file that can't be read and a network
|
||||
# failure (urllib.error.URLError is a subclass); ValueError covers
|
||||
# malformed JSON. Fail loudly on stderr, print nothing on stdout --
|
||||
# a caller doing `versionCode=$(next-version-code.sh ...)` must never
|
||||
# capture a number that was actually an error message.
|
||||
print(f"next-version-code.py: could not read the index at "
|
||||
f"{args.index_source!r}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user