#!/usr/bin/env python3 """Generate Hugo markdown pages listing builds fetched from the server. Reads build artifacts under public_html/builds/ on the deployment server via SSH, parses the git hash from each filename, fetches the commit title from the Codeberg API, then writes Hugo content pages to website/content/builds/. Covers two platforms: - Linux: sharedinbox-linux-amd64-.tar.gz - Android: sharedinbox-mua-.apk At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform. These generated pages are not tracked in git (see .gitignore). """ import json import os import re import subprocess import sys import urllib.request from pathlib import Path CODEBERG_REPO = "guettli/sharedinbox" REMOTE_BUILDS_DIR = "public_html/builds" CONTENT_DIR = Path("website/content/builds") BASE_URL = "https://sharedinbox.de" CODEBERG_BASE = "https://codeberg.org" MAX_BUILDS_PER_PLATFORM = 30 def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]: result = subprocess.run( [ "ssh", f"{ssh_user}@{ssh_host}", f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort", ], capture_output=True, text=True, ) if result.returncode != 0: print( f"WARNING: ssh exit {result.returncode} listing {pattern} on {ssh_user}@{ssh_host}" " — build history will be empty for this pattern", file=sys.stderr, ) print(result.stderr, file=sys.stderr) return [] return [line.strip() for line in result.stdout.splitlines() if line.strip()] def get_commit_info(hash_val: str) -> tuple[str, str]: """Return (title, datetime_iso) for the given commit hash.""" url = f"https://codeberg.org/api/v1/repos/{CODEBERG_REPO}/git/commits/{hash_val}" try: req = urllib.request.Request(url, headers={"User-Agent": "sharedinbox-ci"}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) title = data.get("commit", {}).get("message", "").split("\n")[0] dt = data.get("commit", {}).get("committer", {}).get("date", "") return title, dt except Exception as exc: print(f" warning: could not fetch commit info for {hash_val}: {exc}", file=sys.stderr) return hash_val, "" def parse_builds( paths: list[str], path_re: re.Pattern, # type: ignore[type-arg] ) -> dict[str, list[tuple[str, str, str, str]]]: """Parse build file paths into {date_key: [(hash, url, title, dt), ...]}.""" limited = paths[-MAX_BUILDS_PER_PLATFORM:] if len(paths) > MAX_BUILDS_PER_PLATFORM else paths days: dict[str, list[tuple[str, str, str, str]]] = {} for path in limited: m = path_re.match(path) if not m: print(f" skipping unexpected path: {path}", file=sys.stderr) continue year, month, day, filename, hash_val = m.groups() date_key = f"{year}/{month}/{day}" download_url = f"{BASE_URL}/builds/{year}/{month}/{day}/{filename}" commit_title, commit_dt = get_commit_info(hash_val) days.setdefault(date_key, []).append((hash_val, download_url, commit_title, commit_dt)) return days def render_entries( entries: list[tuple[str, str, str, str]], link_label: str, ) -> str: lines = [] for hash_val, download_url, commit_title, commit_dt in entries: commit_url = f"{CODEBERG_BASE}/{CODEBERG_REPO}/commit/{hash_val}" dt_str = f" · {commit_dt}" if commit_dt else "" lines.append( f"- [{commit_title}]({commit_url}){dt_str} \n" f" [{link_label}]({download_url}) (`{hash_val}`)\n" ) return "".join(lines) def main() -> None: ssh_user = os.environ.get("SSH_USER", "") ssh_host = os.environ.get("SSH_HOST", "") if not ssh_user or not ssh_host: print("SSH_USER and SSH_HOST must be set", file=sys.stderr) sys.exit(1) print(f"Listing Linux builds on {ssh_host}…") linux_paths = list_remote_files(ssh_user, ssh_host, "sharedinbox-linux-amd64-*.tar.gz") print(f"Found {len(linux_paths)} Linux build(s)") linux_re = re.compile( r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-linux-amd64-(.+)\.tar\.gz)$" ) linux_days = parse_builds(linux_paths, linux_re) print(f"Listing Android APKs on {ssh_host}…") apk_paths = list_remote_files(ssh_user, ssh_host, "*.apk") print(f"Found {len(apk_paths)} APK(s)") apk_re = re.compile( r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-mua-(.+)\.apk)$" ) android_days = parse_builds(apk_paths, apk_re) CONTENT_DIR.mkdir(parents=True, exist_ok=True) # _index.md: platform sections, newest-first within each index_lines = ["---\ntitle: Builds\n---\n\n"] index_lines.append(f"## Linux (last {MAX_BUILDS_PER_PLATFORM})\n\n") if linux_days: for date_key in sorted(linux_days, reverse=True): year, month, day = date_key.split("/") index_lines.append(f"### {year}-{month}-{day}\n\n") index_lines.append(render_entries(linux_days[date_key], "Download")) index_lines.append("\n") else: index_lines.append("_No Linux builds yet._\n\n") index_lines.append(f"## Android (last {MAX_BUILDS_PER_PLATFORM})\n\n") if android_days: for date_key in sorted(android_days, reverse=True): year, month, day = date_key.split("/") index_lines.append(f"### {year}-{month}-{day}\n\n") index_lines.append(render_entries(android_days[date_key], "Download APK")) index_lines.append("\n") else: index_lines.append("_No Android builds yet._\n\n") (CONTENT_DIR / "_index.md").write_text("".join(index_lines), encoding="utf-8") # Per-day pages (combined) all_days = set(linux_days) | set(android_days) for date_key in sorted(all_days): year, month, day = date_key.split("/") date_iso = f"{year}-{month}-{day}" day_dir = CONTENT_DIR / year / month day_dir.mkdir(parents=True, exist_ok=True) lines = [f"---\ntitle: 'Builds for {date_iso}'\ndate: {date_iso}T00:00:00Z\n---\n\n"] if date_key in linux_days: lines.append("## Linux\n\n") lines.append(render_entries(linux_days[date_key], "Download")) lines.append("\n") if date_key in android_days: lines.append("## Android\n\n") lines.append(render_entries(android_days[date_key], "Download APK")) lines.append("\n") (day_dir / f"{day}.md").write_text("".join(lines), encoding="utf-8") total_linux = sum(len(v) for v in linux_days.values()) total_android = sum(len(v) for v in android_days.values()) print( f"Generated pages: {total_linux} Linux build(s), {total_android} Android build(s) " f"across {len(all_days)} day(s)" ) if __name__ == "__main__": main()