media-updates/release.py
Claude 798013a957 Keep only the last three builds
The repository grew ~64MB a release and never gave any of it back: at build
14 that was 762MB in public/ and 590MB in .git, stored twice on the VPS. The
cost nobody had counted was the image — public/ is copied into it, so a deploy
built 1.3GB of nginx, and Coolify keeps the old ones for rollback. That is
what filled the server's root filesystem and failed a push with "no space
left on device".

Nothing ever asks for an older build on purpose, so release.py now deletes
all but the newest three of each kind once the new one is staged. Each kind is
counted on its own: Linux is published rarely, and latest-linux.tar.gz has to
keep resolving through the releases that skip it. Both symlinks point at the
build just staged, which is the highest numbered one, so they survive the
prune by construction and /manifest.json never named anything but the newest.

This trims the working tree to 200MB. It does not shrink .git, which still
holds all fourteen builds; pruning that rewrites history and is a separate
decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-31 01:59:29 +01:00

145 lines
5.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""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
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:
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}")
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}",
}
# ensure_ascii=False: the notes are Czech and get read by a person as
# often as by the app.
(public / "manifest.json").write_text(
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)
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".
packaging = HERE.parent / "amber-adult" / "linux" / "packaging"
with tarfile.open(public / tar_name, "w:gz") as tar:
tar.add(linux_bundle, arcname="amber-adult")
# 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")
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()