From 3583c2e15fe0a71d57c36fe50a67267719cc2664 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 16:26:06 +0000 Subject: [PATCH] CI: cache Android SDK/NDK, Gradle, and npm across APK builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist three Docker volumes into the build job (android-sdk, gradle home, npm cache) and enable the Gradle build cache, so repeat builds skip the ~2GB NDK download, Maven dependency resolution, and unchanged native/Kotlin compilation — cutting builds from ~1h to a few minutes after the first run. The runner must whitelist these volumes via config.yaml (container.valid_volumes); deploy-runner.sh now writes that config, pre-creates the volumes, and starts the daemon with --config. Requires re-running deploy-runner.sh on the runner host before the next tag. Co-Authored-By: Claude Fable 5 --- .forgejo/workflows/build-apk.yml | 42 ++++++++++++++++++++++++-------- runner/README.md | 24 ++++++++++++++++++ runner/config.yaml | 9 +++++++ runner/docker-compose.yml | 5 +++- scripts/deploy-runner.sh | 21 +++++++++++++++- 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 runner/config.yaml diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index 5879490..c51c8d6 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -11,14 +11,27 @@ jobs: runs-on: docker container: image: node:22-bookworm + # Persistent caches across runs (Docker named volumes). These must be + # allowed in the runner's config.yaml `container.valid_volumes` — the + # deploy-runner.sh script sets that up. First run populates them (~1h); + # later runs reuse the SDK/NDK, Gradle deps + build cache, and npm cache, + # dropping the build to a few minutes. + volumes: + - camptickets-android-sdk:/opt/android-sdk + - camptickets-gradle:/root/.gradle + - camptickets-npm:/root/.npm env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk + GRADLE_USER_HOME: /root/.gradle # Force IPv4 for all JVMs (Gradle launcher, daemon, Kotlin/CMake workers). # The runner host has no working IPv6 route, so Maven Central (which has # AAAA records) was unreachable — this makes Java ignore AAAA and use IPv4. JAVA_TOOL_OPTIONS: -Djava.net.preferIPv4Stack=true - GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false + # org.gradle.caching enables the local build cache (persisted in + # GRADLE_USER_HOME), so unchanged native/Kotlin tasks are restored instead + # of recompiled. + GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dorg.gradle.caching=true steps: - name: Checkout uses: actions/checkout@v4 @@ -36,16 +49,25 @@ jobs: apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip wget git echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - - name: Install Android SDK + - name: Install Android SDK (cached) run: | - set -eux - mkdir -p "$ANDROID_HOME/cmdline-tools" - cd /tmp - wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip - unzip -q cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" - mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + set -eu + # Skip the whole install when the cache volume already has the SDK. + # (NDK + CMake are auto-installed by Gradle into the same volume on + # the first build, so they persist too.) + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "Installing Android command-line tools..." + mkdir -p "$ANDROID_HOME/cmdline-tools" + cd /tmp + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip + unzip -q cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + else + echo "Android SDK found in cache volume — skipping download." + fi export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" - yes | sdkmanager --licenses >/dev/null || true + yes | sdkmanager --licenses >/dev/null 2>&1 || true + # sdkmanager is a no-op for packages already present in the volume. sdkmanager --install "platform-tools" \ "platforms;android-36" "platforms;android-35" \ "build-tools;36.0.0" "build-tools;35.0.0" >/dev/null @@ -73,7 +95,7 @@ jobs: CAMPSCAN_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon + ./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon --build-cache mkdir -p "$GITHUB_WORKSPACE/artifacts" cp app/build/outputs/apk/release/app-release.apk \ "$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk" diff --git a/runner/README.md b/runner/README.md index 5113651..0420be2 100644 --- a/runner/README.md +++ b/runner/README.md @@ -30,6 +30,27 @@ The runner advertises the `docker` label; the build workflow (`.forgejo/workflow 2. Push a tag: `git tag v0.1.0 && git push origin v0.1.0`. 3. The runner builds `camp-scan-v0.1.0.apk` and attaches it to a Forgejo release; Obtainium picks it up. +## Build caching (fast repeat builds) + +The first APK build takes ~1h (downloads Gradle + the ~2GB Android NDK, resolves +all Maven deps, compiles native modules). After that it should drop to a few +minutes because three persistent Docker volumes are reused across runs: + +| Volume | Holds | +|---|---| +| `camptickets-android-sdk` → `/opt/android-sdk` | SDK, NDK, CMake | +| `camptickets-gradle` → `/root/.gradle` | Gradle dist, Maven deps, local build cache | +| `camptickets-npm` → `/root/.npm` | npm download cache | + +For this to work the runner must **allow** these volumes via `config.yaml` +(`container.valid_volumes`) — `deploy-runner.sh` writes that config, pre-creates +the volumes, and starts the daemon with `--config /data/config.yaml`. If you set +the runner up by hand, copy `config.yaml` next to the compose file and add +`--config /data/config.yaml` to the daemon command. + +To force a clean rebuild, remove the volumes: +`docker volume rm camptickets-android-sdk camptickets-gradle camptickets-npm`. + ## Notes - The runner I initially registered on the app host has been removed. If Forgejo @@ -37,3 +58,6 @@ The runner advertises the `docker` label; the build workflow (`.forgejo/workflow *Settings → Actions → Runners*. - `DOCKER_GID` must match the roomy server's docker socket group, or the runner can't reach the Docker daemon. +- After updating to a caching-enabled runner, re-run `deploy-runner.sh` (or + `docker compose up -d` in the runner dir) so the new `config.yaml` + volumes + take effect, then push a fresh tag. diff --git a/runner/config.yaml b/runner/config.yaml new file mode 100644 index 0000000..56d51d8 --- /dev/null +++ b/runner/config.yaml @@ -0,0 +1,9 @@ +# Forgejo runner config. The important bit is valid_volumes: it whitelists the +# named Docker volumes the build-apk workflow mounts as persistent caches +# (Android SDK/NDK, Gradle home, npm cache). Without these listed, the runner +# rejects the workflow's `volumes:` and the job fails. +container: + valid_volumes: + - camptickets-android-sdk + - camptickets-gradle + - camptickets-npm diff --git a/runner/docker-compose.yml b/runner/docker-compose.yml index 670aff4..e5020d2 100644 --- a/runner/docker-compose.yml +++ b/runner/docker-compose.yml @@ -11,6 +11,9 @@ services: - "${DOCKER_GID:-988}" volumes: - ./data:/data + # config.yaml allows the build workflow to mount the persistent cache + # volumes (valid_volumes); without it those mounts are rejected. + - ./config.yaml:/data/config.yaml:ro # Runner spawns job containers via the host Docker daemon. - /var/run/docker.sock:/var/run/docker.sock env_file: .env @@ -31,4 +34,4 @@ services: --name "${RUNNER_NAME:-camptickets-runner}" \ --labels "docker:docker://node:22-bookworm" fi - exec forgejo-runner daemon + exec forgejo-runner daemon --config /data/config.yaml diff --git a/scripts/deploy-runner.sh b/scripts/deploy-runner.sh index 31255d7..ee5b16d 100755 --- a/scripts/deploy-runner.sh +++ b/scripts/deploy-runner.sh @@ -61,6 +61,24 @@ mkdir -p "$INSTALL_DIR/data" # The runner image runs as uid/gid 1000 and must own its config dir. chown -R 1000:1000 "$INSTALL_DIR/data" +# Runner config: allow the build workflow to mount the persistent cache volumes +# (Android SDK/NDK, Gradle home, npm cache) so APK builds don't redownload and +# recompile everything each run. Without valid_volumes listed, the runner +# rejects the workflow's `volumes:` and the job fails. +cat > "$INSTALL_DIR/config.yaml" <<'CONFIG' +container: + valid_volumes: + - camptickets-android-sdk + - camptickets-gradle + - camptickets-npm +CONFIG +chown 1000:1000 "$INSTALL_DIR/config.yaml" + +# Pre-create the named cache volumes (idempotent). +docker volume create camptickets-android-sdk >/dev/null +docker volume create camptickets-gradle >/dev/null +docker volume create camptickets-npm >/dev/null + cat > "$INSTALL_DIR/docker-compose.yml" <