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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:28:52 -04:00
co-authored by Claude Opus 5.5
parent 68ec5a8d99
commit 3b39910f13
7 changed files with 316 additions and 0 deletions
+102
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Build both release APKs into an output folder:
# musicshare-<version>.apk the normal app (with Google's barcode scanner)
# musicshare-<version>-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)
+37
View File
@@ -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
+19
View File
@@ -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
+23
View File
@@ -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
+76
View File
@@ -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-<version>.apk`: the normal app.
- `musicshare-<version>-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=<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/<versionCode>.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 `<branch>-<commit>`, 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.
+1
View File
@@ -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/<versionCode>.txt`.
3. Commit, tag `v<version>`, 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)