The Android job died on `tauri.properties not found`. It is not found because `cargo tauri android init` does not write it: in a clean clone init leaves gen/android/app/ holding build.gradle.kts, proguard-rules.pro and src/, nothing else. The generated app/.gitignore names the pattern — tauri.properties sits with tauri.build.gradle.kts and proguard-tauri.pro, the tauri.* files that `android build` stamps out on every run. So the step was wrong twice and the crash was the lucky half. Had the file existed, the build would have rewritten it from tauri.conf.json and discarded the sed — a green build shipping the default versionCode, which is the failure that reaches a rider's phone rather than the log. Tauri exposes the actual input, so use it: - tauri.conf.json gains bundle.android.versionCode, committed rather than conjured by CI, so the key is greppable and the sed has a fixed target. - ci-android-version-code.sh edits the config. Formula and the 1000 floor are unchanged; the header comment is rewritten, since its premise (init writes the file, the default collides) does not hold — Tauri's default is major*1000000 + minor*1000 + patch, monotonic, and it puts 0.1.0 at exactly 1000, which is where the floor comes from. Missing key or failed substitution now exits 1 instead of degrading to a silent no-op. - The step moves ahead of `android init`, next to ci-set-version.sh, since both edit the same config. Verified against a clean clone: 0.1.0 -> 1100, v0.2.3 -> 1203, v1.0.0 -> 11000, config still parses at each step. Then a real `cargo tauri android build`, which wrote versionCode=1100 into tauri.properties — the value reaches the APK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
YAML
362 lines
14 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
|
||
# 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
|
||
|
||
# Before init, and before the build: this edits tauri.conf.json, which is
|
||
# the input both of them read. `gen/android/app/tauri.properties` is an
|
||
# output of `android build` — it does not exist yet, and anything written
|
||
# there would be overwritten by the build anyway.
|
||
- name: Pin a monotonic Android versionCode
|
||
run: ./scripts/ci-android-version-code.sh
|
||
|
||
- name: Initialise the Android project
|
||
working-directory: src-tauri
|
||
run: cargo tauri android init
|
||
|
||
# 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: 7
|
||
|
||
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"
|