image: bake every client in, not just the phone
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s

~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product
being complete costs (rule 23): a self-hoster gets a working app for their machine
from the server holding their notes, with no account on a forge that is private.
The AppImage is not optional within that — it is the only bundle that can replace
itself in place, so a server without one cannot serve in-app updates to anybody.

`packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names
and sidecars `client_dist.py` reads. It never fails: a platform with nothing
published means the server advertises nothing for it and the UI hides that
download, and eight fetches must not become eight ways to redden a green lane.

THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been
wrong the easy way. The obvious shortcut is `version.sh display desktop` in the
image job — it has the checkout. But this commit may not be the commit the channel
is serving: a push touching only `src/` does not rebuild the desktop, so the
channel still holds an older build and a locally-derived version would describe
those bytes with this commit's number. `client_dist.py`'s size check could not
catch it, because size IS measured from the real file — it would sail through and
lie about the version alone. So `write-manifest.sh` now publishes
`thoughtsync-desktop.json` beside `latest.json`, from the same two values in the
same breath, and only size/sha256 are measured at bake time.

Which needed the prune's keep-list, or the sidecar would have been uploaded and
deleted again in the same run — a fixed name is self-limiting, which is exactly
why that list exists.

`version_code` is NOT uniformly an integer, and coercing it was a leftover from
the days when Android was the only platform. Android's must stay a JSON number:
`ClientRelease` in core declares it `i64` and a string fails to deserialize on
every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` —
the value its updater actually compares — and `int()` would have rejected every
desktop sidecar CI writes. The table now says which is which, and tests pin both
directions.

Also retires the comment above the fetch step, which claimed the APK came from
"always the rolling dev release" and mentioned `:<version>` images. M314 step 3
made the channel conditional in the code directly below it, and step 6 removed
version-shaped image tags entirely.

Verified against the live dev channel before pushing: the Android half resolves
and exits 0, the desktop half degrades with a warning because the sidecar does not
exist yet, and all five constructed bundle filenames return 200.
This commit is contained in:
Bryan Van Deusen
2026-08-30 13:11:04 -04:00
parent ef8aa9340f
commit ff6e99eb62
7 changed files with 311 additions and 65 deletions
+39 -3
View File
@@ -34,6 +34,18 @@ PAYLOAD = b"not really a client, but the server only ever stats it"
# four would ship untested.
ALL_IDS = [p.id for p in PLATFORMS]
# `version_code` is "whatever this platform's comparator reads", and that is not one
# type. Android's install gate compares an integer; the desktop's updater compares
# Tauri's semver key. The tests carry both shapes for the same reason the module
# does — a suite that only ever wrote integers would pass while every desktop
# sidecar CI writes was being rejected.
ANDROID_CODE = 3503708
DESKTOP_CODE = "1.0.3503707"
def code_for(platform_id: str):
return ANDROID_CODE if BY_ID[platform_id].code_is_int else DESKTOP_CODE
@pytest.fixture(autouse=True)
def _empty_baked_client(tmp_path, monkeypatch):
@@ -71,7 +83,7 @@ def place(
(root / platform.artifact).write_bytes(payload)
meta = {
"version_name": "2026.08.30.0307",
"version_code": 3503708,
"version_code": code_for(platform_id),
"size": len(payload),
"sha256": "ab" * 32,
}
@@ -164,6 +176,30 @@ def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id):
assert release(platform_id) is None
def test_androids_code_must_be_an_integer():
"""`ClientRelease` in core/src/sync/client.rs declares it `i64`. A string here
would fail to deserialize on every phone in the field, so a sidecar carrying one
is not a client this server can honestly offer."""
place("android", version_code="1.0.3503707")
assert release("android") is None
def test_the_desktop_keeps_tauris_semver_key_verbatim():
"""It is not an integer and must not be coerced into one: this is the value the
desktop updater compares, and `1.0.3503707` truncated to `1` orders against
nothing."""
place("linux-deb")
assert release("linux-deb")["version_code"] == "1.0.3503707"
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_an_empty_version_name_counts_as_no_client(platform_id):
"""A sidecar can be well-formed and still say nothing. A blank would render as
an empty space on the download card, which reads as a layout bug."""
place(platform_id, version_name="")
assert release(platform_id) is None
def test_an_unknown_platform_is_not_a_client():
assert release("blackberry") is None
@@ -178,7 +214,7 @@ def test_a_present_client_reports_what_a_comparator_reads(platform_id):
assert found["version"] == "2026.08.30.0307"
# The integer is what decides "is this newer", not the name — a name is a string
# and sorts like one.
assert found["version_code"] == 3503708
assert found["version_code"] == code_for(platform_id)
assert found["size"] == len(PAYLOAD)
assert found["platform"] == platform_id
# A PATH, not an absolute URL: the client joins it to the base it is already
@@ -317,7 +353,7 @@ async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, pl
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}")
assert resp.status_code == 200
assert (await resp.get_json())["version_code"] == 3503708
assert (await resp.get_json())["version_code"] == code_for(platform_id)
@pytest.mark.parametrize("platform_id", ALL_IDS)