Files
thoughtsync/.forgejo/workflows/android.yml
T
bvandeusenandClaude Opus 5 c268ae4f23
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing
sufficient for a user to receive the build" — and here it was not. The
desktop and Android lanes BUILT on main and published nothing: `Publish
release` was gated on `refs/tags/v*`, the channel publishes on
`refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel
moved only when somebody cut a tag, which made a `v*` tag load-bearing
rather than the optional bookmark the model wants.

Both channels are rolling fixed-tag releases now. `dev` from dev, `stable`
from main, same machinery — `publish-release.sh` already took RELEASE_TAG,
`write-manifest.sh` already pruned, and both already PATCHed a stale
description on 409 (#2182). This is wiring, not new mechanism.

## The defect this carried

`ci.yml`'s "Fetch the Android client to bake in" read
`releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked
in the dev APK — `:latest` included — so a stable server served a
dev-channel client to anyone who downloaded it from there. That has nothing
to do with versioning; it is fixed here because this is the step that
finally gives `stable` an APK to point at.

It also means Android needs no channel machinery of its own. The APK is
served FROM the image, so the channel is already a property of which image
you run — note 3127 §7's "nothing to hand off" shape, arrived at here by
accident. One branch-conditional line, not a second channel in
`client_dist.py` as this milestone first assumed.

## The break this nearly shipped

`install.sh --channel stable` read the version out of `stable/latest.json`
and then fetched `releases/tags/v<version>` for the bundles — correct while
stable was a manifest-only pointer, and broken the moment stable holds its
own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for
everyone between this commit and the first merge to main.

Both channels are one lookup now: fetch the fixed-tag release, install what
is on it. A transitional fallback covers the window where `stable` still
has no bundles, marked for deletion in step 7 — without it the default
channel is broken for however long it takes to merge, and that window is
gated on an operator request rather than on this lane.

## The two writers problem

`stable`'s manifest was written by tag builds. It is written by main now,
and the tag path stops writing it — two writers for one channel is a race
with no winner worth having. A `v*` tag still writes its own versioned
manifest; its build consequence goes entirely in step 7.

Also corrected: `update.rs`'s header still described stable as following
`v*` tags. Nothing in that file moved — it only ever read
`<channel>/latest.json` — but the comment was a lie, and it is the file
somebody reads to understand the feed.

#3143

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:22:31 -04:00

268 lines
13 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
permissions:
contents: write
# For the dispatch at the end: this lane starts the server image build.
actions: write
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 channel for this branch, the same fixed-tag releases 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.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
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
# The server image bakes in whatever client the dev release holds, so it has
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
# push that touches the Android app (its `gate` job) and waits to be called
# from here — that is the other half of this.
#
# `always()`: a FAILED Android build must still let the server image through.
# There is no new client in that case, so it bakes in the previous one, which
# is exactly right — the alternative is a broken Android lane silently
# blocking server delivery.
#
# Not `if: success()` and not skipped on tags either: every ref that builds an
# image needs the call, or nothing builds one at all.
- name: Build the server image now the client is published
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
# Loud on failure rather than `|| true`: if this call stops working, the
# symptom is server images silently never being built for Android pushes,
# which is invisible until someone wonders why the app never updates.
curl -fsS -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches"
echo "Dispatched ci.yml on ${{ github.ref_name }}."