fdroid-repo/fdroid-sync.py

512 lines
20 KiB
Python
Raw Normal View History

Feed the F-Droid repo from Forgejo releases automatically The scaffold here was a manual pipeline: copy an APK into repo/, run `fdroid update`, rsync the result somewhere. Every app already builds and signs its own APK in CI and publishes it as a Forgejo release, so all of that copying was re-doing by hand what the forge already had. Both halves of a listing now come from the same release: the APK from the release asset, and the title, summary, description, changelog, icon and screenshots from the fastlane/ tree at the same tag. A listing can no longer describe a build other than the one it ships, because there is no second copy of the text to drift. fdroid-sync.py is the whole reconcile. It is a pure function of what the forge currently holds — work out what should be published, make it so, re-sign the index only if something moved — which is what makes it safe to drive from a webhook that has no delivery guarantee and no retry. A missed delivery costs latency, not correctness. Three things were only found by running the real thing with an empty environment, and all three fail late and confusingly: - fdroidserver bundles apksigner but no JDK, and shells out to jar, keytool and jarsigner by bare name. The PATH is now set outright rather than inherited, because the bundled apksigner is a Gradle-style launcher that also needs xargs and sed — and without xargs fdroidserver reads every APK as having an invalid signature and quarantines the lot into archive/. - index-v2 only carries a changelog for a version matching the last Builds entry, and a repo of prebuilt APKs has no build recipes. Without a declared entry every release shipped an empty "What's New". - fdroidserver does not fall back to the APK's launcher icon, so an app whose fastlane tree has no images/icon.png was listed with no icon at all, which is two of these four. The sync now recovers one from the APK. module.nix keeps the keystore password out of the Nix store via fdroidserver's {env: ...} indirection, and serves the repo rooted at repo/ rather than at the working directory, which would also have handed out config.yml and metadata/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-14 11:54:26 +03:00
#!/usr/bin/env python3
"""Reconcile a self-hosted F-Droid repo with the latest Forgejo releases.
Nothing is built here. Each app's APK is the one its own CI built and signed,
and its listing text comes from the fastlane/ tree in the same repo at the same
tag, so the store entry and the binary can never describe different releases.
A run is a pure function of what the forge currently holds: it works out what
should be in the repo, makes it so, and rebuilds the index only if something
actually moved. That is what makes it safe to drive from a webhook, which has
no delivery guarantee and no retry — a missed delivery costs latency, not
correctness, because the next run (or the timer) converges anyway.
Usage: fdroid-sync <config.json> [app-id ...]
"""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# Both halves of a release filename come from the git tag, which the app
# workflows write as v<versionName>+<versionCode> (Flutter's own pubspec
# version syntax). The code is what F-Droid orders versions by and what names
# the changelog file, so a tag without one is an error rather than a guess.
TAG_SEP = "+"
# How long to keep waiting for an APK that is still uploading. The release
# webhook fires when the release object is created, which in these workflows is
# one API call *before* the APK is attached to it, so a webhook-triggered run
# routinely arrives at a release with no asset yet. That is a race to wait out,
# not an error: a 50-80 MB upload off a CI runner takes a while.
ASSET_WAIT_SECONDS = 600
ASSET_POLL_SECONDS = 20
# A run can be triggered while an earlier one is still going, and systemd
# merges the second `start` into the first rather than queueing it, so the
# release that triggered it would be missed until the daily timer. Re-checking
# the forge after a pass that changed something closes that window; a pass that
# changes nothing ends the loop, so the steady state is still a single pass.
MAX_PASSES = 3
DEFAULT_LOCALE = "en-US"
# Per-read, not per-transfer, so this is not a cap on how long an 80 MB APK may
# take — only on how long the forge may go silent mid-stream.
HTTP_TIMEOUT = 60
def log(*args):
print(*args, flush=True)
class AppError(Exception):
"""A failure confined to one app, so the other three still get a chance."""
# --------------------------------------------------------------------------
# forge API
def api_get(url):
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return json.load(resp)
def download(url, dest: Path, expect_size=None):
"""Fetch to a sibling temp file, then rename.
Never streams straight onto the destination: a half-written file under
repo/ is one `fdroid update` away from being published as a real release,
and a phone that downloads it gets a signature failure it cannot explain.
"""
tmp = dest.with_name(dest.name + ".part")
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp, open(tmp, "wb") as out:
shutil.copyfileobj(resp, out, 1 << 20)
size = tmp.stat().st_size
if expect_size is not None and size != expect_size:
tmp.unlink()
raise AppError(f"{dest.name}: got {size} bytes, forge advertised {expect_size}")
tmp.rename(dest)
return size
def latest_release(forge, repo):
"""The newest published, non-draft, non-prerelease release."""
url = f"{forge}/api/v1/repos/{repo}/releases/latest"
try:
return api_get(url)
except urllib.error.HTTPError as e:
if e.code == 404:
raise AppError("no published release yet") from None
raise
def version_code(tag):
name, sep, code = tag.lstrip("v").partition(TAG_SEP)
if not sep or not code.isdigit():
raise AppError(
f"tag {tag!r} is not v<versionName>{TAG_SEP}<versionCode>; "
"cannot tell which version this is"
)
return name, int(code)
def apk_asset(release):
apks = [a for a in release.get("assets", []) if a["name"].endswith(".apk")]
if len(apks) > 1:
raise AppError(
f"release {release['tag_name']} has {len(apks)} APKs "
f"({', '.join(a['name'] for a in apks)}); expected exactly one"
)
return apks[0] if apks else None
def await_apk_asset(forge, repo, release):
"""Poll until the APK shows up, or give up loudly."""
asset = apk_asset(release)
if asset:
return asset
deadline = time.monotonic() + ASSET_WAIT_SECONDS
log(f" {release['tag_name']}: no APK attached yet, waiting for the upload")
while time.monotonic() < deadline:
time.sleep(ASSET_POLL_SECONDS)
asset = apk_asset(latest_release(forge, repo))
if asset:
return asset
raise AppError(
f"release {release['tag_name']} still has no APK after "
f"{ASSET_WAIT_SECONDS}s; did the build fail after creating the release?"
)
# --------------------------------------------------------------------------
# listing metadata, straight out of the app's own fastlane tree
def tree_manifest(root: Path):
"""(relpath, sha256) for every file under root, sorted.
Used to decide whether a freshly fetched listing differs from the one
already in place. The alternative — always replacing it — would mark every
run as changed and rebuild the index daily for nothing, which rewrites
every index timestamp and makes every client re-download it.
"""
out = []
for path in sorted(root.rglob("*")):
if path.is_file():
out.append((str(path.relative_to(root)), hashlib.sha256(path.read_bytes()).hexdigest()))
return out
def extract_fastlane(forge, repo, tag, fastlane_dir, dest: Path):
"""Unpack <fastlane_dir>/metadata/android/ from the source tag into dest.
Copied verbatim, subdirectories and all: fdroidserver reads the fastlane
layout natively — title.txt, short_description.txt, full_description.txt,
changelogs/<versionCode>.txt and images/{icon,featureGraphic}.png plus
images/*Screenshots/ — so there is nothing to translate and no mapping
table to keep in step with upstream.
"""
url = f"{forge}/{repo}/archive/{urllib.parse.quote(tag, safe='')}.tar.gz"
prefix = f"{fastlane_dir.strip('/')}/metadata/android/"
with tempfile.TemporaryDirectory() as td:
tarball = Path(td) / "src.tar.gz"
download(url, tarball)
found = 0
with tarfile.open(tarball) as tf:
for member in tf:
if not member.isfile():
continue
# Drop the archive's top-level directory, which Forgejo names
# after the repo rather than after anything we control.
_, _, rel = member.name.partition("/")
if not rel.startswith(prefix):
continue
rel = rel[len(prefix):]
target = (dest / rel).resolve()
# A tarball is attacker-controlled input in principle, and this
# one is unpacked as root. Refuse any member that resolves
# outside dest rather than trusting the forge to never serve a
# `../` path.
if not str(target).startswith(str(dest.resolve()) + os.sep):
raise AppError(f"archive member escapes the destination: {member.name}")
target.parent.mkdir(parents=True, exist_ok=True)
src = tf.extractfile(member)
if src is None:
continue
with open(target, "wb") as out:
shutil.copyfileobj(src, out)
found += 1
if not found:
raise AppError(f"{fastlane_dir}/metadata/android/ is empty or missing at tag {tag}")
return found
def open_apk(apk: Path):
"""androguard's APK reader, or None if androguard is not installed."""
try:
from androguard.core.apk import APK
except ImportError:
return None
# androguard logs through loguru, not the stdlib, and at DEBUG it prints a
# line per resource-table oddity — hundreds of them per Flutter APK, which
# buries the one line we actually emit here.
try:
from loguru import logger
logger.remove()
except ImportError:
pass
return APK(str(apk))
def check_version_code(apk: Path, code):
"""Fail if the tag and the APK disagree about which version this is.
Everything downstream keys off the version code taken from the git tag:
it names the APK in repo/, it is what pruning sorts on, and it is what
resolves the changelog. F-Droid, meanwhile, reads the code out of the APK's
own manifest. Normally both come from the same pubspec line and agree, but
nothing enforces that — a hand-moved tag is enough to split them, and then
the repo publishes a file whose name claims one version while the index
claims another, and pruning starts ordering by a number that means nothing.
"""
pkg = open_apk(apk)
if pkg is None:
return
actual = int(pkg.get_androidversion_code())
if actual != code:
raise AppError(
f"{apk.name}: tag says versionCode {code} but the APK's manifest "
f"says {actual}; the tag and the build do not match"
)
def extract_apk_icon(apk: Path, dest: Path):
"""Last resort: pull the launcher icon out of the APK itself.
An app whose fastlane tree carries no images/icon.png would otherwise be
listed with no icon at all, because fdroidserver does not fall back to the
APK. Two of these four apps are in exactly that state, and an iconless row
in the client reads as a broken repo rather than as missing metadata.
Shipping an icon in fastlane is still the better answer — it can be the
square store icon rather than whatever density the launcher happens to
use — so this only fills the gap, it does not replace it.
"""
pkg = open_apk(apk)
if pkg is None:
log(" androguard unavailable; cannot recover an icon from the APK")
return False
# Descending, because the default pick is whatever the manifest points at,
# and on every modern Android app that is an adaptive-icon XML rather than
# a bitmap. Asking for a bounded density returns a real PNG.
for dpi in (640, 480, 320, 240, 160):
name = pkg.get_app_icon(max_dpi=dpi)
if name and name.lower().endswith(".png"):
data = pkg.get_file(name)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
log(f" recovered icon from the APK ({name}, <={dpi}dpi, {len(data)} bytes)")
return True
log(" no bitmap launcher icon in the APK; listing will have no icon")
return False
_listing_done = {}
def sync_listing(app, cfg, tag, apk: Path, locale):
"""Refresh metadata/<packageId>/ from the app repo. True if it changed.
Read from the source archive at the *released tag*, not from the default
branch, so the listing always describes the build it ships with. The
consequence is worth stating plainly: correcting a typo in
full_description.txt on main changes nothing here. It takes a new release,
or moving the tag.
Refetched on every run even when the release has not moved, so a tag that
was moved in place is still picked up, and so a listing lost to a failed
run repairs itself. Within a single run it is done once per tag: the later
passes exist to notice a *new* release, and repeating the fetch and the APK
icon parse for a tag already handled would be pure waste.
Feed the F-Droid repo from Forgejo releases automatically The scaffold here was a manual pipeline: copy an APK into repo/, run `fdroid update`, rsync the result somewhere. Every app already builds and signs its own APK in CI and publishes it as a Forgejo release, so all of that copying was re-doing by hand what the forge already had. Both halves of a listing now come from the same release: the APK from the release asset, and the title, summary, description, changelog, icon and screenshots from the fastlane/ tree at the same tag. A listing can no longer describe a build other than the one it ships, because there is no second copy of the text to drift. fdroid-sync.py is the whole reconcile. It is a pure function of what the forge currently holds — work out what should be published, make it so, re-sign the index only if something moved — which is what makes it safe to drive from a webhook that has no delivery guarantee and no retry. A missed delivery costs latency, not correctness. Three things were only found by running the real thing with an empty environment, and all three fail late and confusingly: - fdroidserver bundles apksigner but no JDK, and shells out to jar, keytool and jarsigner by bare name. The PATH is now set outright rather than inherited, because the bundled apksigner is a Gradle-style launcher that also needs xargs and sed — and without xargs fdroidserver reads every APK as having an invalid signature and quarantines the lot into archive/. - index-v2 only carries a changelog for a version matching the last Builds entry, and a repo of prebuilt APKs has no build recipes. Without a declared entry every release shipped an empty "What's New". - fdroidserver does not fall back to the APK's launcher icon, so an app whose fastlane tree has no images/icon.png was listed with no icon at all, which is two of these four. The sync now recovers one from the APK. module.nix keeps the keystore password out of the Nix store via fdroidserver's {env: ...} indirection, and serves the repo rooted at repo/ rather than at the working directory, which would also have handed out config.yml and metadata/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-14 11:54:26 +03:00
"""
if _listing_done.get(app["id"]) == tag:
return False
root = Path(cfg["root"])
live = root / "metadata" / app["packageId"]
with tempfile.TemporaryDirectory(dir=root / "tmp") as td:
staged = Path(td) / "metadata"
staged.mkdir()
extract_fastlane(cfg["forge"], app["repo"], tag, app.get("fastlane", "fastlane"), staged)
icon = staged / locale / "images" / "icon.png"
if not icon.exists():
extract_apk_icon(apk, icon)
_listing_done[app["id"]] = tag
if live.exists() and tree_manifest(live) == tree_manifest(staged):
return False
# Replace rather than merge, so a file deleted upstream disappears here
# too. The swap is two renames in the same filesystem; `fdroid update`
# is not running concurrently (the unit is a oneshot) so there is no
# reader to tear.
if live.exists():
shutil.rmtree(live.with_name(live.name + ".old"), ignore_errors=True)
live.rename(live.with_name(live.name + ".old"))
staged.rename(live)
shutil.rmtree(live.with_name(live.name + ".old"), ignore_errors=True)
return True
def app_metadata(app, code, version_name):
"""The non-localized half of the listing: metadata/<packageId>.yml.
Name, Summary and Description are deliberately not set. fdroidserver reads
those from the localized files this script just unpacked, and a value here
would win over them — pinning the listing to whatever was true when this
file was written.
Two fields here are load-bearing for the changelog, and both have to be
right or the client shows an empty "What's New":
CurrentVersionCode is what fdroidserver resolves changelogs/<code>.txt
against, so without it the changelog file is never read at all.
Builds is what gets the text into the index once read. index-v2 only
carries whatsNew for a version matching the *last* Builds entry, and a repo
of prebuilt APKs has no build recipes, so the entry has to be declared. It
is metadata only — nothing here builds anything, `fdroid update` just
matches versionCode against it.
"""
forge_repo = app["forgeUrl"]
lines = [
"# Generated by fdroid-sync. Edit apps.nix, not this file.",
f"AuthorName: {app.get('authorName', 'randogoth')}",
f"License: {app['license']}",
"Categories:",
]
lines += [f" - {c}" for c in app["categories"]]
lines += [
f"SourceCode: {forge_repo}",
f"IssueTracker: {forge_repo}/issues",
f"Changelog: {forge_repo}/releases",
]
if app.get("webSite"):
lines.append(f"WebSite: {app['webSite']}")
lines += [
f"CurrentVersion: {version_name}",
f"CurrentVersionCode: {code}",
"Builds:",
f" - versionName: {version_name}",
f" versionCode: {code}",
"",
]
return "\n".join(lines)
# --------------------------------------------------------------------------
# per-app reconcile
def prune_apks(root: Path, package_id, keep):
"""Drop all but the newest `keep` versions of one app.
F-Droid's own answer is archive_older, which moves old APKs into archive/
and publishes a second index for them. That is a feature for a repo with
users who pin old versions; here it would be a second index to sign and
serve so that nobody can reach four-releases-ago builds of a launcher.
Deleting is the honest version of the same decision, and because the index
is regenerated from whatever APKs are present, it needs no bookkeeping.
"""
found = []
for apk in (root / "repo").glob(f"{package_id}_*.apk"):
code = apk.stem[len(package_id) + 1:]
if code.isdigit():
found.append((int(code), apk))
dropped = False
for _code, apk in sorted(found, reverse=True)[keep:]:
log(f" pruning {apk.name} (keeping newest {keep})")
apk.unlink()
dropped = True
return dropped
def reconcile(app, cfg):
"""Bring one app's APK and listing in line with its latest release."""
root = Path(cfg["root"])
locale = cfg.get("locale", DEFAULT_LOCALE)
release = latest_release(cfg["forge"], app["repo"])
tag = release["tag_name"]
name, code = version_code(tag)
apk_path = root / "repo" / f"{app['packageId']}_{code}.apk"
changed = False
if apk_path.exists():
log(f" {app['id']}: {tag} already published")
else:
asset = await_apk_asset(cfg["forge"], app["repo"], release)
log(f" {app['id']}: fetching {tag} ({asset['name']}, {asset['size']} bytes)")
download(asset["browser_download_url"], apk_path, expect_size=asset["size"])
try:
check_version_code(apk_path, code)
except AppError:
# Unlink first: left in place it would be picked up by the next
# run as "already published" and never checked again.
apk_path.unlink()
raise
changed = True
if sync_listing(app, cfg, tag, apk_path, locale):
log(f" {app['id']}: listing updated from {tag}")
changed = True
meta = root / "metadata" / f"{app['packageId']}.yml"
want = app_metadata(app, code, name)
if not meta.exists() or meta.read_text() != want:
meta.write_text(want)
log(f" {app['id']}: metadata set to version {name} ({code})")
changed = True
if prune_apks(root, app["packageId"], cfg.get("keep", 3)):
changed = True
return changed
# --------------------------------------------------------------------------
def build_index(cfg):
"""Sign a fresh index over whatever is now in repo/.
Deliberately without --create-metadata: an APK with no metadata/<id>.yml
means apps.nix and the repo disagree, and the useful outcome is a loud
failure, not a listing auto-filled with the working directory's name as its
category (which is what the skeleton generator actually does).
"""
log("building and signing the index")
env = dict(os.environ)
env["FDROID_KEYSTORE_PASS"] = Path(cfg["keystorePassFile"]).read_text().strip()
# fdroidserver reads keypass separately even when it is the same secret.
env["FDROID_KEY_PASS"] = env["FDROID_KEYSTORE_PASS"]
subprocess.run([cfg["fdroid"], "update"], cwd=cfg["root"], env=env, check=True)
def main(argv):
if len(argv) < 2:
print(__doc__, file=sys.stderr)
return 2
cfg = json.loads(Path(argv[1]).read_text())
root = Path(cfg["root"])
for sub in ("repo", "metadata", "tmp"):
(root / sub).mkdir(parents=True, exist_ok=True)
wanted = set(argv[2:])
apps = [a for a in cfg["apps"] if not wanted or a["id"] in wanted]
unknown = wanted - {a["id"] for a in cfg["apps"]}
if unknown:
print(f"unknown app(s): {', '.join(sorted(unknown))}", file=sys.stderr)
return 2
for app in apps:
app["forgeUrl"] = f"{cfg['forge']}/{app['repo']}"
# An index that was never built is a change in itself, otherwise a first
# run on a fresh server that finds every APK already in place (restored
# from a backup, say) would serve no index at all.
changed = not (root / "repo" / "index-v2.json").exists()
failed = []
for attempt in range(1, MAX_PASSES + 1):
log(f"checking {len(apps)} app(s) against {cfg['forge']} (pass {attempt})")
moved = False
failed = []
for app in apps:
try:
if reconcile(app, cfg):
moved = True
except AppError as e:
log(f" {app['id']}: {e}")
failed.append(app["id"])
# Broad on purpose: one app's surprise must not sink the other three.
except Exception as e:
log(f" {app['id']}: unexpected {type(e).__name__}: {e}")
failed.append(app["id"])
changed |= moved
if not moved:
break
if changed:
build_index(cfg)
else:
log("nothing changed; index left alone")
if failed:
# Loud, but only after everything that could be published has been.
log(f"FAILED: {', '.join(failed)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))