#!/usr/bin/env python3 # Root-owned dev channel publisher for storebound desktop (install as /usr/local/sbin/storebound-desktop-dev, root:root # 755, run by storebound-desktop-dev.timer every 2 minutes; see docs/desktop-release.md, "Dev channel"). # # Every push to main attaches its dev bundles (Windows NSIS setup, Linux AppImage + deb, SHA256SUMS) to a Forgejo # draft release `dev-` (ci.yml `dev` job, tools/desktop-dev.ts). This script, with a # read-only Forgejo token (/root/.storebound-keys/forgejo-read-token): # 1. takes the newest complete dev draft whose version is above the live dev/latest.json (never a lower one), # 2. downloads its files into /var/lib/storebound-desktop/incoming// and checks them against SHA256SUMS, # 3. signs each with the updater key (/root/.storebound-keys/updater.key; it never leaves this host), # 4. writes latest.json + SHA256SUMS and runs storebound-desktop-publish dev (latest.json last), # 5. keeps the newest 5 dev versions in /var/www/storebound-desktop/dl/ (and whatever dev/latest.json names). # Nothing it downloads is executed. One run at a time (flock). import fcntl, hashlib, json, os, re, shutil, subprocess, sys, urllib.request from datetime import datetime, timezone FORGEJO = "https://git.crt.fyi" REPO = "CRT/storebound" KEYS = "/root/.storebound-keys" TOKEN = f"{KEYS}/forgejo-read-token" KEY = f"{KEYS}/updater.key" KEY_PASSWORD = f"{KEYS}/updater.key.password" TAURI_CLI = "@tauri-apps/cli@2.12.1" INCOMING = "/var/lib/storebound-desktop/incoming" ROOT = "/var/www/storebound-desktop" SITE = "https://storebound.dev/desktop" KEEP = 5 DEV = re.compile(r"^(\d+)\.(\d+)\.(\d+)-dev\.(\d{14})$") SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") def log(msg): print(f"storebound-desktop-dev: {msg}", flush=True) def key(v): m = DEV.match(v) return tuple(int(x) for x in m.groups()) if m else None def headers(): # Cloudflare refuses urllib's default User-Agent (403) return {"Authorization": f"token {open(TOKEN).read().strip()}", "User-Agent": "storebound-desktop-dev"} def get(url, binary=False): req = urllib.request.Request(url, headers=headers()) with urllib.request.urlopen(req, timeout=60) as r: return r.read() if binary else json.loads(r.read()) def live_version(): try: return json.load(open(f"{ROOT}/dev/latest.json")).get("version") except FileNotFoundError: return None def kind(name, v): if name == f"storebound_{v}_x64-setup.exe": return "nsis", f"storebound_{v}_x64-setup-unsigned.exe" if name == f"storebound_{v}_amd64.AppImage": return "appimage", name if name == f"storebound_{v}_amd64.deb": return "deb", name return None, None def sign(path): pw = open(KEY_PASSWORD).read().strip() if os.path.exists(KEY_PASSWORD) else "" env = dict(os.environ, TAURI_SIGNING_PRIVATE_KEY_PASSWORD=pw) if os.path.exists(path + ".sig"): os.remove(path + ".sig") subprocess.run(["timeout", "300", "bunx", TAURI_CLI, "signer", "sign", "-f", KEY, path], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, check=True) s = open(path + ".sig").read().strip() os.remove(path + ".sig") return s def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for b in iter(lambda: f.read(1 << 20), b""): h.update(b) return h.hexdigest() def publish(rel, v): assets = {a["name"]: a for a in rel["assets"]} sums = {} for line in get(f"{FORGEJO}/attachments/{assets['SHA256SUMS']['uuid']}", binary=True).decode().splitlines(): m = re.match(r"^([0-9a-f]{64})\s+\*?(\S+)$", line.strip()) if m: sums[m.group(2)] = m.group(1) files = [(n,) + kind(n, v) for n in assets if kind(n, v)[0]] if sorted(k for _, k, _ in files) != ["appimage", "deb", "nsis"]: log(f"{v}: draft incomplete ({', '.join(assets)}), waiting") return False inc = f"{INCOMING}/{v}" shutil.rmtree(inc, ignore_errors=True) os.makedirs(inc, mode=0o755) manifest = {"version": v, "notes": f"storebound desktop {v} (dev channel: every push to main)", "pub_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "platforms": {}, "downloads": {}} out_sums = [] for name, k, dest in files: assert SAFE.match(dest) and name in sums, name p = f"{inc}/{dest}" log(f"{v}: fetching {name}") req = urllib.request.Request(f"{FORGEJO}/attachments/{assets[name]['uuid']}", headers=headers()) with urllib.request.urlopen(req, timeout=900) as r, open(p, "wb") as f: shutil.copyfileobj(r, f, 1 << 20) h = sha256(p) if h != sums[name]: raise SystemExit(f"{name}: SHA-256 differs from the draft's SHA256SUMS") sig = sign(p) url = f"{SITE}/dl/{v}/{dest}" d = {"file": dest, "url": url, "sha256": h, "size": os.path.getsize(p), "signature": sig, "codeSigned": False} if k == "nsis": manifest["downloads"]["windows-x86_64-nsis"] = d manifest["platforms"]["windows-x86_64"] = {"signature": sig, "url": url} elif k == "appimage": manifest["downloads"]["linux-x86_64-appimage"] = d manifest["platforms"]["linux-x86_64"] = {"signature": sig, "url": url} else: manifest["downloads"]["linux-x86_64-deb"] = d out_sums.append(f"{h} {dest}") log(f"{v}: {dest} checked and signed") with open(f"{inc}/latest.json", "w") as f: json.dump(manifest, f, indent=2) out_sums.append(f"{sha256(inc + '/latest.json')} latest.json") with open(f"{inc}/SHA256SUMS", "w") as f: f.write("\n".join(sorted(out_sums, key=lambda l: l.split()[1])) + "\n") for n in os.listdir(inc): os.chmod(f"{inc}/{n}", 0o644) subprocess.run(["/usr/local/sbin/storebound-desktop-publish", v, "dev"], check=True) return True def prune(): live = live_version() dl = f"{ROOT}/dl" dev = sorted((d for d in os.listdir(dl) if key(d)), key=key, reverse=True) for d in dev[KEEP:]: if d != live: shutil.rmtree(f"{dl}/{d}") log(f"pruned dl/{d}") def main(): os.makedirs(INCOMING, exist_ok=True) lock = open(f"{INCOMING}/.dev.lock", "w") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return rels = get(f"{FORGEJO}/api/v1/repos/{REPO}/releases?limit=50") cands = [(r, r["tag_name"][4:]) for r in rels if r["tag_name"].startswith("dev-") and key(r["tag_name"][4:])] if not cands: return rel, v = max(cands, key=lambda c: key(c[1])) live = live_version() if live and key(live) and key(v) <= key(live): prune() return log(f"publishing {v} (live: {live or 'none'})") if publish(rel, v): prune() if __name__ == "__main__": try: main() except subprocess.CalledProcessError as e: log(f"failed: {e}") sys.exit(1)