#!/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)+'

' 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.

'+digest_html+'Signed manifestChecksum file
' cards.append('
'+('LATEST RELEASE' if v == version else 'PREVIOUS RELEASE')+'

Pharos '+v+'

'+inline(summary)+'

Read release notes'+notes+'
Download ZIPVersion-pinned installer'+('' if v == version else 'Use the latest version for a new installation.')+'
'+checksum+'
') nav = ''.join(''+v+' '+d+'' for v,d,_,_ in rels) schema = json.dumps({"@context":"https://schema.org","@type":"SoftwareApplication","name":"Pharos","softwareVersion":version,"operatingSystem":"PHP 8.3+ or Docker","applicationCategory":"DeveloperApplication","url":"https://pharos.solutionmax.net/","downloadUrl":RELEASES+'/pharos-'+version+'.zip'}) template = TEMPLATE.replace('@@CSS@@', CSS).replace('@@SCHEMA@@', schema).replace('@@VERSION@@', html.escape(version)).replace('@@DATE@@', nice_date(current[1])).replace('@@SUMMARY@@', inline(featured_summary)).replace('@@SIZE@@',size).replace('@@CARDS@@',''.join(cards)).replace('@@NAV@@',nav) print(template) CSS = r""" :root{--ink:#101c2d;--muted:#596a7e;--blue:#0066c0;--line:#dfe7f0;--bg:#f8fafd;--card:#fff;font-family:'Public Sans',system-ui,sans-serif;color:var(--ink);background:var(--bg)} *{box-sizing:border-box}body{margin:0;line-height:1.65}a{color:var(--blue);text-underline-offset:4px}button,summary{cursor:pointer}a:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid #168ef0;outline-offset:4px}h1,h2,h3{font-family:Archivo,system-ui,sans-serif;letter-spacing:-.04em;line-height:1.12;margin:0}p{margin:12px 0}code{font-family:'JetBrains Mono',monospace;font-size:12px}.wrap{max-width:1200px;padding:0 26px;margin:auto}.nav{border-bottom:1px solid var(--line);background:white}.nav .wrap{min-height:68px;display:flex;align-items:center;justify-content:space-between;gap:20px}.brand img{display:block;width:110px;height:auto}.nav-links{display:flex;gap:25px;align-items:center}.nav-links a{font-size:13px;color:var(--muted);text-decoration:none}.nav-links .nav-start{background:var(--blue);color:white;padding:9px 15px;border-radius:6px}.skip{position:absolute;left:10px;top:-100px}.skip:focus{top:10px;background:white;z-index:5}.intro{padding:48px 0 30px;display:flex;justify-content:space-between;gap:30px;align-items:end}.eyebrow{font:10px 'JetBrains Mono',monospace;letter-spacing:.12em;color:var(--blue);display:block;margin-bottom:10px}h1{font-size:clamp(35px,4vw,52px)}.intro p{max-width:56ch;color:var(--muted);font-size:16px}.intro>a{white-space:nowrap;font-size:13px;margin-bottom:18px}.latest{display:grid;grid-template-columns:1.15fr 1fr;gap:45px;background:#0c2038;color:white;border:1px solid #203c5a;border-radius:16px;padding:34px 38px;box-shadow:0 20px 48px -32px #163a69;margin-bottom:34px}.latest .eyebrow{color:#83bfff}.latest h2{font-size:42px}.latest p{color:#c1d0e0;font-size:14px;max-width:53ch}.latest .release-date{font-size:12px;color:#96abc4}.latest-badge{display:inline-flex;gap:7px;align-items:center;color:#94ebc0;background:#123f39;font:10px 'JetBrains Mono',monospace;border:1px solid #27584c;border-radius:30px;padding:4px 10px;margin-bottom:14px}.latest-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.button{display:inline-flex;justify-content:center;align-items:center;text-decoration:none;padding:11px 17px;background:#0066c0;border-radius:7px;color:white;font-size:13px;font-weight:600}.button.secondary{background:#1d3653;border:1px solid #38546f}.latest .file-note{font-size:11px;color:#aabbd0}.latest-points{border-left:1px solid #304761;padding-left:32px;align-self:center}.latest-points h3{font-size:16px;letter-spacing:-.015em}.latest-points p{font-size:13px}.latest-points a{color:#9bd1ff;font-size:12px}.paths{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:45px}.path{background:white;padding:23px;border:1px solid var(--line);border-radius:10px;min-width:0;display:flex;flex-direction:column}.path h3{font-size:19px}.path p{font-size:13px;color:var(--muted)}.path>a{font-size:12px;font-weight:600;margin-top:auto;padding-top:12px}.path-label{font:10px 'JetBrains Mono',monospace;color:var(--muted);display:block;margin-bottom:12px}.command{background:#edf2f8;border-radius:6px;padding:12px;overflow-wrap:anywhere;white-space:pre-wrap;margin:10px 0}.history-layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:40px}.version-nav{position:sticky;top:25px;align-self:start}.version-nav a{display:block;padding:9px 10px;border-left:2px solid var(--line);text-decoration:none;font:12px 'JetBrains Mono',monospace;color:var(--ink)}.version-nav a:hover{border-color:var(--blue);background:#edf4fc}.version-nav a span{display:block;font-size:10px;color:var(--muted);margin-top:5px}.release{scroll-margin-top:20px;background:white;border:1px solid var(--line);border-radius:12px;padding:28px;margin-bottom:18px;min-width:0}.release header{display:flex;justify-content:space-between;align-items:center;gap:16px}.release h2{font-size:27px}.release time{font-size:11px;color:var(--muted);white-space:nowrap}.release-summary{font-size:14px;color:var(--muted)}summary{font-size:13px;font-weight:600;padding:12px 0}.change-group{display:grid;grid-template-columns:90px minmax(0,1fr);gap:18px;border-top:1px solid #edf1f6;padding:15px 0}.change-group h3{font-size:12px;color:var(--blue);padding-top:5px;letter-spacing:0}.change-group ul{margin:0;padding-left:18px;font-size:13px;color:var(--muted)}li+li{margin-top:9px}.release-downloads{display:flex;gap:16px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:16px;font-size:12px}.release-downloads span{color:var(--muted);font-size:11px}.verify{margin-top:10px}.verify p{font-size:12px;color:var(--muted)}.digest{display:block;overflow-wrap:anywhere;white-space:normal;background:#f2f5f9;padding:12px;margin-bottom:12px}.verify button{border:1px solid #ccd9e8;border-radius:5px;padding:7px 10px;background:white;font:12px inherit;color:var(--ink);margin-right:15px}.verify>a{font-size:12px;margin-right:15px}.upgrade-note{margin:28px 0;background:#eef4fa;border-left:3px solid var(--blue);padding:20px 24px}.upgrade-note h2{font-size:20px}.upgrade-note p{font-size:13px;color:var(--muted)}.copy-status{font-size:12px;color:var(--muted)} .foot{background:#0a1729;color:#b9c8da;border-top:1px solid #0e2036;margin-top:32px}.foot-end{display:flex;flex-wrap:wrap;gap:.6rem 1.4rem;padding-block:.9rem;font-family:'JetBrains Mono',monospace;font-size:.69rem;letter-spacing:.1em;text-transform:uppercase;color:#91a8c2}.foot-end a{color:#77bfff;text-decoration:underline} @media(max-width:850px){.latest{grid-template-columns:1fr;gap:24px}.latest-points{border-left:0;border-top:1px solid #304761;padding:22px 0 0}.paths{grid-template-columns:1fr}.history-layout{grid-template-columns:1fr;gap:20px}.version-nav{position:static;display:flex;flex-wrap:wrap;gap:5px}.version-nav .eyebrow{width:100%}.version-nav a{border:1px solid var(--line);border-radius:6px}.intro{display:block}.nav-links{gap:14px}} @media(max-width:520px){.wrap{padding:0 20px}.intro{padding:32px 0 20px}.latest{padding:25px}.latest h2{font-size:35px}.release{padding:20px}.release header{align-items:start;flex-direction:column;gap:4px}.change-group{grid-template-columns:1fr;gap:8px}.nav-links a:nth-child(2){display:none}.nav-links{gap:12px}.nav-links a{font-size:12px}.brand img{width:90px}.latest-actions .button{width:100%}.release code{overflow-wrap:anywhere}} """ TEMPLATE = r""" Pharos downloads and release notes — @@VERSION@@
DOWNLOADS & CHANGELOG

The latest Pharos.
Ready for your hosting.

Choose your installation, see what changed and update with a clear recovery path.

Browse release history ↓
● LATEST RELEASE

Pharos @@VERSION@@

Released @@DATE@@

@@SUMMARY@@

ZIP: @@SIZE@@ · Dependencies included · PHP 8.3+

Installing for the first time?

Use the web installer on shared hosting. It checks requirements and verifies the signed release before unpacking.

Installation guide →

Already running Pharos?

On PHP hosting, open Admin → Updates. On Docker, update from the host using the guide below.

Read the release notes →
01 / SHARED HOSTING

Upload. Open. Set up.

DirectAdmin, cPanel or Plesk. Upload the PHP installer to your web folder, open it in a browser and follow the steps, including cron verification.

Install without SSH →
03 / PHP WITH SSH

Install from your shell.

For a new PHP installation, run the installer and follow the document-root and scheduler instructions:

curl -fsSL https://pharos.solutionmax.net/get | sh -s -- --php --version @@VERSION@@
SSH installation guide →
@@CARDS@@

Before you update or roll back

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.

Updating from 0.5.4 may require one PHP or container restart to clear old OPcache code. Read the update and recovery guide →

""" if __name__ == "__main__": main()