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>
145 lines
5.7 KiB
Python
Executable file
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()
|