Files
reforgermods-launcher/scripts/release-manifest.py
T
SowinskiBraedenandClaude Opus 5 377f3a31b3 initial commit: reforgermods.net launcher 0.1.0
A desktop server browser for Arma Reforger, built on the reforgermods.net v2
API. Live server list with search, sort and filters; a detail inspector with
population history and mod readiness; measured ICMP latency; favorites, recents
and settings; and direct joining through Steam.

Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in
one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested
directly; src-tauri is the window, the command surface and the wiring.

Packaged for the public beta: a per-user NSIS installer, a portable .exe and an
Arch package, with signature-verified background updates on Windows and a
release pipeline that builds from a tag. The artifacts are not yet
Authenticode-signed — that is wired and waiting on an Azure Trusted Signing
identity, and docs/distribution.md says so plainly rather than letting anyone
discover it at the SmartScreen dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:29:00 -07:00

184 lines
7.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build the two release manifests from a directory of artifacts.
scripts/release-manifest.py --version 0.1.0 --artifacts dist/release \
--base-url https://dl.reforgermods.net --out dist/release
Writes two files, because two different consumers need two different shapes and
conflating them is how a download page and an updater drift apart:
releases.json what the website's /launcher/ page reads. Every download it
should offer, with size and SHA-256 so a visitor can verify.
Consumed by public/static/launcher.js in reforgermods-web.
latest.json what tauri-plugin-updater polls. Only the platforms that have
an updater artifact and a signature, in Tauri's own schema.
Both are generated from the same scan of the same directory, so the version, the
URLs and the filenames cannot disagree between them.
Artifacts are recognised by suffix rather than listed, so a build that fails to
produce one is a manifest that is missing it — not a manifest with a dead link.
"""
import argparse
import hashlib
import json
import pathlib
import sys
from datetime import datetime, timezone
# Suffix -> (platform id understood by launcher.js, kind, human label).
# Order matters: the longest, most specific suffix has to be tested first, or
# "-setup.exe" is matched by the portable ".exe" rule.
ARTIFACT_RULES = [
(".nsis.zip", "windows", "updater", "Updater package"),
("-setup.exe", "windows", "installer", "Installer, Windows 10 or 11 64-bit"),
# An NSIS bundle is signed in place: the updater downloads the same
# -setup.exe a visitor does, so that file is both a download and the updater
# artifact. Any artifact with a sibling .sig is treated as one, below.
(".pkg.tar.zst", "arch", "pacman", "Arch Linux package"),
(".deb", "debian", "deb", "Debian / Ubuntu package"),
(".AppImage", "appimage", "appimage", "AppImage, no install"),
(".rpm", "fedora", "rpm", "Fedora package"),
(".exe", "windows", "portable", "Portable, no installer"),
]
# The updater target triple Tauri looks for in latest.json.
UPDATER_TARGETS = {"windows": "windows-x86_64"}
def classify(name):
for suffix, platform, kind, label in ARTIFACT_RULES:
if name.endswith(suffix):
return platform, kind, label
return None
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
parser.add_argument("--artifacts", required=True, type=pathlib.Path)
parser.add_argument("--base-url", required=True, help="e.g. https://dl.reforgermods.net")
parser.add_argument("--out", required=True, type=pathlib.Path)
parser.add_argument("--notes-url", default="https://reforgermods.net/launcher/changelog/")
parser.add_argument(
"--notes",
default="",
help="Release notes shown by the updater. Kept short: it is a dialog, not a changelog.",
)
parser.add_argument(
"--published-at",
default=None,
help="RFC3339 timestamp. Defaults to now, in UTC.",
)
args = parser.parse_args()
version = args.version.lstrip("v")
base = args.base_url.rstrip("/")
prefix = f"{base}/{version}"
published = args.published_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
if not args.artifacts.is_dir():
sys.exit(f"not a directory: {args.artifacts}")
downloads, updater_platforms, skipped = [], {}, []
# Signatures sit beside their artifact as <artifact>.sig; they are read, never
# listed as downloads.
for path in sorted(p for p in args.artifacts.iterdir() if p.is_file()):
if path.suffix == ".sig" or path.name in ("releases.json", "latest.json", "SHA256SUMS"):
continue
classified = classify(path.name)
if classified is None:
skipped.append(path.name)
continue
platform, kind, label = classified
url = f"{prefix}/{path.name}"
# A sibling .sig makes this the platform's updater artifact, whether or
# not it is also a download. An unsigned one is never offered: the client
# rejects it, so publishing it means every launch checks, fails, retries.
signature = path.with_name(path.name + ".sig")
target = UPDATER_TARGETS.get(platform)
if signature.is_file():
if target is None:
skipped.append(f"{path.name} (signed, but no updater target for {platform})")
elif target in updater_platforms:
skipped.append(f"{path.name} (a {target} updater artifact was already chosen)")
else:
updater_platforms[target] = {
"signature": signature.read_text(encoding="utf-8").strip(),
"url": url,
}
elif kind == "updater":
skipped.append(f"{path.name} (no {signature.name}; not offered to the updater)")
if kind == "updater":
# A dedicated updater package is not a human download.
continue
downloads.append(
{
"platform": platform,
"kind": kind,
"label": label,
"url": url,
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
# launcher.js offers the first entry whose platform matches as the primary
# download, so an installer has to come before a portable build of the same
# platform. Within a platform, installer first; otherwise by platform id for
# a stable file.
kind_order = {"installer": 0, "pacman": 0, "deb": 0, "rpm": 0, "appimage": 1, "portable": 2}
# Windows leads because that is where the players are; the rest follow in the
# order the download table should read.
platform_order = {"windows": 0, "debian": 1, "arch": 2, "fedora": 3, "appimage": 4}
downloads.sort(key=lambda d: (platform_order.get(d["platform"], 9), kind_order.get(d["kind"], 9)))
args.out.mkdir(parents=True, exist_ok=True)
releases = {
"version": version,
"publishedAt": published,
"notesUrl": args.notes_url,
"downloads": downloads,
}
(args.out / "releases.json").write_text(json.dumps(releases, indent=2) + "\n", encoding="utf-8")
latest = {
"version": version,
"notes": args.notes or f"reforgermods.net launcher {version}",
"pub_date": published,
"platforms": updater_platforms,
}
(args.out / "latest.json").write_text(json.dumps(latest, indent=2) + "\n", encoding="utf-8")
print(f"version {version}, published {published}")
for download in downloads:
print(f" download {download['platform']:9} {download['kind']:9} {download['size']:>10,} B {download['url']}")
for target, entry in updater_platforms.items():
print(f" updater {target} {entry['url']}")
for name in skipped:
print(f" skipped {name}")
if not downloads:
sys.exit("no downloadable artifacts were found: refusing to write an empty manifest")
if not updater_platforms:
# Not fatal: a Linux-only or unsigned build is a legitimate state. It is
# called out because it means the updater will find nothing.
print(" WARNING: latest.json has no platforms; the updater will never fire")
if __name__ == "__main__":
main()