Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 23s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m31s
Android / Build the server image (push) Successful in 1s
#6225 "Comment standard sweep: root, config, CI workflows, docs and packaging", milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)". - README restructured in newcomer order: what it is, quick start, configuration, setup pitfalls, updating and backups, features, troubleshooting, development, licence. Its quick-start compose carries no restart policy. - Dockerfile and .env.example trimmed to one or two lines per setting. - Workflows, packaging scripts and docs lose issue, run, note, rule and milestone references and their history narration. Comments that still described path filters and "step 6" as future work now state what the workflows do. - ci-requirements.md rewritten against the current workflows. It said the build gated on typecheck and lint only, the integration lane did not gate, a v* tag published :latest, and ci-tauri lacked bsdtar and zstd; none of those is true now (the last checked against run 9630's log). - Echo strings in guard-forward.sh and version.sh drop their note citations; every string a test asserts on is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
16 KiB
YAML
352 lines
16 KiB
YAML
name: Android
|
|
|
|
# The native Kotlin/Compose client over the shared Rust core. 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.
|
|
|
|
on:
|
|
push:
|
|
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
|
# See desktop.yml for why.
|
|
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.
|
|
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, the same one file set in
|
|
# `packaging/version.sh`.
|
|
#
|
|
# The guard runs here so it covers the skip path too.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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, so `openssl-sys` fails
|
|
# 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. flutter-ci is the
|
|
# proven-working label that can pull our container images.
|
|
runs-on: flutter-ci
|
|
container:
|
|
# 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.
|
|
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. 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.
|
|
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
|
|
# 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. 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.
|
|
# Printing alone is not a check: a build signed with any other key would go on
|
|
# to be staged and published, and every phone would refuse 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 prints it for a signed
|
|
# APK (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.
|
|
#
|
|
# Publishing `stable` from main 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`.
|
|
- 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` would shadow 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 because gitea/runner 3.x edits
|
|
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
|
# @v3 — it reports success while Gitea serves artifacts back only through the
|
|
# v4 API, so the upload is stored and invisible.
|
|
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: 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.
|
|
#
|
|
# No `always()`: a red core check or APK build means no image for this
|
|
# commit. Building regardless would publish a `:<sha>` holding a client that
|
|
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
|
|
#
|
|
# Not skipped on tags: every ref that builds an image needs the call, since
|
|
# ci.yml stands down on these pushes and nothing else builds one.
|
|
- 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 }}."
|