media-updates/release.py

110 lines
4.1 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
2026-08-30 16:09:54 +00:00
"""Publish a release: copy the artifacts in, write the manifest, commit.
Reads the version and build number out of amber-adult's pubspec so the two
cannot drift a manifest advertising a build number the APK does not carry is
an update the device installs and is then offered again on every launch.
"""
import hashlib
import json
import pathlib
import re
import shutil
import subprocess
import sys
2026-08-30 16:09:54 +00:00
import tarfile
HERE = pathlib.Path(__file__).resolve().parent
PUBSPEC = HERE.parent / "amber-adult" / "pubspec.yaml"
BASE_URL = "https://media-updates.petruzalekr.cz"
def pubspec_version() -> tuple[str, int]:
m = re.search(r"^version:\s*([0-9.]+)\+([0-9]+)\s*$",
PUBSPEC.read_text(encoding="utf-8"), re.M)
if not m:
sys.exit(f"no version line in {PUBSPEC}")
return m.group(1), int(m.group(2))
def main() -> None:
2026-08-30 16:09:54 +00:00
args = sys.argv[1:]
# Linux is optional and off by default. It is another ~34MB in the
# repository per release, and the desktop build has no self-updater to feed
# — it exists so somebody can check a source without walking to the
# television, and is published when that is worth doing.
linux_bundle = None
if "--linux" in args:
i = args.index("--linux")
linux_bundle = pathlib.Path(args[i + 1]).resolve()
del args[i:i + 2]
if not args:
sys.exit("usage: release.py <apk> [notes] [--linux <bundle-dir>]")
apk = pathlib.Path(args[0]).resolve()
notes = args[1] if len(args) > 1 else ""
if not apk.is_file():
sys.exit(f"no such APK: {apk}")
2026-08-30 16:09:54 +00:00
if linux_bundle is not None and not linux_bundle.is_dir():
sys.exit(f"no such bundle directory: {linux_bundle}")
version, build = pubspec_version()
public = HERE / "public"
public.mkdir(exist_ok=True)
name = f"media-{build}.apk"
dest = public / name
shutil.copy2(apk, dest)
digest = hashlib.sha256(dest.read_bytes()).hexdigest()
manifest = {
"version": version,
"buildNumber": build,
"notes": notes,
"sha256": digest,
"size": dest.stat().st_size,
"url": f"{BASE_URL}/{name}",
}
2026-08-30 01:53:55 +00:00
# ensure_ascii=False: the notes are Czech and get read by a person as
# often as by the app.
(public / "manifest.json").write_text(
2026-08-30 01:53:55 +00:00
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
# A stable name, so "where do I download it" has one permanent answer
# instead of one that has to be looked up in the manifest each time. A
# symlink rather than a copy: git stores it as a path, so it costs bytes
# rather than another 34MB per release.
latest = public / "latest.apk"
latest.unlink(missing_ok=True)
latest.symlink_to(name)
2026-08-30 16:09:54 +00:00
if linux_bundle is not None:
tar_name = f"media-linux-{build}.tar.gz"
# Renamed on the way in, so unpacking gives a directory somebody can
# read rather than "bundle".
2026-09-06 15:13:31 +00:00
packaging = HERE.parent / "amber-adult" / "linux" / "packaging"
2026-08-30 16:09:54 +00:00
with tarfile.open(public / tar_name, "w:gz") as tar:
tar.add(linux_bundle, arcname="amber-adult")
2026-09-06 15:13:31 +00:00
# The desktop entry, its icons and the installer ride along, so the
# tarball can put itself in the launcher. Without them the app can
# only be started by path, and on Wayland it has no icon at all —
# the compositor resolves that from an installed .desktop, never
# from the running process.
if packaging.is_dir():
tar.add(packaging, arcname="amber-adult/packaging")
2026-08-30 16:09:54 +00:00
tar_latest = public / "latest-linux.tar.gz"
tar_latest.unlink(missing_ok=True)
tar_latest.symlink_to(tar_name)
print(f"staged {tar_name}")
subprocess.run(["git", "add", "-A"], cwd=HERE, check=True)
subprocess.run(
["git", "commit", "-m", f"Release {version}+{build}"],
cwd=HERE, check=True)
print(f"staged {name} ({dest.stat().st_size // 1024 // 1024} MB), "
f"version {version}+{build}")
print("push to deploy")
if __name__ == "__main__":
main()