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>
143 lines
4.5 KiB
Python
143 lines
4.5 KiB
Python
#!/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())
|