media-updates/release.py

146 lines
5.7 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"
# How many builds of each kind stay in the repository. A device midway through
# a download has to be able to finish it, so the newest is never published
# alone; past that they are only weight, because nothing ever asks for an older
# build on purpose. Each one is ~34MB in git, again in the image nginx serves,
# and again in every clone of the repository.
KEEP_BUILDS = 3
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 prune_old(public: pathlib.Path, prefix: str, suffix: str,
keep: int) -> list[str]:
"""Delete all but the `keep` newest `<prefix><build><suffix>` artifacts.
The `latest` symlinks point at the build just staged, which is the highest
numbered one, so they survive this by construction.
"""
builds = {}
for path in public.glob(f"{prefix}*{suffix}"):
middle = path.name[len(prefix):len(path.name) - len(suffix)]
# Excludes the other kind: "media-*.apk" would otherwise match a
# "media-linux-*" name, whose middle is not a number.
if middle.isdigit():
builds[int(middle)] = path
dropped = []
for build in sorted(builds)[:-keep]:
dropped.append(builds[build].name)
builds[build].unlink()
return dropped
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}")
# After the new artifacts are in place, so the build just staged counts
# towards the window it is kept by. Each kind is counted separately: Linux
# is published rarely, and its symlink has to keep resolving through the
# releases that skip it.
dropped = (prune_old(public, "media-", ".apk", KEEP_BUILDS)
+ prune_old(public, "media-linux-", ".tar.gz", KEEP_BUILDS))
if dropped:
print(f"dropped {', '.join(dropped)}")
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()