CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
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.
225 lines
11 KiB
YAML
225 lines
11 KiB
YAML
name: Android
|
|
|
|
# The native Kotlin/Compose client over the shared Rust core (M12).
|
|
#
|
|
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
|
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
|
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
|
|
# uniffi.
|
|
#
|
|
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
|
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
|
# phone.
|
|
#
|
|
# The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an
|
|
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
|
# signed with different keys cannot replace one another, and bridging that gap
|
|
# means uninstalling first — which deletes the app's database and every local note
|
|
# with it (Scribe issue 2803).
|
|
|
|
on:
|
|
push:
|
|
branches: [dev, main]
|
|
paths:
|
|
- "android/**"
|
|
# The Rust the .so is built from. A core change reaches the phone exactly
|
|
# as it reaches the desktop, so this lane has to rebuild on it.
|
|
- "core/**"
|
|
- "Cargo.toml"
|
|
- "Cargo.lock"
|
|
- ".forgejo/workflows/android.yml"
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: android-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
|
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
|
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
|
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
|
|
|
jobs:
|
|
build:
|
|
name: Kotlin + Rust (APK)
|
|
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
|
# proven-working label that can pull our container images.
|
|
runs-on: flutter-ci
|
|
container:
|
|
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
|
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
|
|
|
defaults:
|
|
run:
|
|
working-directory: android
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Cache Gradle and Cargo
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: |
|
|
~/.gradle/caches
|
|
~/.gradle/wrapper
|
|
~/.kotlin
|
|
target
|
|
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
|
|
restore-keys: |
|
|
android-
|
|
|
|
# Everything downstream keys off this: the variant to build, the Cargo
|
|
# profile to build it with, and the version it carries. Decided once so no
|
|
# two Gradle invocations in this run can disagree and force a second
|
|
# four-minute cross-compile.
|
|
- name: Signing key, variant and version
|
|
id: build
|
|
env:
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
run: |
|
|
version="$(sh ../desktop/packaging/build-version.sh)"
|
|
echo "name=$version" >> $GITHUB_OUTPUT
|
|
# versionCode must RISE for Android to accept an update, and the run
|
|
# number is the same monotonic counter the desktop's version scheme
|
|
# already uses — no state carried between runs, and immune to the
|
|
# shallow checkout that makes a commit count useless here.
|
|
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
|
|
|
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
|
echo "variant=Release" >> $GITHUB_OUTPUT
|
|
echo "label=release" >> $GITHUB_OUTPUT
|
|
# DEBUG profile, in a release APK, deliberately — see the note above
|
|
# the cargoNdk task. The release profile strips the symbols uniffi
|
|
# reads its metadata out of, so `generateUniffiBindings` fails
|
|
# outright (run 4077). Unpicking that is worth doing and is not worth
|
|
# blocking signed builds on.
|
|
echo "profile=debug" >> $GITHUB_OUTPUT
|
|
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
|
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
|
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
|
else
|
|
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
|
echo "variant=Debug" >> $GITHUB_OUTPUT
|
|
echo "label=debug" >> $GITHUB_OUTPUT
|
|
echo "profile=debug" >> $GITHUB_OUTPUT
|
|
echo "keystore=" >> $GITHUB_OUTPUT
|
|
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Make gradlew executable
|
|
run: chmod +x ./gradlew
|
|
|
|
# Fails loudly here if the wrapper and the image's JDK disagree, rather
|
|
# than thirty seconds into a compile with an opaque version message.
|
|
- name: Gradle wrapper check
|
|
run: ./gradlew --version
|
|
|
|
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
|
|
# from the built .so. Run as its own step so a Rust failure is legible as a
|
|
# Rust failure instead of arriving inside a Gradle stack trace.
|
|
- name: Build the native library and bindings
|
|
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
|
|
|
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
|
# (M12 step 3) precisely so this lane needs no second image, and going
|
|
# through Gradle plugins would mean a second version of each tool resolved
|
|
# at build time and kept in lockstep with the image's by hand.
|
|
#
|
|
# Scoped to src/main: the generated uniffi bindings live under build/ and
|
|
# are not ours to style.
|
|
- name: ktlint
|
|
run: ktlint "app/src/main/**/*.kt"
|
|
|
|
- name: detekt
|
|
run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java
|
|
|
|
- name: Unit tests
|
|
# Host-JVM tests only. Anything touching the core needs an Android
|
|
# runtime to load the .so, so those are instrumented tests and belong on
|
|
# an emulator, not here — the Rust side is covered by the workspace
|
|
# tests in the desktop lane.
|
|
#
|
|
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
|
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
|
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
|
# type-check on the debug variant, which is the one an emulator build
|
|
# would use.
|
|
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
|
|
|
- name: Assemble the APK
|
|
env:
|
|
# Empty on the unsigned path, which build.gradle.kts reads as "no
|
|
# signing config" rather than as a path to a missing file.
|
|
ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }}
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
run: |
|
|
./gradlew assemble${{ steps.build.outputs.variant }} \
|
|
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
|
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
|
|
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
|
|
|
|
# Prints the certificate the APK was actually signed with, so the operator
|
|
# can compare it against the fingerprint recorded when the key was
|
|
# generated. Signing with the WRONG key produces a perfectly valid APK that
|
|
# simply refuses to install over the app already on the phone — a failure
|
|
# that otherwise only shows up on the device, after the run is green.
|
|
- name: Show the signing certificate
|
|
if: steps.build.outputs.keystore != ''
|
|
run: |
|
|
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
|
|
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
|
|
|
|
# Staged with a STABLE name plus the sidecar the server reads its version
|
|
# out of — an APK keeps that in a binary manifest Python cannot parse, and
|
|
# `aapt` is not on a Quart server. Computed here, where the real values are
|
|
# already known.
|
|
- name: Stage the client for distribution
|
|
if: steps.build.outputs.keystore != ''
|
|
run: |
|
|
mkdir -p dist
|
|
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
|
|
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
|
|
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
|
|
cat > dist/thoughtsync-android.json <<JSON
|
|
{
|
|
"version_name": "${{ steps.build.outputs.name }}",
|
|
"version_code": ${{ steps.build.outputs.code }},
|
|
"size": $size,
|
|
"sha256": "$sha"
|
|
}
|
|
JSON
|
|
cat dist/thoughtsync-android.json
|
|
|
|
# The rolling dev channel, same fixed-tag release the desktop bundles use.
|
|
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
|
|
# target; a release asset has a permanent URL. Only ever a SIGNED build —
|
|
# publishing an unsigned APK would offer people something they cannot
|
|
# install over what they already have.
|
|
- name: Publish to the dev channel
|
|
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
|
|
working-directory: .
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
RELEASE_TAG: dev
|
|
RELEASE_PRERELEASE: "true"
|
|
run: bash desktop/packaging/publish-release.sh
|
|
|
|
- name: Upload the APK
|
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
|
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
|
# it reports success while Gitea serves artifacts back only through the
|
|
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
|
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
|
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
|
with:
|
|
# The APK's variant, NOT the Cargo profile — those are the same word
|
|
# for different things and the profile is pinned to debug (#2810).
|
|
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
|
path: ${{ steps.build.outputs.apk }}
|
|
if-no-files-found: error
|