The builds page at /builds/ was empty because generate-build-history
only ran inside deploy-playstore; if that job failed early (e.g. Play
Store secrets not configured) the website was never updated, and the
build-linux job never triggered a website update at all.
Changes:
- generate_build_history.py: extend to cover Linux tarballs in addition
to Android APKs, capped at MAX_BUILDS_PER_PLATFORM (30) each
- Taskfile: add website-publish task (generate-build-history +
website-deploy), exclude *.tar.gz from rsync, update descriptions
- .forgejo/workflows/ci.yml: add publish-website job that waits for
both build-linux and deploy-playstore (using always() so it runs
even when deploy-playstore fails), then removes the duplicate
generate/deploy steps from deploy-playstore
- .github/workflows/ci.yml: add deploy job that deploys Linux build,
generates build history, builds Hugo site, and rsyncs to server
- .gitignore: ignore website/content/builds/_index.md (generated),
Python __pycache__, and widget test failure screenshots
- stalwart-dev/integration_ui_test.sh: use ${USER:-$(id -un)} for
robustness in environments where USER is unset
- scripts/test_generate_build_history.py: unit tests for parse_builds
and render_entries covering both platforms
Generated content (builds/_index.md and per-day pages) is not tracked
in git; it is produced at CI time and rsynced to the server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
#!/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-<hash>.tar.gz
|
|
- Android: sharedinbox-mua-<hash>.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",
|
|
"-o",
|
|
"StrictHostKeyChecking=no",
|
|
f"{ssh_user}@{ssh_host}",
|
|
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
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()
|