initial: fdroidserver-ipfs image
Build fdroidserver-ipfs image / build (push) Successful in 22s

Downstream of registry.gitlab.com/fdroid/docker-executable-fdroidserver:master,
patched for publishing F-Droid repos over IPFS:

  - install Debian ipfs-cid so fdroidserver populates ipfsCIDv1 on every
    package file (APKs, icons, screenshots), which fdroidclient with an
    IPFS-gateway mirror routes via CID natively
  - rehash.py wrapper that adds ipfsCIDv1 to entry["index"], renames the
    index to a content-addressed filename, and re-signs entry.jar after
    `fdroid update` — closes the cache-busting gap on the index file itself
  - entrypoint.sh chains rehash automatically after any `fdroid update`,
    so existing fdroid-via-docker wrappers need no changes

See SPEC.md for the rationale and Change 2's algorithm; README.md for
end-user usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-05-24 22:48:39 +02:00
co-authored by Claude Opus 4.7
commit a6168221d1
8 changed files with 462 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Post-process `fdroid update` output for IPFS publishing.
For each repodir (repo, archive) discovered from config.yml:
- rename index-v2.json to index-v2-<sha256_last12>.json (cache-busts gateways)
- rename diff/<ts>.json to diff/<ts>-<sha256_last12>.json
- rewrite entry.json so name/ipfsCIDv1 point at the renamed files
- re-sign entry.jar via fdroidserver.signindex.sign_index
- regenerate entry.json.asc if GPG signing is configured
Run in the same working directory as `fdroid update` (where config.yml lives).
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
# Make fdroidserver importable + invocable when this script is run outside the
# upstream image's ENTRYPOINT (which sources /etc/profile.d/bsenv.sh). The
# buildserver-style image places fdroidserver at $fdroidserver — typically
# /home/vagrant/fdroidserver. Both sys.path (for the import below) and PATH
# (for the `fdroid gpgsign` subprocess call) need it.
_FDS = os.environ.get('fdroidserver') or '/home/vagrant/fdroidserver'
if os.path.isdir(_FDS):
if _FDS not in sys.path:
sys.path.insert(0, _FDS)
if _FDS not in os.environ.get('PATH', '').split(os.pathsep):
os.environ['PATH'] = _FDS + os.pathsep + os.environ.get('PATH', '')
from fdroidserver import common, signindex
HASH_SUFFIX_LEN = 12
def sha256_last_chars(path: Path) -> str:
h = hashlib.sha256()
with path.open('rb') as fp:
for chunk in iter(lambda: fp.read(65536), b''):
h.update(chunk)
return h.hexdigest()[-HASH_SUFFIX_LEN:]
def write_entry_json(entry: dict, path: Path) -> None:
with path.open('w', encoding='utf-8') as fp:
json.dump(entry, fp, ensure_ascii=False, sort_keys=True)
def rehash_entry_file(repodir: Path, file_entry: dict) -> None:
"""Rename the file referenced by file_entry["name"] and update name/ipfsCIDv1 in place."""
old_rel = file_entry['name'].lstrip('/')
old_path = repodir / old_rel
suffix = sha256_last_chars(old_path)
new_path = old_path.with_name(f"{old_path.stem}-{suffix}{old_path.suffix}")
cid = common.calculate_IPFS_cid(str(old_path))
if new_path != old_path:
old_path.rename(new_path)
file_entry['name'] = '/' + new_path.relative_to(repodir).as_posix()
if cid:
file_entry['ipfsCIDv1'] = cid
def rehash_repodir(repodir_name: str) -> bool:
repodir = Path(repodir_name)
entry_json = repodir / 'entry.json'
if not entry_json.exists():
return False
with entry_json.open(encoding='utf-8') as fp:
entry = json.load(fp)
rehash_entry_file(repodir, entry['index'])
for diff_entry in entry.get('diffs', {}).values():
rehash_entry_file(repodir, diff_entry)
write_entry_json(entry, entry_json)
signindex.sign_index(repodir_name, 'entry.json')
return True
def regenerate_gpg_sigs(repodirs: list) -> None:
"""Drop stale .asc files we just invalidated, then let `fdroid gpgsign` recreate them.
We detect "GPG is part of this pipeline" by the prior existence of entry.json.asc
rather than from config keys — matches whatever the user's pipeline actually does.
gpgsign only signs files missing a sibling .asc, so deleting the stale ones is
enough to trigger a fresh signature on the new filenames.
"""
pipeline_uses_gpg = any(
(Path(r) / 'entry.json.asc').exists() for r in repodirs
)
if not pipeline_uses_gpg:
return
for repodir_name in repodirs:
repodir = Path(repodir_name)
stale = [repodir / 'entry.json.asc']
stale += list(repodir.glob('index-v2*.json.asc'))
if (repodir / 'diff').is_dir():
stale += list((repodir / 'diff').glob('*.json.asc'))
for path in stale:
if path.exists():
path.unlink()
subprocess.run(['fdroid', 'gpgsign'], check=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'repodirs', nargs='*',
help='Repo directories to process. Default: auto-discover from config.yml.',
)
args = parser.parse_args()
config = common.read_config()
signindex.config = config
repodirs = args.repodirs
if not repodirs:
repodirs = ['repo']
if config.get('archive_older', 0) != 0:
repodirs.append('archive')
processed = []
for repodir in repodirs:
if rehash_repodir(repodir):
processed.append(repodir)
regenerate_gpg_sigs(processed)
return 0
if __name__ == '__main__':
sys.exit(main())