The same leak we just closed on JellyTau, fed harder from here. `target/` is 30 GB locally (25G debug, 3.1G android, 2.9G release) and was cached under two keys across five jobs, on the runner both repos share. - Cache registry/index, registry/cache and git/db only. registry/src is left out as well: 155 MB of .crate tarballs beats 1.1 GB extracted, and cargo re-extracts it for free. Verified that `cargo fetch` unpacks, so sync-android-sources.sh — which reads btleplug's Java backend out of registry/src before any build has run — still finds its sources. - Collapse cargo-host and cargo-android into one cargo-registry key. The split only existed to keep the two `target` dirs off each other; with target uncached, registry contents are target-independent. This also ends a silent miss: build-release's `test` and `build-linux` jobs shared cargo-host, so the test job claimed the key and build-linux's cache was never saved. - CARGO_INCREMENTAL=0. Never reused between runs, and the bulk of the 25 GB debug dir. - npm: cache ~/.npm instead of ui/node_modules. `npm ci` deletes node_modules before installing, so that entry was restored and thrown away unread. - Installer artifact retention 30d -> 7d; the tagged release carries them. Rust jobs now compile cold every run. sccache with a hard size cap is the way back if that starts to hurt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
358 lines
14 KiB
YAML
358 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
|
||
|
||
- 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: 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"
|