name: '🚴 Build and Test BikeControl' on: push: branches: - master paths-ignore: - '**/*.md' pull_request: branches: - master paths-ignore: - '**/*.md' workflow_dispatch: env: RUST_BACKTRACE: 1 CARGO_TERM_COLOR: always # Incremental state is never reused between CI runs -- pure disk cost. CARGO_INCREMENTAL: 0 jobs: test: name: Workspace tests runs-on: linux/amd64 container: image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Cache Rust dependencies uses: actions/cache@v3 with: # Registry only -- never `target`. That directory is ~30 GB locally # (25G debug, 3.1G android, 2.9G release) and was cached under two # keys, on a runner that JellyTau's builds share -- the same leak that # filled its disk in August 2026. registry/src is left out too: cargo # re-extracts it for free from registry/cache (155 MB of .crate # tarballs vs 1.1 GB extracted). path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db # One shared key across every job. The host/android split existed to # stop the two `target` dirs clobbering each other; with target no # longer cached, registry contents are target-independent and every # job wants the same crates. First job to finish saves; the rest # restore. key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-registry- - name: Cache npm downloads uses: actions/cache@v3 with: # npm's download cache, not ui/node_modules: `npm ci` deletes # node_modules before it installs, so the old cache was restored and # then immediately thrown away. ~/.npm is what actually makes the # reinstall fast. path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('ui/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - name: Install frontend dependencies run: npm --prefix ui ci # Advisory, not a gate. The tree predates this workflow and `cargo fmt # --all` currently rewrites ~2000 lines across 28 files; making that a # blocking check would mean landing a repo-wide reformat as a side effect # of adding CI. Run `cargo fmt --all` once, in its own commit, then drop # the `continue-on-error` below and this becomes a real gate. - name: Check formatting (advisory) run: cargo fmt --all --check continue-on-error: true - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings - name: Run workspace tests run: cargo test --workspace --locked - name: Type-check the frontend run: npm --prefix ui run check - name: Frontend tests run: npm --prefix ui test - name: Build the frontend run: npm --prefix ui run build # Per-commit Android compile check. # # This deliberately does NOT build an APK. The full signed build runs only on # tags (build-release.yml) and takes ~15 min; `cargo check` for the Android # target is ~1 min and catches everything that actually breaks here — the JNI # shim in src-tauri/src/android.rs, btleplug's droidplug backend, and any # desktop-only API that has crept into a shared crate (NFR-5). android-check: name: Android compile check runs-on: linux/amd64 needs: test container: image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Cache Rust dependencies uses: actions/cache@v3 with: # Registry only -- never `target`. That directory is ~30 GB locally # (25G debug, 3.1G android, 2.9G release) and was cached under two # keys, on a runner that JellyTau's builds share -- the same leak that # filled its disk in August 2026. registry/src is left out too: cargo # re-extracts it for free from registry/cache (155 MB of .crate # tarballs vs 1.1 GB extracted). path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db # One shared key across every job. The host/android split existed to # stop the two `target` dirs clobbering each other; with target no # longer cached, registry contents are target-independent and every # job wants the same crates. First job to finish saves; the rest # restore. key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-registry- # Cheap invariants that a compiler cannot see. Both failures are silent: # the app builds, installs, launches, and then finds no trainer. - name: Check the Android source layout and the JNI symbols run: | set -e # gen/ is generated and must stay untracked, or a `tauri android init` # turns into a confusing diff and the sync script becomes optional. if git ls-files --error-unmatch src-tauri/gen >/dev/null 2>&1; then echo "❌ src-tauri/gen is tracked. It is generated — untrack it and" echo " keep hand-written sources in src-tauri/android/." exit 1 fi KT=src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt [ -f "$KT" ] || { echo "❌ $KT is missing"; exit 1; } # JNI symbols are matched by the *runtime*, by name. Rename the # Kotlin package, add a native without its Rust half, or drop a Rust # export, and nothing fails to compile — the method simply is not # there when Java calls it. So compare the two sets, both ways. # # Sets, not a single pair: there are three natives now, and matching # `external fun name()` on empty parens missed the two that take # arguments. Parameters do not appear in the symbol name, so stop at # the opening paren. TMP="${RUNNER_TEMP:-/tmp}" PKG=$(sed -n 's/^package \(.*\)$/\1/p' "$KT" | tr -d '\r') PREFIX="Java_$(echo "$PKG" | tr '.' '_')_MainActivity_" sed -n 's/.*pub extern "system" fn \(Java_[A-Za-z0-9_]*\).*/\1/p' \ src-tauri/src/android.rs | sort -u > "$TMP/jni-rust" sed -n "s/.*external fun \([A-Za-z0-9_]*\)(.*/$PREFIX\1/p" \ "$KT" | sort -u > "$TMP/jni-kotlin" [ -s "$TMP/jni-rust" ] || { echo "❌ No JNI exports found in android.rs"; exit 1; } if ! diff -u "$TMP/jni-kotlin" "$TMP/jni-rust" > "$TMP/jni-diff"; then echo "❌ JNI symbols differ between MainActivity.kt and android.rs:" echo " - declared in MainActivity.kt, not exported by android.rs" echo " + exported by android.rs, not declared in MainActivity.kt" tail -n +4 "$TMP/jni-diff" exit 1 fi echo "✅ $(wc -l < "$TMP/jni-rust") JNI symbols match $PKG.MainActivity:" sed 's/^/ /' "$TMP/jni-rust" - name: Cargo check (aarch64-linux-android) run: | TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang" export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang" export AR_aarch64_linux_android="$TC/llvm-ar" cargo check -p bikecontrol-app --lib --target aarch64-linux-android --locked # btleplug's Android backend is half Java. That half is copied out of the # crate sources at the version Cargo.lock pins, so a bump to btleplug that # moved or renamed those sources must fail here — loudly, in a one-minute # job — rather than in a fifteen-minute release build, or at the first # scan on a phone. - name: Verify the BLE Java backend can be sourced run: | set -e cargo fetch --target aarch64-linux-android for crate in btleplug jni-utils; do VER=$(awk -v pkg="name = \"$crate\"" \ '$0 == pkg { f = 1; next } f && /^version = / { gsub(/[",]/, "", $3); print $3; exit }' Cargo.lock) DIR=$(ls -d "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/"$crate-$VER" 2>/dev/null | head -1) [ -n "$DIR" ] || { echo "❌ $crate $VER sources not in the registry"; exit 1; } echo "✅ $crate $VER at $DIR" done test -d "$(ls -d "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/btleplug-*/src/droidplug/java/src/main/java/com | head -1)" echo "✅ droidplug Java sources present"