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:
@@ -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())
|
||||
Reference in New Issue
Block a user