Commit Graph
7 Commits
Author SHA1 Message Date
Bryan Van Deusen 8a75e5f340 clients: an unquoted 1.0.3504551 is not JSON, and every sidecar was one
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m18s
`fetch-clients.sh` wrote `"version_code": %s` unquoted, which was right when the
only ordering key in sight was Android's integer. The desktop's is Tauri's
`1.0.<minutes>`, and unquoted that is not valid JSON at all — so `json.loads`
raised on all four generated sidecars and the server advertised nothing. A silent
zero, not an error: `_read` treats a malformed sidecar as "no client here", which
is right for a corrupt drop-in and indistinguishable from this.

Caught by running the real fetch against the live dev channel and feeding the
result to the real resolver, rather than by reading the printf.

Also makes `_resolve` wrap BOTH candidate roots in Path(). Only the first was, and
the asymmetry fails the same quiet way: a str `/` str raises TypeError, `_read`
catches it, and a perfectly good directory reads as empty.

The whole pipeline now resolves end to end against the live channel — five of five
platforms, every sidecar valid JSON, one human-readable version across all of them
with each artifact keeping its own comparator type:

  android         2026.08.30.1711  code=3504552        57.6 MB
  linux-appimage  2026.08.30.1711  code='1.0.3504551'  95.3 MB  signed
  linux-deb       2026.08.30.1711  code='1.0.3504551'   3.3 MB
  linux-pacman    2026.08.30.1711  code='1.0.3504551'   2.7 MB
  windows         2026.08.30.1711  code='1.0.3504551'   2.6 MB
2026-08-30 13:21:03 -04:00
Bryan Van Deusen d2f9d316cf tests: 300 comes back as "300" from a platform whose key is not an integer
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 41s
The precedence test wrote `version_code=300` for all five platforms and compared
the desktop's against the int it wrote. It comes back as `"300"`, because the
module preserves each platform's own comparator type instead of flattening both
to int — which is the behaviour the change it was testing had just introduced.

A `coded()` helper now says which shape to expect and why, and the assertion runs
over every non-Android platform rather than spot-checking `linux-deb`. The test
caught a real inconsistency in itself precisely because it compared against a
concrete value rather than round-tripping what it wrote.
2026-08-30 13:19:18 -04:00
Bryan Van Deusen ff6e99eb62 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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
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.
2026-08-30 13:11:04 -04:00
Bryan Van Deusen ef8aa9340f clients: the server hands out five platforms, not "the Android client"
Android / Kotlin + Rust (APK) (push) Skipped
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 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.

Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.

Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.

THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.

PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.

The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.

The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
2026-08-30 12:52:40 -04:00
Bryan Van Deusen f992439588 version: every surface can say which build it is, and two of them were lying
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
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.

The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.

Two of the values it was going to show were wrong, which is the part worth
knowing about.

The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).

The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.

Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.

Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.

build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
2026-08-29 23:07:29 -04:00
Bryan Van Deusen 544cf72735 install: the stable fallback is dead now that stable publishes its own bundles
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It existed for one window: `stable` was a manifest-only pointer at whatever `v*`
tag had last been cut, and `stable` is the DEFAULT channel, so without the
fallback `curl … | sh` was broken for everyone between step 3 landing and the
first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds
its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving
by the one lookup both channels share.

Kept as a fallback it stops being a safety net and becomes a mask: the branch
only runs when `stable` has no bundles, which from here on means something is
broken, and chasing a `v*` release instead of saying so is the wrong answer.

The header now says the transition is finished and that neither channel should
be special-cased again, because the shape of that code invites re-adding it.
2026-08-29 16:59:44 -04:00
Bryan Van Deusen 6e524ec616 guard: an empty channel killed the lane instead of passing it
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.

`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.

What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.

The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
2026-08-29 13:45:26 -04:00