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:
commit
2fc0981195
5 changed files with 142 additions and 0 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.git
|
||||
release.py
|
||||
README.md
|
||||
4
Dockerfile
Normal file
4
Dockerfile
Normal 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
40
README.md
Normal 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
29
nginx.conf
Normal 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
66
release.py
Executable 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()
|
||||
Loading…
Reference in a new issue