CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 17s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a number and trusts it; this compares the derived value against what the channel is actually serving and fails the lane if it went down. Too-low is the unrecoverable direction: every installed client reports "up to date" forever, and no later build fixes it until one climbs back above the bad number. #2183 and #2993 are both that symptom. ## Two hazards, two mechanisms A shallow clone is now tested DIRECTLY, in `version.sh`, via `--is-shallow-repository`. The empty-result guard only caught the case where nothing matched — and run 4796 showed the worse one, where a partial match returned a real six-days-stale answer. Asking the question outright costs no network and covers artifacts with nothing published to compare against. `guard-forward.sh` handles the rest: a squash or rebase merge rewriting the committer date, a rebuild of an older commit, and clock skew between runners. ## The comparison is per artifact, and the operator differs desktop derived >= published commit time, so equality is the ORDINARY no-change case and `<=` would fail every build that changed nothing android derived > published build time, so equality means two builds in one minute — and Android refuses to install an APK whose versionCode does not RISE The server is deliberately unguarded: nothing compares its version, `:latest` moves regardless, and rule 145 removed the version tags that would be the published list. A too-low value there is a wrong date in a footer, not a stranded client. It still gets the shallow-clone check. ## Proved to fire, not assumed Cloned the repo, checked out a commit eight back, ran the guard against the LIVE dev feed: at the tip derived 1.0.3502151, published 1.0.3502151 -> pass eight back derived 1.0.3501535, published 1.0.3502151 -> FAILS android tip derived 3502171, published 3502152 -> pass stable derived 1.0.3502151, published 0.2.0 -> pass That last row is worth keeping: stable still advertises the bare `0.2.0` from the old Cargo.toml scheme, so the transition orders upward on BOTH channels, not just the one being exercised. A channel with nothing published passes rather than failing — otherwise the first publish to a new channel could never happen. The guard runs BEFORE the build in all three lanes, so a bad derivation costs seconds rather than a five-minute compile and a publish to undo. `compare` is exposed as an explicit mode so the ordering is testable without a network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`, which a string compare gets exactly backwards. #3145 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
15 KiB
YAML
299 lines
15 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"
|
|
# The version deriver — see the note in desktop.yml. This lane did not run on
|
|
# 85ead4d, which changed it.
|
|
- "packaging/**"
|
|
- ".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
|
|
with:
|
|
# Derives a version, so it needs the whole history — see the note in
|
|
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
|
fetch-depth: 0
|
|
|
|
- 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: |
|
|
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
|
# same code?", so it comes from the COMMIT and a dev build and the main
|
|
# build of one commit read identically. The CODE answers "may this be
|
|
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
|
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
|
|
# leaves a channel you cannot get out of — so it comes from BUILD time,
|
|
# which cannot go backwards. Commit time can.
|
|
version="$(sh ../packaging/version.sh display android)"
|
|
code="$(sh ../packaging/version.sh key android)"
|
|
echo "name=$version" >> $GITHUB_OUTPUT
|
|
echo "code=$code" >> $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 $code)"
|
|
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
|
|
|
|
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
|
|
# lane derives a number and trusts it; this compares the derived value against
|
|
# what the channel is actually serving, and fails the lane if it went DOWN.
|
|
#
|
|
# Placed before the build, not after: a bad derivation should cost seconds, not
|
|
# a five-minute compile and a publish that has to be undone. Too-low is the
|
|
# unrecoverable direction — every installed client reports "up to date" forever
|
|
# and no later build fixes it until one climbs back above the bad number
|
|
# (#2183, #2993).
|
|
- name: Guard — the version must not go backwards
|
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
|
env:
|
|
GITHUB_TOKEN: ${ github.token }
|
|
run: |
|
|
case "$GITHUB_REF_NAME" in
|
|
main) channel=stable ;;
|
|
*) channel=dev ;;
|
|
esac
|
|
sh ../packaging/guard-forward.sh android "$channel"
|
|
|
|
- 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 }}."
|