CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 45s
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118): Practice 3, CI fails on the wrong signer. The signing step printed the certificate and went on. It now fails unless the APK has exactly one signer and that signer is the release certificate (SHA-256 408a5835…, pinned from run 8753). The steps that publish come after it in the same job, so a wrongly signed build is never staged or published. Practice 6, app downloads are throttled and carry a sha256 ETag. - The download route counts per account and answers 429 with Retry-After past the limit. The limit is a new Settings → Security value, "App downloads per account per hour" (default 30), live like the sign-in limits. - The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a phone resuming a download across a redeploy is not told its partial copy is stale. Quart's own ETag and conditional handling are off, and the route runs the conditional pass after setting the ETag, so Range and If-Range are judged against the content. Practice 9, the update offer and debug builds. - The install-permission notice re-reads the grant each time the app comes back, as ReminderNotice does. Read once, it stayed up after someone granted the permission in Settings and came back. - A debuggable build says it can't update itself and checks for nothing. Android would refuse the release-signed APK over a debug signature anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
18 KiB
YAML
362 lines
18 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 `inkwell-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 server-image job 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
|
|
|
|
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
|
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
|
# there cannot stop this lane publishing (rule 177, #5237).
|
|
#
|
|
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
|
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
|
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
|
|
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
|
|
rust:
|
|
name: Core and FFI clippy and tests
|
|
needs: [decide]
|
|
if: needs.decide.outputs.build == 'true'
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- name: Clippy
|
|
run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings
|
|
|
|
- name: Test
|
|
run: cargo test --locked -p inkwell-core -p inkwell-ffi
|
|
|
|
build:
|
|
name: Kotlin + Rust (APK)
|
|
needs: [decide, rust]
|
|
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
|
|
|
|
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/inkwell-release.jks
|
|
echo "variant=Release" >> $GITHUB_OUTPUT
|
|
echo "label=release" >> $GITHUB_OUTPUT
|
|
# An optimised .so for the APK people install. The cargoNdk task keeps
|
|
# the symbols uniffi reads its metadata from, which the release
|
|
# profile would strip (run 4077, Scribe #2810).
|
|
echo "profile=release" >> $GITHUB_OUTPUT
|
|
echo "keystore=/tmp/inkwell-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 -PINKWELL_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 the `rust` job, which this
|
|
# one needs.
|
|
#
|
|
# 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 -PINKWELL_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 }} \
|
|
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
|
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
|
|
-PINKWELL_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.
|
|
#
|
|
# And FAILS unless there is exactly one signer and it is the release key
|
|
# (family idea #5103, practice 3). Printing alone was not a check: a build
|
|
# signed with any other key would have gone on to be staged and published,
|
|
# and every phone would have refused it. The steps that publish come after
|
|
# this one in the same job, so a failure here stops them.
|
|
- name: Check the signing certificate
|
|
if: steps.build.outputs.keystore != ''
|
|
env:
|
|
# The release certificate's SHA-256, as apksigner printed it for the
|
|
# signed APK in run 8753 (CN=Bryan Van Deusen, O=fabledsword). Every
|
|
# installed copy carries this certificate. It only changes if the key
|
|
# does, and then every install has to be replaced by hand anyway.
|
|
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
|
|
run: |
|
|
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
|
|
certs="$("$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk")"
|
|
printf '%s\n' "$certs"
|
|
signers="$(printf '%s\n' "$certs" | grep -c '^Signer #[0-9]* certificate SHA-256 digest:' || true)"
|
|
digest="$(printf '%s\n' "$certs" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
|
|
if [ "$signers" != 1 ] || [ "$digest" != "$RELEASE_CERT_SHA256" ]; then
|
|
echo "::error::The APK is not signed by the release key alone ($signers signer(s), first $digest; expected $RELEASE_CERT_SHA256)."
|
|
exit 1
|
|
fi
|
|
echo "Signed by the release key."
|
|
|
|
# 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/inkwell.apk
|
|
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
|
|
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
|
|
cat > dist/inkwell-android.json <<JSON
|
|
{
|
|
"version_name": "${{ steps.build.outputs.name }}",
|
|
"version_code": ${{ steps.build.outputs.code }},
|
|
"size": $size,
|
|
"sha256": "$sha"
|
|
}
|
|
JSON
|
|
cat dist/inkwell-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. They are the same word for
|
|
# different things, and an unsigned debug APK still differs from a
|
|
# signed release one in more than its Rust.
|
|
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
|
path: ${{ steps.build.outputs.apk }}
|
|
if-no-files-found: error
|
|
|
|
server-image:
|
|
name: Build the server image
|
|
needs: [decide, rust, build]
|
|
if: always() && needs.decide.outputs.build == 'true' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
|
runs-on: flutter-ci
|
|
container:
|
|
# The image the dispatch has always run in, so its curl is a known quantity.
|
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
|
permissions:
|
|
actions: write
|
|
steps:
|
|
# 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 — or failed core checks, which skip the
|
|
# build job entirely — 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. Its own job for that reason: a step inside `build` never runs
|
|
# when `build` is skipped.
|
|
#
|
|
# 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
|
|
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 }}."
|