Somewhere for the app to look for a newer version of itself

Two static files behind nginx and a script that writes them. No
authentication: there is no account to check against, and the APK carries
no secret — the credential in that app is the addon URL, which lives in
the device keystore.

The APKs are committed rather than stored elsewhere. At ~34MB and a few
releases a year that beats standing up artifact storage for one app, and
the history is disposable because only the newest artifact is ever
fetched.

release.py reads the version out of amber-adult's pubspec instead of
taking it as an argument. A manifest advertising a build number the APK
does not carry is an update the device installs and is then offered
again, every launch, forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard 2026-08-30 03:53:38 +02:00
commit 2fc0981195
5 changed files with 142 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
.git
release.py
README.md

4
Dockerfile Normal file
View file

@ -0,0 +1,4 @@
# Static files, nothing more: a manifest and the APK it points at.
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# media-updates
What the `amber-adult` app checks at launch to find a newer build of itself.
Two files behind nginx: `manifest.json`, and the APK it names. No
authentication, deliberately — there is no account to check against and the
APK holds no secret. The credential in that app is the addon URL, which lives
in the device keystore and never leaves it.
## Publishing a release
```
./release.py ../amber-adult/build/app/outputs/flutter-apk/app-release.apk "what changed"
```
It reads the version and build number out of `amber-adult/pubspec.yaml`, copies
the APK in under a versioned name, writes `manifest.json`, and commits. Push,
and Coolify redeploys.
**The APKs are committed.** At ~34MB and a handful of releases a year that is
the cheaper trade against standing up artifact storage for one app; if the
repository ever gets uncomfortable, the history is disposable — only the
newest artifact is ever fetched.
## The manifest
```json
{
"version": "0.1.0",
"buildNumber": 1,
"notes": "…",
"sha256": "…64 hex…",
"size": 34210000,
"url": "https://media-updates.petruzalekr.cz/media-1.apk"
}
```
The client refuses anything it cannot act on: an equal or older `buildNumber`,
a missing `url`, or a `sha256` that is not 64 characters. The download is
checked against that digest before it reaches the installer.

29
nginx.conf Normal file
View file

@ -0,0 +1,29 @@
server {
listen 80;
root /usr/share/nginx/html;
# Nginx's bundled mime.types does not name the APK type on every build, and
# a wrong Content-Type is enough for a download manager to save an HTML
# error page under the right filename. Say it explicitly.
types {
application/json json;
application/vnd.android.package-archive apk;
}
default_type application/octet-stream;
# The manifest changes every release; the APKs never do, because a new
# release is a new filename. Cache accordingly.
location = /manifest.json {
add_header Cache-Control "no-cache";
}
location ~ \.apk$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Nothing here is a secret, but nothing here is a directory listing either.
autoindex off;
location / {
try_files $uri =404;
}
}

66
release.py Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Publish an APK: copy it 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
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:
if len(sys.argv) < 2:
sys.exit("usage: release.py <apk> [notes]")
apk = pathlib.Path(sys.argv[1]).resolve()
notes = sys.argv[2] if len(sys.argv) > 2 else ""
if not apk.is_file():
sys.exit(f"no such APK: {apk}")
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}",
}
(public / "manifest.json").write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
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()