server: bake the newest Android client into every image (operator call)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 47s

Reverses the placement decision made an hour ago. That one put the APK only on
the data volume, reasoning that ~55 MiB should not be charged to installs that
never touch Android. The operator's call is that ending the manual copy is worth
the megabytes, and it is their deployment.

CI now fetches the newest published client into the build context immediately
before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a
`docker compose pull` delivers a new server and a new client together.

**Always the rolling `dev` release — the newest build there is.** A versioned
image therefore carries the newest client rather than one pinned to that
version. Deliberate: the two negotiate a sync protocol version before linking, so
a mismatch is caught by the handshake, and pinning would buy nothing the
handshake does not already provide.

**Fetched by the JOB, never by the Dockerfile.** The release is private, and a
token used inside a build ends up in the context or a layer.

**It cannot fail the image build.** No release yet, a network blip, a first-ever
build — all of them log a warning and produce an image with no client, which is a
state the server already supports. Half a pair is cleaned up rather than shipped:
a sidecar without its APK is worse than neither, because the server would be
describing something it cannot serve.

**The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy
second, so an operator who deliberately drops a build in gets that build — and a
BROKEN drop-in falls through to the image's copy rather than taking the feature
offline, which is what makes the copy-order advice survivable instead of
load-bearing. Three tests cover the precedence, including that last case.

The baked copy lives inside the package, not under DATA_DIR: that path is a
volume mount, and anything the image wrote there would disappear behind it the
moment one is attached.

`client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI
step never ran; the artifacts themselves are gitignored, since a 55 MiB binary
does not belong in git history and is re-fetched on every build anyway.
This commit is contained in:
2026-08-20 21:19:47 -04:00
parent 43ebb6eceb
commit 010e9a2f85
7 changed files with 198 additions and 38 deletions
+47 -13
View File
@@ -13,10 +13,19 @@ the client cannot hand out a phone it is unable to talk to.
## Where the file comes from
`DATA_DIR/client/` — the same volume that already holds attachments, so an
operator drops a build there once and container recreation does not lose it.
Deliberately NOT baked into the image: that would charge ~55 MiB to every
self-hoster, including everyone who never touches Android.
Two places, checked in that order:
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
operator who wants a SPECIFIC build drops it there and it wins.
2. the copy baked into the image at build time — CI fetches the newest published
Android build into every image, so `:dev`, `:latest` and `:<version>` all
carry a client and `docker compose pull` delivers a new one with nothing
copied by hand.
The precedence is the point: the image is the default, and a person who wants to
override it should not have to fight it. The baked copy sits inside the package
rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the
image wrote there would be hidden the moment one is attached.
Two files, and both must be present:
@@ -51,21 +60,23 @@ MANIFEST_NAME = "thoughtsync-android.json"
DOWNLOAD_PATH = "/api/client/android/download"
APK_MIMETYPE = "application/vnd.android.package-archive"
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
# is a volume mount, and a file the image wrote there would vanish behind it.
BAKED_ROOT = Path(__file__).resolve().parent / "client"
bp = Blueprint("client_dist", __name__)
def android_release() -> dict | None:
"""What Android build this server holds, or None if it holds none.
def _read(root: Path) -> dict | None:
"""The build in one directory, or None.
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
sidecar that describes a different file are all the same answer to the only
question being asked — "is there a client here I can honestly offer?" — and
that answer is no.
"""
root = Path(Config.client_root())
apk = root / APK_NAME
try:
size = apk.stat().st_size
size = (root / APK_NAME).stat().st_size
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
version = str(meta["version_name"])
code = int(meta["version_code"])
@@ -94,6 +105,27 @@ def android_release() -> dict | None:
}
def _resolve() -> tuple[Path, dict] | None:
"""Which directory this server serves from, and what is in it.
The operator's drop-in beats the baked copy — someone who deliberately put a
build on the volume wants that build, not whatever the image happened to ship
with. A directory holding a broken or half-copied pair does NOT shadow the
image: it simply is not a client, so the search moves on.
"""
for root in (Path(Config.client_root()), BAKED_ROOT):
release = _read(root)
if release is not None:
return root, release
return None
def android_release() -> dict | None:
"""What Android build this server holds, or None if it holds none."""
resolved = _resolve()
return resolved[1] if resolved else None
def advertisement() -> dict:
"""The `/api/config` fragment describing this server's Android client.
@@ -124,11 +156,13 @@ async def android_download():
above is public because a client has to be able to ask "is there something
newer?" cheaply, but the bytes are not for anyone who can reach the port.
"""
if android_release() is None:
resolved = _resolve()
if resolved is None:
return jsonify({"error": "this server has no Android client"}), 404
response = await send_from_directory(
Path(Config.client_root()), APK_NAME, mimetype=APK_MIMETYPE
)
# From the SAME directory the advertisement came from, or a drop-in appearing
# between the two calls would serve bytes the metadata does not describe.
root, _ = resolved
response = await send_from_directory(root, APK_NAME, mimetype=APK_MIMETYPE)
# Without this some browsers try to render it, and Android's download handler
# wants a filename to hand to the package installer.
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'