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>
323 lines
11 KiB
YAML
323 lines
11 KiB
YAML
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"
|