Build every artifact in a pinned image, not on my laptop

Two builder images, because the jobs genuinely need two distributions:
Ubuntu for the tests, the Linux bundle and the Android APK, and Arch for
the .pkg.tar.zst, since makepkg is Arch-specific. Both are built from
this repo (NFR-12) and pinned by tag in the workflows, so a Dockerfile
change only reaches CI once it has been pushed.

libdbus-1-dev is not incidental in the Ubuntu image: btleplug's Linux
backend is bluez-async over the dbus crate, so without it the workspace
does not build at all.

The per-commit Android job is a cargo check, not an APK. The full signed
build is ~15 minutes and runs only on tags; a one-minute check catches
what actually breaks — the JNI shim, droidplug, and any desktop-only API
that has crept into a shared crate.

Two invariants a compiler cannot see are checked there too, because both
fail silently: the app builds, installs, launches, and finds no trainer.
The JNI symbol in android.rs is matched by the runtime by name, so
renaming the Kotlin package compiles fine and simply never initialises
btleplug; and gen/ must stay untracked or the sync script quietly becomes
optional.

The Android versionCode carries a 1000 floor. `tauri android init` writes
1000 for 0.1.0 today, so anyone holding a locally built APK already has
that number installed, and a bare major/minor/patch code would be 100 —
a downgrade, which Android refuses outright.

The fmt check is advisory for now. The tree predates this workflow and
`cargo fmt --all` currently rewrites ~2000 lines across 28 files; making
it a gate here would mean landing a repo-wide reformat as a side effect
of adding CI. Run fmt in its own commit, then drop the continue-on-error.
The two clippy warnings that stood between the tree and a real
`-D warnings` gate are fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:52:45 +02:00
co-authored by Claude Opus 5
parent 0679a1f524
commit 06b4635470
9 changed files with 762 additions and 2 deletions
+167
View File
@@ -0,0 +1,167 @@
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
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:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: ui/node_modules
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:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
# 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 symbol
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; }
# The JNI symbol in android.rs is matched by the *runtime*, by name.
# Rename the Kotlin package or the method and nothing fails to
# compile — btleplug simply never gets initialised.
SYM=$(sed -n 's/.*pub extern "system" fn \(Java_[A-Za-z0-9_]*\).*/\1/p' src-tauri/src/android.rs)
PKG=$(sed -n 's/^package \(.*\)$/\1/p' "$KT" | tr -d '\r')
METHOD=$(sed -n 's/.*external fun \([A-Za-z0-9_]*\)().*/\1/p' "$KT")
EXPECTED="Java_$(echo "$PKG" | tr '.' '_')_MainActivity_$METHOD"
if [ "$SYM" != "$EXPECTED" ]; then
echo "❌ JNI symbol mismatch:"
echo " android.rs exports: $SYM"
echo " MainActivity needs: $EXPECTED"
exit 1
fi
echo "✅ JNI symbol $SYM matches $PKG.MainActivity.$METHOD"
- 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"
+322
View File
@@ -0,0 +1,322 @@
name: Build & Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g., v0.2.0)'
required: false
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
jobs:
test:
name: Run 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:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
- name: Install frontend dependencies
run: npm --prefix ui ci
- name: Run workspace tests
run: cargo test --workspace --locked
- name: Frontend tests
run: npm --prefix ui test
- name: Type-check the frontend
run: npm --prefix ui run check
build-linux:
name: Build Linux (deb + AppImage)
runs-on: linux/amd64
needs: test
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:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
- name: Install frontend dependencies
run: npm --prefix ui ci
- name: Set app version from tag
run: ./scripts/ci-set-version.sh
- name: Build the desktop bundle
working-directory: src-tauri
run: cargo tauri build
- name: Collect Linux artifacts
run: |
set -e
mkdir -p dist/linux
find target/release/bundle -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.rpm' \) \
-exec cp -v {} dist/linux/ \;
# A release that quietly ships nothing is worse than a failed build.
[ -n "$(ls -A dist/linux)" ] || { echo "❌ No Linux bundle produced"; exit 1; }
ls -lah dist/linux/
- name: Upload Linux build artifact
uses: actions/upload-artifact@v3
with:
name: bikecontrol-linux
path: dist/linux/
retention-days: 30
build-arch:
name: Build Arch package
runs-on: linux/amd64
needs: test
# Arch-specific image: makepkg does not exist on the Ubuntu builder, and
# the package must be built against Arch's own webkit2gtk/gtk3.
container:
image: gitea.tourolle.paris/dtourolle/bikecontrol-arch-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
# scripts/build-arch.sh derives the version from git describe.
fetch-depth: 0
- name: Build the package
run: |
set -e
# makepkg refuses to run as root, and the checkout is owned by the
# job's user, so hand the tree to the image's `builder` account.
chown -R builder:builder .
git config --global --add safe.directory "$PWD"
sudo -u builder git config --global --add safe.directory "$PWD"
sudo -u builder --preserve-env=OUTPUT_DIR \
env OUTPUT_DIR="$PWD/dist/arch" ./scripts/build-arch.sh --no-install
ls -lah dist/arch/
- name: Upload Arch build artifact
uses: actions/upload-artifact@v3
with:
name: bikecontrol-arch
path: dist/arch/
retention-days: 30
build-android:
name: Build Android APK
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:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
- name: Install frontend dependencies
run: npm --prefix ui ci
- name: Set app version from tag
run: ./scripts/ci-set-version.sh
- name: Initialise the Android project
working-directory: src-tauri
run: cargo tauri android init
- name: Pin a monotonic Android versionCode
run: ./scripts/ci-android-version-code.sh
# Manifest, MainActivity, gradle config, and btleplug's Java backend.
# `tauri android init` above regenerated gen/android and knows about none
# of it — without this step the APK builds and then finds no trainer.
- name: Sync custom Android sources
run: ./scripts/sync-android-sources.sh
# gen/ is not tracked, so a Kotlin file that exists only there is one git
# has never seen and the next init will delete. Catch it before it ships.
- name: Verify every hand-written Android source is tracked
run: ./scripts/check-android-sources.sh
- name: Write the signing keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/bikecontrol-release.jks"
cat > src-tauri/gen/android/keystore.properties <<EOF
storeFile=$RUNNER_TEMP/bikecontrol-release.jks
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: Build the signed APK
working-directory: src-tauri
run: cargo tauri android build --apk --target aarch64
- name: Collect and verify the APK
run: |
set -e
mkdir -p dist/android
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
[ -n "$APK" ] || { echo "❌ No release APK produced"; exit 1; }
cp "$APK" dist/android/bikecontrol-release.apk
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
echo "🔏 Verifying with $APKSIGNER"
"$APKSIGNER" verify --print-certs dist/android/bikecontrol-release.apk
ls -lah dist/android/
- name: Upload Android build artifact
uses: actions/upload-artifact@v3
with:
name: bikecontrol-android
path: dist/android/
retention-days: 30
create-release:
name: Create release
runs-on: linux/amd64
needs: [build-linux, build-arch, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version from tag
id: tag_name
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download Linux artifacts
uses: actions/download-artifact@v3
with:
name: bikecontrol-linux
path: artifacts/linux/
- name: Download Arch artifacts
uses: actions/download-artifact@v3
with:
name: bikecontrol-arch
path: artifacts/arch/
- name: Download Android artifacts
uses: actions/download-artifact@v3
with:
name: bikecontrol-android
path: artifacts/android/
- name: Prepare release notes
run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}"
cat > release_notes.md <<'EOF'
## Downloads
| Platform | File | Install |
|---|---|---|
| Linux (any) | `*.AppImage` | `chmod +x BikeControl*.AppImage && ./BikeControl*.AppImage` |
| Debian/Ubuntu | `*.deb` | `sudo dpkg -i bikecontrol*.deb` |
| Arch | `*.pkg.tar.zst` | `sudo pacman -U bikecontrol-*.pkg.tar.zst` |
| Android (arm64) | `bikecontrol-release.apk` | `adb install bikecontrol-release.apk`, or sideload |
## Requirements
**Linux** — BlueZ running (`bluetoothd`), and a Bluetooth adapter with BLE.
**Android** — 7.0 (API 24) or newer, and Bluetooth LE. The app asks for
the Bluetooth scan/connect permissions on first launch; on Android 11
and older it asks for location instead, which is what the platform
required for a BLE scan at the time.
A Zwift Click v2 must have been unlocked once in the free Zwift app —
see the README.
EOF
sed -i "1i # BikeControl $VERSION\n" release_notes.md
cat release_notes.md
- name: Publish Gitea release & upload assets
env:
# A PAT is preferred; falls back to the auto-provided token.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
VERSION="${{ steps.tag_name.outputs.VERSION }}"
API="${GITHUB_SERVER_URL}/api/v1"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "BikeControl $VERSION" \
--rawfile body release_notes.md \
--argjson pre "$PRE" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
echo "📦 Creating release $VERSION on $REPO"
# Capture the status rather than using -f, so an existing release
# (409) is handled instead of dropping the assets on the floor.
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP" = "201" ]; then
RELEASE_ID=$(jq -r '.id' resp.json)
elif [ "$HTTP" = "409" ]; then
echo "️ Release $VERSION already exists; fetching its id to upload assets"
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
-H "Authorization: token $TOKEN" | jq -r '.id')
else
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
fi
for f in artifacts/*/*; do
[ -f "$f" ] || continue
echo "⬆️ Uploading $(basename "$f")"
curl -fsS -X POST \
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$f" >/dev/null
done
echo "✅ Release $VERSION published with assets"