CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
The rolling dev release lived on a tag named `dev`, beside the branch named `dev`. Once a clone had fetched tags, `git push origin dev` failed with "src refspec dev matches more than one" (Scribe #2184, note #3042), and every session had to know to spell out refs/heads/dev. The channel is still `dev` everywhere a person sees it: the app's setting, `install.sh --channel dev`, the stored pref. Only the release tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no branch to collide with and keeps its name. - packaging/channel-tag.sh is the one channel -> tag mapping CI reads: the publish steps in android.yml and desktop.yml, the manifest job, fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an unmapped channel instead of fetching an empty URL and passing. - update.rs and install.sh carry their own copy because neither can run it; update.rs gains a test that no channel feed is named like a branch. - tests/test_channel_tag.py runs the script: no tag is a branch name, dev is exactly dev-rolling, an unknown channel fails with no output. - publish-release.sh titles the release "ThoughtSync dev (rolling)", so the tag name does not leak into what people read. TEMPORARY bridge: desktop apps installed before this have .../download/dev/latest.json compiled in. The dev manifest job sets BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to the old `dev` release. Its URLs name dev-rolling assets, so those apps update once into a build that reads the new tag. The bridge, and the old release and tag, are removed once installed apps have crossed over. Until then the push still needs the explicit refspec, as ci-requirements.md now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
310 lines
15 KiB
YAML
310 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:
|
|
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
|
# See desktop.yml for why, and 85ead4d for what the duplication cost.
|
|
branches: [dev, main]
|
|
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:
|
|
# Does the APK need rebuilding, or is the channel already serving this source?
|
|
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
|
|
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
|
#
|
|
# The guard runs here so it covers the skip path too (§6.3).
|
|
#
|
|
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
|
|
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
|
# happens — and that is correct, because ci.yml's `gate` stands down only when the
|
|
# push touched Android's files, which is the same condition that makes this build.
|
|
# The two decisions agree because they read the same fact; they are still two
|
|
# readers of it, which is why the gate's grep carries a comment pointing here.
|
|
decide:
|
|
name: Build, or is the channel already serving this?
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
outputs:
|
|
build: ${{ steps.d.outputs.build }}
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Decide
|
|
id: d
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
case "$GITHUB_REF_NAME" in
|
|
main) channel=stable ;;
|
|
*) channel=dev ;;
|
|
esac
|
|
sh packaging/guard-forward.sh android "$channel"
|
|
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
|
|
|
build:
|
|
name: Kotlin + Rust (APK)
|
|
needs: [decide]
|
|
if: needs.decide.outputs.build == 'true'
|
|
# 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) channel=stable; RELEASE_PRERELEASE=false ;;
|
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
|
esac
|
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
|
export RELEASE_TAG RELEASE_PRERELEASE
|
|
echo "Publishing the APK to the $RELEASE_TAG channel."
|
|
bash desktop/packaging/publish-release.sh
|
|
|
|
- name: Upload the APK
|
|
# Stock action: it works on this forge since the runner moved to
|
|
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
|
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
|
# success while Gitea serves artifacts back only through the v4 API, so the
|
|
# upload is stored and invisible (Scribe 2270).
|
|
uses: actions/upload-artifact@v7
|
|
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 }}."
|