From 3b39910f135fe7d14154b55ba0f4c38e904756c7 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 14 Oct 2026 21:28:52 -0400 Subject: [PATCH] Build the Android app on our own Gitea runner Pushing a v* tag builds the normal and FOSS APKs, signed with the upload key, and attaches them to a Gitea release; a manual run on a branch uploads a test build to the repo's packages. Everything runs on the Gitea server: a builder image with the Android SDK, an act_runner registered only for this repo, and plain shell steps (no third-party actions). The build itself is apps/mobile/scripts/build-android.sh, the same script for hand builds. Setup steps are in docs/ANDROID-CI.md. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/android.yml | 102 +++++++++++++++++++++++++++ apps/mobile/scripts/build-android.sh | 58 +++++++++++++++ deploy/android-builder/Dockerfile | 37 ++++++++++ deploy/gitea-runner/compose.yml | 19 +++++ deploy/gitea-runner/config.yaml | 23 ++++++ docs/ANDROID-CI.md | 76 ++++++++++++++++++++ docs/FDROID.md | 1 + 7 files changed, 316 insertions(+) create mode 100644 .gitea/workflows/android.yml create mode 100755 apps/mobile/scripts/build-android.sh create mode 100644 deploy/android-builder/Dockerfile create mode 100644 deploy/gitea-runner/compose.yml create mode 100644 deploy/gitea-runner/config.yaml create mode 100644 docs/ANDROID-CI.md diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 0000000..2586114 --- /dev/null +++ b/.gitea/workflows/android.yml @@ -0,0 +1,102 @@ +# Builds the Android app on our own runner (deploy/gitea-runner) and publishes it. +# +# - Push a tag like v1.1.0: builds the normal and FOSS APKs, signed with the upload key, +# and attaches them to a Gitea release for that tag. +# - Actions > Android > Run workflow on a branch: a test build, uploaded to this repo's +# Packages (generic package "musicshare-android", version = branch + short commit). +# +# Uses only `run` steps (no third-party actions), inside the image from deploy/android-builder. +# Secrets (repo Settings > Actions > Secrets): +# UPLOAD_KEYSTORE_BASE64 the upload keystore, base64 -w0 +# UPLOAD_STORE_PASSWORD, UPLOAD_KEY_ALIAS, UPLOAD_KEY_PASSWORD +# RELEASE_TOKEN an access token with write:repository and write:package +name: Android + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build: + runs-on: musicshare-android + container: + image: musicshare-android-builder + # Keep Gradle and npm downloads between builds. + options: -v musicshare-gradle:/root/.gradle -v musicshare-npm:/root/.npm + timeout-minutes: 150 + env: + SERVER: ${{ github.server_url }} + REPO: ${{ github.repository }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false + steps: + - name: Check out + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git init -q src + cd src + git -c http.extraHeader="Authorization: token $TOKEN" fetch -q --depth 1 "$SERVER/$REPO.git" "$SHA" + git checkout -q FETCH_HEAD + + - name: Check the version + working-directory: src + run: | + version=$(node -p "require('./apps/mobile/app.json').expo.version") + echo "app.json version: $version" + if [ "$REF_TYPE" = tag ] && [ "$REF_NAME" != "v$version" ]; then + echo "Tag $REF_NAME doesn't match app.json version $version; bump it (and android.versionCode) first." >&2 + exit 1 + fi + + - name: Install + working-directory: src + run: npm ci --workspace @musicshare/mobile --workspace @musicshare/shared --include-workspace-root --no-audit --no-fund + + - name: Build + working-directory: src + env: + KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE_BASE64 }} + ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_STORE_FILE: /tmp/upload.keystore + ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_STORE_PASSWORD: ${{ secrets.UPLOAD_STORE_PASSWORD }} + ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }} + ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_KEY_PASSWORD: ${{ secrets.UPLOAD_KEY_PASSWORD }} + REQUIRE_SIGNED: '1' + run: | + printf '%s' "$KEYSTORE" | base64 -d > /tmp/upload.keystore + trap 'rm -f /tmp/upload.keystore' EXIT + apps/mobile/scripts/build-android.sh "$PWD/out" + + - name: Publish + working-directory: src/out + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + api="$SERVER/api/v1/repos/$REPO" + auth="Authorization: token $TOKEN" + if [ "$REF_TYPE" = tag ]; then + # Reuse the release if this tag was built before. + id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true) + if [ -z "$id" ]; then + body=$(node -e 'console.log(JSON.stringify({ tag_name: process.argv[1], name: process.argv[1], body: "Android builds. musicshare-*-foss.apk has no closed-source code (no Google ML Kit)." }))' "$REF_NAME") + id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id') + fi + for f in *.apk SHA256SUMS; do + old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0)).find(a => a.name === process.argv[1])?.id ?? ""' "$f") + [ -n "$old" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$old" + curl -fsS -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null + echo "Attached $f" + done + echo "Release: $SERVER/$REPO/releases/tag/$REF_NAME" + else + owner=${REPO%%/*} + version="$(echo "$REF_NAME" | tr -c 'A-Za-z0-9._-\n' '-')-${SHA::8}" + for f in *.apk SHA256SUMS; do + curl -fsS -X PUT -H "$auth" --upload-file "$f" "$SERVER/api/packages/$owner/generic/musicshare-android/$version/$f" + echo "Uploaded $f" + done + echo "Package: $SERVER/$owner/-/packages/generic/musicshare-android/$version" + fi diff --git a/apps/mobile/scripts/build-android.sh b/apps/mobile/scripts/build-android.sh new file mode 100755 index 0000000..cf4a547 --- /dev/null +++ b/apps/mobile/scripts/build-android.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Build both release APKs into an output folder: +# musicshare-.apk the normal app (with Google's barcode scanner) +# musicshare--foss.apk no closed-source code (MUSICSHARE_FOSS=1), as F-Droid builds it +# +# apps/mobile/scripts/build-android.sh [out-dir] (default: apps/mobile/dist) +# +# Both are signed with the upload key when it's configured (see plugins/withReleaseSigning.js; +# in CI it comes from ORG_GRADLE_PROJECT_MUSICSHARE_UPLOAD_* variables). Set REQUIRE_SIGNED=1 to +# fail rather than produce unsigned APKs. ABIS picks the CPU types (default: phones, 32 and 64 bit). +# Regenerates apps/mobile/android each time (prebuild --clean). +set -euo pipefail +cd "$(dirname "$0")/.." + +OUT=$(realpath -m "${1:-dist}") +VERSION=$(node -p "require('./app.json').expo.version") +ABIS=${ABIS:-armeabi-v7a,arm64-v8a} +BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1) +mkdir -p "$OUT" + +say() { printf '\n==> %s\n' "$*"; } + +build() { + local suffix=$1 foss=$2 + say "Building musicshare-$VERSION$suffix.apk" + MUSICSHARE_FOSS=$foss CI=1 npx expo prebuild --platform android --clean --no-install + (cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures="$ABIS" --console=plain) + local apk + apk=$(ls android/app/build/outputs/apk/release/*.apk | head -1) + + if "$BUILD_TOOLS/apksigner" verify "$apk" > /dev/null 2>&1; then + "$BUILD_TOOLS/apksigner" verify --print-certs "$apk" | grep 'SHA-256 digest' + elif [ "${REQUIRE_SIGNED:-0}" = 1 ]; then + echo "The APK isn't signed: is the upload key configured?" >&2 + exit 1 + else + echo "Unsigned (no upload key configured)." + fi + + if [ "$foss" = 1 ]; then + # Nothing from Google's closed-source libraries may be inside. + local tmp count + tmp=$(mktemp -d) + unzip -q "$apk" 'classes*.dex' -d "$tmp" + count=$(for f in "$tmp"/classes*.dex; do "$BUILD_TOOLS/dexdump" "$f" 2>/dev/null | grep 'Class descriptor'; done | grep -cE 'Lcom/google/(mlkit|android/gms)/' || true) + rm -rf "$tmp" + if [ "$count" != 0 ]; then + echo "The FOSS build contains $count ML Kit / Play Services classes." >&2 + exit 1 + fi + echo "No ML Kit / Play Services classes." + fi + cp "$apk" "$OUT/musicshare-$VERSION$suffix.apk" +} + +build "" 0 +build "-foss" 1 +(cd "$OUT" && sha256sum musicshare-"$VERSION"*.apk > SHA256SUMS && cat SHA256SUMS) diff --git a/deploy/android-builder/Dockerfile b/deploy/android-builder/Dockerfile new file mode 100644 index 0000000..f438644 --- /dev/null +++ b/deploy/android-builder/Dockerfile @@ -0,0 +1,37 @@ +# Build environment for the Android app, used by the Gitea Actions workflow +# (.gitea/workflows/android.yml). Build it on the machine running the runner: +# +# docker build -t musicshare-android-builder deploy/android-builder +# +# Versions match what React Native 0.86 / Expo 57 ask for +# (node_modules/react-native/gradle/libs.versions.toml). Gradle downloads anything +# else it needs on first use; the workflow keeps ~/.gradle in a volume. +FROM node:22-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ + ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools + +# Checksum from https://developer.android.com/studio#command-line-tools-only +ARG CMDLINE_TOOLS=commandlinetools-linux-15859902_latest.zip +ARG CMDLINE_TOOLS_SHA256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583 +RUN curl -fsSLo /tmp/tools.zip https://dl.google.com/android/repository/$CMDLINE_TOOLS \ + && echo "$CMDLINE_TOOLS_SHA256 /tmp/tools.zip" | sha256sum -c \ + && mkdir -p $ANDROID_HOME/cmdline-tools \ + && unzip -q /tmp/tools.zip -d $ANDROID_HOME/cmdline-tools \ + && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \ + && rm /tmp/tools.zip + +RUN yes | sdkmanager --licenses > /dev/null \ + && sdkmanager --install \ + "platform-tools" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "ndk;27.1.12297006" \ + "cmake;3.22.1" > /dev/null \ + && chmod -R a+rwX $ANDROID_HOME diff --git a/deploy/gitea-runner/compose.yml b/deploy/gitea-runner/compose.yml new file mode 100644 index 0000000..014cca3 --- /dev/null +++ b/deploy/gitea-runner/compose.yml @@ -0,0 +1,19 @@ +# Gitea Actions runner for building the Android app (.gitea/workflows/android.yml). +# Runs on the Gitea server; see docs/ANDROID-CI.md for setup. +# +# It starts build containers through the host's Docker, so it has the same power as +# root on this machine: only register it for the musicshare repository. +services: + runner: + image: gitea/act_runner:0.6.1 + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: https://gitea.chrisedwards.tech + # From the repo: Settings > Actions > Runners > Create new runner. Only needed the first time. + GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN:-} + GITEA_RUNNER_NAME: musicshare-android + CONFIG_FILE: /config.yaml + volumes: + - ./config.yaml:/config.yaml:ro + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock diff --git a/deploy/gitea-runner/config.yaml b/deploy/gitea-runner/config.yaml new file mode 100644 index 0000000..1b88764 --- /dev/null +++ b/deploy/gitea-runner/config.yaml @@ -0,0 +1,23 @@ +# act_runner settings (full reference: `docker run --rm gitea/act_runner:0.6.1 act_runner generate-config`). +log: + level: info + +runner: + file: /data/.runner + # One build at a time: an Android build wants several GB of memory. + capacity: 1 + timeout: 3h + labels: + - "musicshare-android:docker://musicshare-android-builder" + +cache: + enabled: false + +container: + # Use the locally built deploy/android-builder image rather than pulling from a registry. + force_pull: false + privileged: false + # The volumes the workflow may mount (Gradle and npm caches), and nothing else. + valid_volumes: + - musicshare-gradle + - musicshare-npm diff --git a/docs/ANDROID-CI.md b/docs/ANDROID-CI.md new file mode 100644 index 0000000..c2e1a73 --- /dev/null +++ b/docs/ANDROID-CI.md @@ -0,0 +1,76 @@ +# Building the Android app on the Gitea server + +Pushing a version tag builds both APKs on our own server and attaches them to a Gitea release: + +- `musicshare-.apk`: the normal app. +- `musicshare--foss.apk`: no closed-source code, the same build F-Droid makes. The + build fails if any Google ML Kit or Play Services code ends up inside. + +Both are signed with the upload key, and `SHA256SUMS` lists their checksums. Nothing leaves the server except the +downloads the build itself needs (npm packages, Gradle and its plugins). + +The pieces: + +| File | What it is | +| --- | --- | +| `.gitea/workflows/android.yml` | The workflow. It uses only plain shell steps, no third-party actions. | +| `apps/mobile/scripts/build-android.sh` | The build. You can also run it by hand on any machine with the Android SDK. | +| `deploy/android-builder/Dockerfile` | The build environment: Node 22, Java 17 and the Android SDK/NDK. | +| `deploy/gitea-runner/` | The runner (`act_runner`) that runs builds on the server. | + +## One-time setup (on the Gitea server) + +The server needs about 8 GB of free memory during a build and about 15 GB of disk for the image and +caches. A first build takes 30-40 minutes; later ones reuse the Gradle cache. + +1. **Turn on Actions** if it's off. In Gitea's `app.ini`, set: + ```ini + [actions] + ENABLED = true + ``` + Restart Gitea. Then, in the repo, go to **Settings > Repository > Actions** and enable it. + +2. **Build the builder image** from a checkout of this repo: + ```bash + docker build -t musicshare-android-builder deploy/android-builder + ``` + Rebuild it when React Native's SDK or NDK versions change (the Dockerfile says where to look). + +3. **Start the runner.** In the repo, open **Settings > Actions > Runners > Create new runner** and + copy the token. Use the repo's page, not the site admin's, so the runner only takes this repo's jobs. + Then: + ```bash + cd deploy/gitea-runner + RUNNER_TOKEN= docker compose up -d + ``` + It registers once (saved in `./data`), then shows as idle with the label `musicshare-android`. + The runner controls Docker on the host, so treat it like root access. The workflow only runs for + tags and manual runs, never for pull requests. + +4. **Add the secrets** under **Settings > Actions > Secrets**: + - `UPLOAD_KEYSTORE_BASE64`: the output of `base64 -w0 ~/.config/musicshare/musicshare-upload.jks`. + - `UPLOAD_STORE_PASSWORD`, `UPLOAD_KEY_ALIAS` (`musicshare-upload`), `UPLOAD_KEY_PASSWORD`: the values in + `~/.gradle/gradle.properties` on the machine you've been building on. + - `RELEASE_TOKEN`: create it under **User settings > Applications > Access tokens**, with + *write:repository* and *write:package* permissions. + +## Releasing + +1. Bump `version` and `android.versionCode` in `apps/mobile/app.json`. Add + `fastlane/metadata/android/en-US/changelogs/.txt`, then commit. +2. Tag and push: `git tag v1.1.0 && git push origin main v1.1.0`. +3. Watch it under the repo's **Actions** tab. The APKs appear on the **Releases** page. + +The build refuses to run if the tag doesn't match the version in `app.json`. Re-running a tag's build replaces +that release's files. + +**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the +repo's **Packages** as `musicshare-android`, versioned `-`, instead of to a release. + +## Building by hand + +```bash +apps/mobile/scripts/build-android.sh # APKs in apps/mobile/dist +``` + +It uses the upload key from `~/.gradle/gradle.properties` if it's there; otherwise the APKs come out unsigned. diff --git a/docs/FDROID.md b/docs/FDROID.md index 6c7dccf..219f963 100644 --- a/docs/FDROID.md +++ b/docs/FDROID.md @@ -48,6 +48,7 @@ Music, Last.fm and Plex. That's expected and doesn't block inclusion. 1. Bump `version` and `android.versionCode` in `apps/mobile/app.json`. 2. Add `fastlane/metadata/android/en-US/changelogs/.txt`. 3. Commit, tag `v`, push the tag. F-Droid picks it up automatically (`AutoUpdateMode`). + Our own server builds the signed APKs for the same tag (see `docs/ANDROID-CI.md`). ## IzzyOnDroid (faster alternative)