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 # Incremental state is never reused between CI runs -- pure disk cost. CARGO_INCREMENTAL: 0 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: # 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: 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: # 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: 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: 7 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: 7 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: # 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: 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 <> $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"