2026-05-14 21:46:56 +02:00
|
|
|
#!/usr/bin/env python3
|
2026-05-15 19:08:55 +02:00
|
|
|
"""Generate Hugo markdown pages listing builds fetched from the server.
|
2026-05-14 21:46:56 +02:00
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
Reads build artifacts under public_html/builds/ on the deployment server via SSH,
|
2026-05-14 21:46:56 +02:00
|
|
|
parses the git hash from each filename, fetches the commit title from the
|
|
|
|
|
Codeberg API, then writes Hugo content pages to website/content/builds/.
|
|
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
Covers two platforms:
|
|
|
|
|
- Linux: sharedinbox-linux-amd64-<hash>.tar.gz
|
|
|
|
|
- Android: sharedinbox-mua-<hash>.apk
|
|
|
|
|
|
|
|
|
|
At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform.
|
|
|
|
|
|
2026-05-14 21:46:56 +02:00
|
|
|
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"
|
2026-05-15 19:08:55 +02:00
|
|
|
MAX_BUILDS_PER_PLATFORM = 30
|
2026-05-14 21:46:56 +02:00
|
|
|
|
|
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
2026-05-14 21:46:56 +02:00
|
|
|
result = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"ssh",
|
|
|
|
|
f"{ssh_user}@{ssh_host}",
|
2026-05-15 19:08:55 +02:00
|
|
|
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
2026-05-14 21:46:56 +02:00
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
2026-05-23 12:13:26 +02:00
|
|
|
if result.returncode != 0:
|
2026-05-23 12:17:58 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-23 12:13:26 +02:00
|
|
|
print(result.stderr, file=sys.stderr)
|
2026-05-23 12:17:58 +02:00
|
|
|
return []
|
2026-05-15 19:08:55 +02:00
|
|
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
2026-05-14 21:46:56 +02:00
|
|
|
|
|
|
|
|
|
2026-05-14 23:14:50 +02:00
|
|
|
def get_commit_info(hash_val: str) -> tuple[str, str]:
|
|
|
|
|
"""Return (title, datetime_iso) for the given commit hash."""
|
2026-05-14 21:46:56 +02:00
|
|
|
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())
|
2026-05-14 23:14:50 +02:00
|
|
|
title = data.get("commit", {}).get("message", "").split("\n")[0]
|
|
|
|
|
dt = data.get("commit", {}).get("committer", {}).get("date", "")
|
|
|
|
|
return title, dt
|
2026-05-14 21:46:56 +02:00
|
|
|
except Exception as exc:
|
2026-05-14 23:14:50 +02:00
|
|
|
print(f" warning: could not fetch commit info for {hash_val}: {exc}", file=sys.stderr)
|
|
|
|
|
return hash_val, ""
|
2026-05-14 21:46:56 +02:00
|
|
|
|
|
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 21:46:56 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
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)
|
2026-05-14 21:46:56 +02:00
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
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)
|
2026-05-14 21:46:56 +02:00
|
|
|
|
|
|
|
|
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
|
2026-05-14 23:14:50 +02:00
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
# _index.md: platform sections, newest-first within each
|
2026-05-14 23:14:50 +02:00
|
|
|
index_lines = ["---\ntitle: Builds\n---\n\n"]
|
2026-05-15 19:08:55 +02:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-05-14 23:14:50 +02:00
|
|
|
(CONTENT_DIR / "_index.md").write_text("".join(index_lines), encoding="utf-8")
|
2026-05-14 21:46:56 +02:00
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
# Per-day pages (combined)
|
|
|
|
|
all_days = set(linux_days) | set(android_days)
|
|
|
|
|
for date_key in sorted(all_days):
|
2026-05-14 21:46:56 +02:00
|
|
|
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"]
|
2026-05-15 19:08:55 +02:00
|
|
|
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")
|
2026-05-14 21:46:56 +02:00
|
|
|
(day_dir / f"{day}.md").write_text("".join(lines), encoding="utf-8")
|
|
|
|
|
|
2026-05-15 19:08:55 +02:00
|
|
|
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)"
|
|
|
|
|
)
|
2026-05-14 21:46:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|