#!/usr/bin/env python3
"""Render https://pharos.solutionmax.net/releases/ from CHANGELOG.md, the manifest and releases.json.
scripts/release-page.py CHANGELOG.md dist/latest.json dist/releases.json > dist/index.html
releases.json is kept by build-release.sh: [{"version","date","size","sha256"}], newest first.
Static, no build dependencies. Same type as the site (Archivo / Public Sans / JetBrains Mono).
"""
import base64
import html
import json
import re
import sys
RELEASES = "https://pharos.solutionmax.net/releases"
KIND = { # heading in CHANGELOG → (label, css class)
"Added": ("Added", "add"), "Changed": ("Changed", "chg"), "Fixed": ("Fixed", "fix"),
"Security": ("Security", "sec"), "Removed": ("Removed", "rem"), "Deprecated": ("Deprecated", "rem"),
}
def manifest(path):
token = open(path).read().strip().split(".", 1)[0]
return json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)))
def sections(changelog):
"""[(version, date, summary, [(kind, [items])])] newest first."""
out = []
for m in re.finditer(r"^## \[(\d+\.\d+\.\d+)\] — (\d{4}-\d{2}-\d{2})\n(.*?)(?=^## \[|\Z)", changelog, re.M | re.S):
body = m.group(3).strip()
summary, groups, cur = [], [], None
for line in body.splitlines():
if line.startswith("### "):
cur = (line[4:].strip(), []); groups.append(cur)
elif line.startswith("- ") and cur:
cur[1].append(line[2:].strip())
elif line.strip() and cur is None:
summary.append(line.strip())
out.append((m.group(1), m.group(2), " ".join(summary), groups))
return out
def inline(s):
s = html.escape(s)
s = re.sub(r"`([^`]+)`", r"\1", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
return s
def nice_date(iso):
y, mo, d = iso.split("-")
return f"{int(d)} {['January','February','March','April','May','June','July','August','September','October','November','December'][int(mo)-1]} {y}"
def human(n):
return f"{n/1048576:.1f} MB" if n >= 1048576 else f"{n/1024:.0f} KB"
def main():
changelog = open(sys.argv[1]).read()
latest = manifest(sys.argv[2])
meta = {r["version"]: r for r in json.load(open(sys.argv[3]))} if len(sys.argv) > 3 else {}
rels = sections(changelog)
version = latest["version"]
current = next((r for r in rels if r[0] == version), None)
if current is None:
raise ValueError("Latest manifest version is missing from changelog")
featured_summary = current[2] or latest.get("notes", "")
size = human(meta[version]["size"]) if meta.get(version, {}).get("size") else "Production package"
cards = []
for v, d, summary, groups in rels:
digest = meta.get(v, {}).get("sha256") or (latest["sha256"] if v == version else "")
notes = "".join('
'+inline(k)+'
'+"".join('
'+inline(it)+'
' for it in items)+'
' for k, items in groups if items)
digest_html = ''+html.escape(digest)+'' if digest else ''
checksum = 'Verify this download
The installer verifies the signed manifest and archive. A checksum alone confirms file integrity, not who published it.
Take a backup first. Pharos snapshots include SQLite; MySQL needs a separate database backup. A version-pinned installer is for a fresh installation, not a downgrade. Restore matching code and database when rolling back.