CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
`85ead4d` changed `packaging/version.sh` — the script that decides what every artifact claims to be — and the desktop and Android lanes did not run at all. Only CI & Build fired, and only because it happens to watch `tests/**`. So the fix in that commit is unverified on exactly the two lanes whose bug it was fixing. `version.sh` lists `packaging` in all three file sets; the workflows' `paths:` filters did not. Two places holding one decision, with one of them updated — the failure this subsystem keeps producing (#2181-2183, and again in step 3 where `install.sh` still expected stable's bundles on a versioned release). The script's own header already warned about this: "a change here that is not mirrored there means a lane that does not fire — check both." Written, then not followed, in the same commit. Step 6 removes the duplication for real by replacing these filters with skip-if-exists. This is the stopgap until then, and it says so at each site. #3144 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
14 KiB
YAML
279 lines
14 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
|
|
|
|
- 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 }}."
|