33e927897540526ab7611dff089bcbe08a380969
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0cf77336d4 |
ci: build the server image after the Android lane, not alongside it
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 8s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Baking the newest client into every image left two holes, both raised by the operator. **An Android-only push never rebuilt the image.** `ci.yml` does not trigger on `android/**`, so a new APK could be published and no image would ever pick it up until some unrelated server change came along. **A push touching both raced.** Both workflows start at once; the image build would fetch the PREVIOUS client and there would be no second build to correct it — `:<sha>` is the immutable rollback unit (rule 46), so rebuilding it with different content would make it neither immutable nor a rollback unit. Ordering now runs the other way: the Android lane finishes, then calls the image build. `ci.yml` gains a `gate` job that stands down on any push touching the Android app, and `android.yml` dispatches `ci.yml` when it is done. One image per commit, containing the client from that commit. Cases: - **server only** — ci builds immediately; the newest published client is already the right one. - **Android only** — ci does not trigger at all; the Android lane dispatches it afterwards. - **both** — ci's push run stands down, the Android lane dispatches it. Exactly one image. - **tag** — always builds. The Android lane does not run on tags, so waiting for a call that never comes would mean a release tag with no image. The dispatch is `always()`, so a FAILED Android build still lets the server image through with the previous client. The alternative is a broken Android lane silently blocking server delivery, which is a worse failure than a slightly old APK. Two details that would each have made this quietly wrong: The gate diffs the whole PUSHED RANGE (`event.before..HEAD`, full fetch), not `HEAD^..HEAD`. A three-commit push whose Android change sat in the first would otherwise have looked Android-free and raced anyway — silently, which is the worst version of this bug. The dispatch is `curl -fsS`, not `|| true`. If that call ever stops working the symptom is server images silently never being built for Android pushes, which nobody would notice until wondering why the app stopped updating. The gate's path list has to match android.yml's trigger, and two places holding one decision is the recurring failure in this repo (issues 2181-2183). It is a `git diff` rather than a config precisely so the decision is visible in the log, and both sides carry a comment pointing at the other. |
||
|
|
d77a79859c |
server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
|
||
|
|
6589be2b0f |
android: unit tests are a debug-only task, and the artifact name says variant
Android / Kotlin + Rust (APK) (push) Successful in 6m52s
Run 4082: `Task 'testReleaseUnitTest' not found`. AGP creates unit-test tasks only for `testBuildType`, which is debug — so pairing the test task with the packaged variant was wrong from the start. It was only paired to stop two Gradle invocations asking for different Cargo profiles and paying the four-minute cross-compile twice. With the profile pinned to debug (#2810) that reason is gone, so the step goes back to `testDebugUnitTest` unconditionally. Costs one extra Kotlin compile and buys the type-check on the variant an emulator build would actually use. Also: the artifact was named from the Cargo profile, which is now always "debug" — so a signed release APK would have been uploaded as `thoughtsync-android-debug-<sha>`. Same word, two different things. It is named from the APK's variant now, and the two outputs are kept separate so they cannot be confused again. |
||
|
|
cae9888eb9 |
android: build the release APK with a debug-profile .so, for now
`d0a9c73` switched the lane to a release Cargo profile alongside the release variant. The variant was the point; the profile was mine, and it broke the build (run 4077): `generateUniffiBindings` fails with "No UniFFI metadata found" on the release `.so`. The workspace release profile sets `strip = true`, and uniffi's `--library` mode finds its interface metadata through symbols. That is the obvious suspect and it is recorded as a suspect, not a finding — `lto = true` dropping the metadata statics would print the identical message and the two have not been told apart. Backed out to the debug profile rather than guessing at a fix, because the two halves of that commit are not equally important. Signing and a rising versionCode are what make an install replace the last one instead of wiping the notes; the Rust profile only makes the result faster. The APK this produces is no worse than every previous build, all of which shipped a debug-profile `.so`. Recorded as Scribe #2810 with the four candidate fixes and, more usefully, the instruction to establish the cause on a host build before spending another four-minute cold cross-compile on a guess. |
||
|
|
d0a9c73bf9 |
android: sign the release build, and give it a version that rises
Two separate reasons updates were impossible, both fixed here. **Every CI build was signed with a different key** (issue #2803, measured with `apksigner --print-certs` across two runs). No signing config meant AGP's debug keystore, which AGP GENERATES when absent — and every job starts from a fresh container. So no build could ever be installed over another: the only way through was uninstall-then-install, which deletes the app's database and every local note with it. **versionCode was hardcoded to 1.** `build.gradle.kts` has read a `THOUGHTSYNC_VERSION_CODE` property since the skeleton landed; nothing ever passed it. Even with signing fixed, every APK would have claimed to be the same version and nothing could tell a newer one existed. It now comes from `GITHUB_RUN_NUMBER` — the same monotonic counter the desktop's version scheme already uses, needing no state between runs and immune to the shallow checkout that makes a commit count useless here. The version NAME comes from the desktop's `build-version.sh`, so both surfaces report one product version rather than two that can disagree. **The alias is hardcoded, not a secret.** It is fixed for the life of the app and already written into the certificate every install carries; hiding it would buy nothing and stop this file describing its own signing. Two secrets, not three — and PKCS12 cannot hold a key password distinct from the store password anyway, so `keyPassword` is the same value by necessity rather than by shortcut. **The lane now builds RELEASE when it can sign, debug when it cannot.** That is not cosmetic. A debug APK is `debuggable`, which on a phone holding personal notes and a device sync token means anyone with adb can read both. Which meant confronting something the release path would have shipped quietly: `cargoNdkDebug` was hardcoded to the debug Cargo profile and every variant took its `.so` from it, so `assembleRelease` would have packaged an UNOPTIMISED store and sync engine. Now one `cargoNdk` task takes its profile from a property, and the whole run uses one profile. A debug/release task pair would have been the tidier shape and would have made a run that both type-checks and packages pay the four-minute cross-compile twice — this runner has no working Gradle or Cargo cache, so that cost is real on every push. The run prints the signing certificate after assembling, so the fingerprint can be compared against the one recorded at generation. Signing with the wrong key produces a perfectly valid APK that simply refuses to install — a failure that otherwise surfaces on the device, long after the run is green. `.gitignore` learns `*.jks`, `*.keystore`, `*.p12`, `*.b64` first, so generating a keystore anywhere near this tree cannot go wrong. Also corrects the record: the comment this replaces cited "Scribe task 2136" as though it were a standing rule. It is not one — none of the 46 always-on rules mentions signing keys. 2136 is a desktop-updater task whose REASONING got repeated until it sounded like policy. The reasoning holds, and holds harder on Android where a key cannot be rotated without the original, so the practice is unchanged; the citation is now honest about what it is. |
||
|
|
f179928c57 |
android: run ktlint and detekt from the image, not as Gradle plugins
First Android run failed at plugin resolution: Plugin [id: 'io.gitlab.arturbosch.detekt', version: '2.0.0-alpha.3'] was not found in any of the following sources That version is published to neither Maven Central nor the plugin portal — the latest detekt anywhere is 1.23.8. It was copied from Minstrel's catalog, where it presumably resolves from a cached artifact; copying a pin without checking it exists is what made it my problem. Rather than chase a working plugin version, the analyzers now run from the CLIs ci-rust-android already ships. That was the point of putting them in the image in step 3, and going through Gradle plugins would have meant a SECOND pinned version of each tool, resolved at build time, kept in lockstep with the image's by hand. One less resolution step, and step 3's decision finally earns its keep. Also replaces the source-ordering hack while here. Kotlin has to compile after the bindings are generated, and the usual `tasks.withType<KotlinCompile>` cannot be written in this build at all — AGP 9's built-in Kotlin means that class is not on the buildscript classpath. Passing the TASK PROVIDERS to srcDir instead lets Gradle read their @OutputDirectory and infer the ordering itself, which is the idiomatic form and removes the dependsOn entirely. Good news from the failed run: the Gradle wrapper check passed, so Gradle 9.1.0 on the image's JDK 25 works — the toolchain decision from step 3 holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
20907abf6e |
android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
The skeleton, and the lane that builds it. Gradle invokes cargo-ndk to
cross-compile thoughtsync-ffi for four ABIs, generates the Kotlin bindings from
the resulting .so, and packages both.
BUILT ON MINSTREL'S TOOLCHAIN, not a fresh guess. Gradle 9.1.0 / AGP 9.0.1 /
Kotlin 2.3.21 on JDK 25 is the combination already proven in this family on
ci-android, including the JDK 22+ native-access opt-in the launcher JVM needs
and the artifact-upload action pinned by SHA (issues 2255 / 2270). It also
independently confirms the JDK 25 call made on ci-rust-android in step 3.
Gradle wiring worth noting:
* ExecOperations, not project.exec — the latter was REMOVED in Gradle 9, and
touching `project` at execution time is also what breaks the configuration
cache this build enables.
* The cargo task's inputs are the Rust SOURCES, not the workspace directory.
Declaring the directory would make Gradle hash target/, which is gigabytes.
* Bindings are generated with `--library` against the built .so, so they can
never describe a different version of the Rust than the one being packaged.
* cargo runs --locked, so an Android build cannot silently re-resolve the
lockfile the desktop lanes are gated on.
JNA is a real dependency, with the @aar classifier. The plain jar builds fine
and fails at runtime with UnsatisfiedLinkError, which is the worst way to learn
it. R8 keep rules for JNA and the bindings are in for the same reason — that
failure would otherwise appear only in a minified release.
The UI is a working board, not a debug screen: capture field, note list, empty
state, error banner, and an honest failure screen for a store that won't open.
Rules 23/24 — a surface ships at quality from the first commit. Capture uses the
IME action key because the north star is a thought captured in under a second,
and leaves the title empty so the core derives it from the first body line.
Every core call runs on Dispatchers.IO: they are blocking FFI into synchronous
SQLite, and running them on the main thread is exactly the jank going native was
meant to avoid.
The launcher icon reuses frontend/public/icon-maskable-512.png as an adaptive
foreground on the brand #F5C518 — the same asset and colour the web app already
ships, so the three surfaces wear one face.
No signing config. A release keystore that has passed through an agent session
or shell history is compromised by construction (task 2136); it has to be
generated by the operator and reach CI only as a secret. CI builds debug.
CI can only prove this BUILDS — a Linux runner cannot execute an APK, so feel
and on-device correctness remain an operator pass on an emulator.
Scribe #2739.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0a7480cf9b |
core: extract the store and sync engine into a shared crate (M12 step 1)
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.
This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.
The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.
Two things a workspace changes that are easy to miss, both caught before pushing:
[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.
And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.
Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
40cb463be7 |
android: build the x86_64 ABI too, so an emulator can run it (task 1864)
Android (Tauri) / Android APK (debug) (push) Successful in 3m55s
arm64 is every real device, but a desktop emulator is x86_64 — an arm64-only APK installs there and then dies unable to load its native library. A build nobody can try on an emulator is a build nobody checks, which defeats the point of producing an artifact at all while there is no phone in the loop. armv7 and i686 stay out: 32-bit hardware we do not target, and the image carries all four targets if that ever changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1f140c7457 |
android: scaffold the Tauri mobile lane and build a debug APK in CI
The phone client is Tauri v2 mobile (operator decision), so it reuses the Vue frontend and the Rust store and sync engine that already exist rather than becoming a third implementation to keep in step by hand. gen/android is committed. tauri android init generated it, its own .gitignore already excludes the build outputs and every keystore file, and CI must not have to regenerate a project that manifest edits will accumulate in. What the scaffold confirms is that the image's JDK pin was load-bearing rather than incidental: Tauri templated Gradle 8.14.3 with AGP 8.11.0, and CI-android's versions.env records that JDK 25 needs Gradle 9.1.0+ and that anything older fails with an opaque "25.0.3" message. Picking 17 for ci-tauri-android avoided exactly that. namespace and applicationId came out as com.fabledsword.thoughtsync, matching the desktop identifier, so the app-data story stays consistent. The lane builds a DEBUG APK for arm64 only. Release APKs need signing, and the keystore has to be generated by the operator and never pass through CI logs or an agent session — the constraint recorded for the updater key applies unchanged. Gradle's throwaway debug keystore needs nothing from anyone, so this can prove the app compiles and packages today and grow a signed job when a key exists. arm64 is every real device; the image carries the other three ABIs, so widening is a word. Triggered by frontend/** as well as desktop/**, because generate_context! compiles the frontend into the app — the same reasoning that widened desktop.yml. Android, desktop and web are peers on one quality bar, and a frontend commit that skipped this lane would ship a stale phone build. Green here will mean it BUILT. A Linux runner cannot execute an APK, so nothing in this lane proves the app runs, renders, or is usable by finger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |