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:
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# BikeControl Arch Linux package builder.
|
||||||
|
#
|
||||||
|
# Separate from Dockerfile.builder on purpose: makepkg is Arch-specific, so the
|
||||||
|
# .pkg.tar.zst cannot be produced from the Ubuntu image that builds everything
|
||||||
|
# else. This is an *environment* image — it carries no source, so the same tag
|
||||||
|
# serves both CI (which checks the repo out at run time) and local use.
|
||||||
|
#
|
||||||
|
# Build and push:
|
||||||
|
# docker build -f Dockerfile.arch -t gitea.tourolle.paris/dtourolle/bikecontrol-arch-builder:latest .
|
||||||
|
# docker push gitea.tourolle.paris/dtourolle/bikecontrol-arch-builder:latest
|
||||||
|
#
|
||||||
|
# Local one-shot build of the package:
|
||||||
|
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/out" \
|
||||||
|
# gitea.tourolle.paris/dtourolle/bikecontrol-arch-builder:latest
|
||||||
|
FROM archlinux:latest
|
||||||
|
|
||||||
|
# base-devel brings makepkg, fakeroot and the rest of the packaging toolchain.
|
||||||
|
# The remainder are the PKGBUILD's makedepends plus what Tauri and btleplug link
|
||||||
|
# against: webkit2gtk-4.1/gtk3 for the shell, and dbus for BlueZ.
|
||||||
|
RUN pacman -Syu --noconfirm \
|
||||||
|
base-devel git sudo \
|
||||||
|
rust nodejs npm \
|
||||||
|
webkit2gtk-4.1 gtk3 libayatana-appindicator \
|
||||||
|
libsoup3 pkgconf openssl dbus bluez-libs \
|
||||||
|
&& pacman -Scc --noconfirm
|
||||||
|
|
||||||
|
# makepkg refuses to run as root. The passwordless sudo is for `makepkg -s`,
|
||||||
|
# which pacman-installs missing makedepends.
|
||||||
|
RUN useradd -m builder && \
|
||||||
|
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /out && chown builder:builder /out
|
||||||
|
VOLUME ["/out"]
|
||||||
|
|
||||||
|
# CI overrides this and runs the script itself (it must chown the checkout to
|
||||||
|
# `builder` first). The default is the local convenience path.
|
||||||
|
CMD ["bash", "-c", "chown -R builder:builder /app && sudo -u builder --preserve-env=OUTPUT_DIR OUTPUT_DIR=/out scripts/build-arch.sh --no-install"]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# BikeControl builder image.
|
||||||
|
#
|
||||||
|
# One image that can do every job CI asks of it:
|
||||||
|
# - `cargo test` / `clippy` on the host target, which means the *Linux BLE*
|
||||||
|
# stack must be present: btleplug talks to BlueZ over D-Bus, so libdbus is a
|
||||||
|
# hard build dependency, not an optional extra.
|
||||||
|
# - the Tauri desktop bundle (deb / AppImage), which needs the webkit2gtk set.
|
||||||
|
# - the Android APK: SDK, NDK, JDK, and the three Android Rust targets.
|
||||||
|
#
|
||||||
|
# Build and push:
|
||||||
|
# docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest .
|
||||||
|
# docker push gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
|
||||||
|
# or run scripts/build-builder-image.sh.
|
||||||
|
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
ANDROID_HOME=/opt/android-sdk \
|
||||||
|
ANDROID_SDK_ROOT=/opt/android-sdk \
|
||||||
|
NDK_VERSION=27.0.11902837 \
|
||||||
|
SDK_VERSION=36 \
|
||||||
|
BUILD_TOOLS_VERSION=35.0.0 \
|
||||||
|
RUST_BACKTRACE=1 \
|
||||||
|
CARGO_HOME=/root/.cargo \
|
||||||
|
PATH="/root/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
ca-certificates \
|
||||||
|
unzip \
|
||||||
|
jq \
|
||||||
|
file \
|
||||||
|
pkg-config \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
libssl-dev \
|
||||||
|
libclang-dev \
|
||||||
|
llvm-dev \
|
||||||
|
# BLE on the host target: btleplug's Linux backend is bluez-async over the
|
||||||
|
# `dbus` crate, which links against libdbus-1. Without this the workspace
|
||||||
|
# does not build at all — `crates/ble` is not optional.
|
||||||
|
libdbus-1-dev \
|
||||||
|
# Tauri v2 desktop (needed for `cargo test`/`clippy` on the host target and
|
||||||
|
# for the deb/AppImage bundle).
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
# AppImage bundling reaches for these at bundle time.
|
||||||
|
fuse3 \
|
||||||
|
desktop-file-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Node 20 for the Vite/Svelte frontend. The UI is plain npm (ui/package-lock.json
|
||||||
|
# is the lockfile CI installs from), so no bun here.
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||||
|
apt-get install -y --no-install-recommends nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Rust, with every target the release job builds for.
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && \
|
||||||
|
. "$CARGO_HOME/env" && \
|
||||||
|
rustup component add rustfmt clippy && \
|
||||||
|
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||||
|
|
||||||
|
# The Tauri CLI as a cargo subcommand. The frontend package.json deliberately
|
||||||
|
# does not carry @tauri-apps/cli — the shell is a cargo workspace member, so the
|
||||||
|
# CLI belongs to the toolchain, not to the UI's dependency tree.
|
||||||
|
RUN . "$CARGO_HOME/env" && cargo install tauri-cli --locked --version "^2"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Android SDK / NDK.
|
||||||
|
RUN mkdir -p "$ANDROID_HOME" /root/.android && \
|
||||||
|
printf '### User Sources for `android` cmd line tool ###\ncount=0\n' > /root/.android/repositories.cfg && \
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
||||||
|
unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME" && \
|
||||||
|
rm /tmp/cmdline-tools.zip && \
|
||||||
|
mkdir -p "$ANDROID_HOME/cmdline-tools/latest" && \
|
||||||
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools/"* "$ANDROID_HOME/cmdline-tools/latest/" && \
|
||||||
|
rmdir "$ANDROID_HOME/cmdline-tools/cmdline-tools"
|
||||||
|
|
||||||
|
# Licences up front so Gradle never blocks on a prompt in CI.
|
||||||
|
RUN yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" --licenses > /dev/null
|
||||||
|
|
||||||
|
RUN "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-$SDK_VERSION" \
|
||||||
|
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||||
|
"ndk;$NDK_VERSION" \
|
||||||
|
--channel=0 2>&1 | grep -v "Warning" || true
|
||||||
|
|
||||||
|
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION \
|
||||||
|
ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION \
|
||||||
|
ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/$NDK_VERSION \
|
||||||
|
PATH="/opt/android-sdk/platform-tools:/opt/android-sdk/cmdline-tools/latest/bin:$PATH"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/bash"]
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build (and optionally push) the CI builder images.
|
||||||
|
#
|
||||||
|
# scripts/build-builder-image.sh # build both, no push
|
||||||
|
# scripts/build-builder-image.sh --push # build both and push
|
||||||
|
# scripts/build-builder-image.sh --only arch # just the Arch image
|
||||||
|
# scripts/build-builder-image.sh --only main # just the Ubuntu image
|
||||||
|
#
|
||||||
|
# Two images, because the jobs genuinely need different distributions:
|
||||||
|
# main — Ubuntu: tests, Linux desktop bundle, Android APK (Dockerfile.builder)
|
||||||
|
# arch — Arch: the .pkg.tar.zst, which needs makepkg (Dockerfile.arch)
|
||||||
|
#
|
||||||
|
# The workflows in .gitea/workflows pin these by tag, so a change to either
|
||||||
|
# Dockerfile only reaches CI once this has run with --push.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-gitea.tourolle.paris}"
|
||||||
|
NAMESPACE="${NAMESPACE:-$REGISTRY/dtourolle}"
|
||||||
|
TAG="${TAG:-latest}"
|
||||||
|
|
||||||
|
PUSH=0
|
||||||
|
ONLY=both
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--push) PUSH=1 ;;
|
||||||
|
--only) shift; ONLY="${1:-}" ;;
|
||||||
|
-h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||||
|
*) echo "❌ Unknown option: $1 (try --help)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
build_one() {
|
||||||
|
local dockerfile="$1" image="$NAMESPACE/$2:$TAG"
|
||||||
|
echo "🐳 Building $image from $dockerfile"
|
||||||
|
docker build -f "$dockerfile" -t "$image" .
|
||||||
|
if [ "$PUSH" = 1 ]; then
|
||||||
|
echo "⬆️ Pushing $image"
|
||||||
|
docker push "$image"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ONLY" in
|
||||||
|
main) build_one Dockerfile.builder bikecontrol-builder ;;
|
||||||
|
arch) build_one Dockerfile.arch bikecontrol-arch-builder ;;
|
||||||
|
both)
|
||||||
|
build_one Dockerfile.builder bikecontrol-builder
|
||||||
|
build_one Dockerfile.arch bikecontrol-arch-builder
|
||||||
|
;;
|
||||||
|
*) echo "❌ --only takes main, arch or both" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ "$PUSH" = 1 ] || echo "ℹ️ Not pushed. Re-run with --push when you are happy with it."
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Pin an explicit, monotonic Android versionCode.
|
||||||
|
#
|
||||||
|
# `tauri android init` derives one from the semver in a way that is not
|
||||||
|
# monotonic across a version series: 0.1.0 and 0.0.10 can collide, and Android
|
||||||
|
# refuses to install an APK whose versionCode is not greater than the installed
|
||||||
|
# one. A rider who cannot update is a rider who stops updating.
|
||||||
|
#
|
||||||
|
# code = 1000 + major * 10000 + minor * 100 + patch
|
||||||
|
#
|
||||||
|
# so 0.1.0 -> 1100, 0.1.3 -> 1103, 0.2.0 -> 1200, 1.0.0 -> 11000. Monotonic in
|
||||||
|
# semver order for any minor/patch below 100, which is well past where this
|
||||||
|
# project will ever get.
|
||||||
|
#
|
||||||
|
# The 1000 floor is not decoration: `tauri android init` writes versionCode=1000
|
||||||
|
# for 0.1.0 today, so anyone carrying a locally-built APK already has that
|
||||||
|
# number installed. A bare major/minor/patch code would be 100 — a *downgrade*,
|
||||||
|
# which Android refuses outright.
|
||||||
|
#
|
||||||
|
# POSIX sh: the runner's /bin/sh is dash. No here-strings, no \s in sed.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PROPS="$ROOT/src-tauri/gen/android/app/tauri.properties"
|
||||||
|
CONF="$ROOT/src-tauri/tauri.conf.json"
|
||||||
|
|
||||||
|
[ -f "$PROPS" ] || { echo "❌ $PROPS not found — run 'cargo tauri android init' first" >&2; exit 1; }
|
||||||
|
|
||||||
|
VERSION=$(grep '"version"' "$CONF" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||||
|
# Guard a malformed or short version so we can never emit code 0, which Android
|
||||||
|
# treats as "older than everything".
|
||||||
|
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||||
|
CODE=$(( 1000 + MAJ * 10000 + MIN * 100 + PAT ))
|
||||||
|
|
||||||
|
echo "version=$VERSION -> versionCode=$CODE"
|
||||||
|
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||||
|
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||||
|
else
|
||||||
|
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
cat "$PROPS"
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# On a tag build, the tag is the single source of truth for the app version.
|
||||||
|
#
|
||||||
|
# Only src-tauri/tauri.conf.json is rewritten. That is what the Tauri bundler
|
||||||
|
# stamps onto the .deb/.AppImage and what `tauri android init` turns into the
|
||||||
|
# Android versionName, so it is the version a user ever sees. The workspace
|
||||||
|
# Cargo.toml is deliberately left alone: editing it would invalidate Cargo.lock
|
||||||
|
# and break every `--locked` build in the same run, to change a number that
|
||||||
|
# appears nowhere but the binary's own metadata.
|
||||||
|
#
|
||||||
|
# On a non-tag run this is a no-op, so it is safe to call unconditionally.
|
||||||
|
#
|
||||||
|
# POSIX sh: the runner's /bin/sh is dash.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONF="$(dirname "$0")/../src-tauri/tauri.conf.json"
|
||||||
|
|
||||||
|
case "${GITHUB_REF:-}" in
|
||||||
|
refs/tags/v*)
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
echo "Setting app version to $VERSION (from $GITHUB_REF)"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$CONF"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Not a tag build — keeping the version in tauri.conf.json"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
grep '"version"' "$CONF"
|
||||||
@@ -424,7 +424,7 @@ pub fn spawn_ride_loop(app: AppHandle) {
|
|||||||
.record(&frame.snapshot, altitude_m);
|
.record(&frame.snapshot, altitude_m);
|
||||||
}
|
}
|
||||||
n += 1;
|
n += 1;
|
||||||
if n % TICK_HZ == 0 {
|
if n.is_multiple_of(TICK_HZ) {
|
||||||
let s = &frame.snapshot;
|
let s = &frame.snapshot;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
elapsed_ms = s.elapsed_ms,
|
elapsed_ms = s.elapsed_ms,
|
||||||
|
|||||||
Reference in New Issue
Block a user