Files
BikeControl/.gitea/workflows/build-release.yml
T
dtourolleandClaude Opus 5 5a89433746 Cache the crates, not the 30 GB of build output
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>
2026-08-21 12:11:11 +02:00

358 lines
14 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"