From ec90c1588c490d22856d966af511847268ffcba8 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Sat, 13 Jun 2026 19:25:24 +0200 Subject: [PATCH] ci: build + serve signed Android APK on push to master - docker/android.Dockerfile: full socket-safe build (JDK17 + SDK + NDK 27 + CMake + Bun) -> expo prebuild -> sign (BuildKit --secret) -> assembleRelease. - ci.yml build-apk job: buildx build, extract APK via docker create/cp, copy into the running wisp container so it serves at /wisp.apk; artifact upload. - app.json: set android.package = dev.briggen.wisp (required by prebuild). Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 56 +++++++++++--- app.json | 1 + docker/android.Dockerfile | 149 ++++++++++++++++++-------------------- 3 files changed, 114 insertions(+), 92 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3dc655d..12fd076 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -64,15 +64,47 @@ jobs: done echo "wisp container did not become healthy" >&2; exit 1 - # ========================================================================= - # TODO (next iteration, once the runner is validated): build-apk. - # The Android build is genuinely risky (whisper.rn C++/NDK on RN 0.85) and - # untested locally (no Android SDK on the dev box). It must be socket-safe: - # a FULL `docker build` of a build-stage in docker/android.Dockerfile (repo - # COPYed in, prebuild + gradlew assembleRelease, signing via BuildKit - # --secret using the ANDROID_* secrets), then extract the APK with - # `docker create`/`docker cp` (NO host bind-mounts — they don't resolve over - # the mounted socket) and `docker cp` it into the running `wisp` container at - # /usr/share/nginx/html/wisp.apk (served at https://wisp.briggen.dev/wisp.apk), - # plus upload it as a CI artifact. Keystore secrets are already configured. - # ========================================================================= + # ---- build-apk: signed release APK, served at /wisp.apk ------------------ + # Socket-safe: the whole Android build runs inside `docker buildx build` + # (context streamed to the host BuildKit — no host bind-mounts), signed via + # BuildKit --secret from the ANDROID_* repo secrets. We then extract the APK + # with docker create/cp and copy it into the running `wisp` web container so + # it is downloadable at https://wisp.briggen.dev/wisp.apk. + build-apk: + needs: [test, deploy-web] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Stage signing secrets as files + run: | + umask 077 + mkdir -p "$RUNNER_TEMP/sec" + printf '%s' "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" > "$RUNNER_TEMP/sec/ks_b64" + printf '%s' "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" > "$RUNNER_TEMP/sec/ks_pass" + printf '%s' "${{ secrets.ANDROID_KEY_ALIAS }}" > "$RUNNER_TEMP/sec/ks_alias" + printf '%s' "${{ secrets.ANDROID_KEY_PASSWORD }}" > "$RUNNER_TEMP/sec/ks_keypass" + - name: Build signed APK image + run: | + docker buildx build \ + --secret id=ks_b64,src="$RUNNER_TEMP/sec/ks_b64" \ + --secret id=ks_pass,src="$RUNNER_TEMP/sec/ks_pass" \ + --secret id=ks_alias,src="$RUNNER_TEMP/sec/ks_alias" \ + --secret id=ks_keypass,src="$RUNNER_TEMP/sec/ks_keypass" \ + -f docker/android.Dockerfile -t wisp-android:ci --load . + - name: Extract + publish APK to /wisp.apk + run: | + cid=$(docker create wisp-android:ci) + docker cp "$cid:/workspace/android/app/build/outputs/apk/release/app-release.apk" ./app-release.apk + docker rm "$cid" + ls -lh app-release.apk + docker cp ./app-release.apk wisp:/usr/share/nginx/html/wisp.apk + - name: Cleanup staged secrets + if: always() + run: rm -rf "$RUNNER_TEMP/sec" + - name: Upload APK artifact + uses: actions/upload-artifact@v3 + continue-on-error: true + with: + name: wisp-release-apk + path: app-release.apk diff --git a/app.json b/app.json index 811daeb..3dc3772 100644 --- a/app.json +++ b/app.json @@ -11,6 +11,7 @@ "icon": "./assets/expo.icon" }, "android": { + "package": "dev.briggen.wisp", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/docker/android.Dockerfile b/docker/android.Dockerfile index 3a2e575..db4c4bf 100644 --- a/docker/android.Dockerfile +++ b/docker/android.Dockerfile @@ -1,110 +1,99 @@ # syntax=docker/dockerfile:1 # -# docker/android.Dockerfile +# docker/android.Dockerfile — full Android build for Wisp -> signed release APK. # --------------------------------------------------------------------------- -# Reproducible Android build environment for Wisp (Expo SDK 56 / RN 0.85). +# Two stages: +# toolchain : JDK17 + Android SDK (platform/build-tools) + NDK + CMake + Bun. +# Reusable on its own (docker build --target toolchain). +# build : COPYs the repo, installs JS deps, `expo prebuild`, signs, and +# runs `gradlew assembleRelease`. The signed APK lands at +# /workspace/android/app/build/outputs/apk/release/app-release.apk # -# This image contains everything needed to run: -# bunx expo prebuild --platform android --no-install -# cd android && ./gradlew assembleRelease +# This is socket-safe for CI: the whole build happens inside `docker build` +# (context streamed to the daemon — NO host bind mounts, which don't resolve +# over a mounted Docker socket). CI then extracts the APK via docker create/cp. # -# It is used both in CI (.gitea/workflows/ci.yml -> build-apk) and locally -# (see docs/DEPLOY.md "Build the APK locally") so the APK is built identically -# everywhere. The repository itself is mounted in at run time; this image only -# provides the toolchain (JDK + Android SDK + Bun), not the source. +# Signing secrets are passed as BuildKit secrets (NOT build args / layers): +# --secret id=ks_b64 -> ANDROID_KEYSTORE_BASE64 (base64 of the .keystore) +# --secret id=ks_pass -> ANDROID_KEYSTORE_PASSWORD +# --secret id=ks_alias -> ANDROID_KEY_ALIAS +# --secret id=ks_keypass -> ANDROID_KEY_PASSWORD # +# JDK pinned to 17 (AGP 8.x); versions track RN 0.85 / Expo SDK 56 +# (compileSdk/targetSdk 36, buildTools 36.0.0, NDK 27.1.12297006). # --------------------------------------------------------------------------- -# Pinned tool versions (Expo SDK 56 / React Native 0.85 era). -# Sourced from node_modules/react-native/gradle/libs.versions.toml: -# compileSdk = 36 targetSdk = 36 minSdk = 24 -# buildTools = 36.0.0 -# ndkVersion = 27.1.12297006 (installed lazily by Gradle if a module needs it) -# AGP = 8.12.0 Kotlin = 2.1.20 Gradle wrapper = 9.3.1 -# -# IMPORTANT: pin to JDK 17. AGP 8.x targets JDK 17; the dev machine may have a -# newer JDK (e.g. JDK 26), but CI/Docker MUST use 17 for reproducible, supported -# builds. See docs/DEPLOY.md. -# --------------------------------------------------------------------------- -FROM eclipse-temurin:17-jdk -# ---- Build-time version arguments (override with --build-arg if needed) ---- -# Android compile/target SDK platform required by Expo SDK 56 / RN 0.85. +FROM eclipse-temurin:17-jdk AS toolchain + ARG ANDROID_PLATFORM_VERSION=36 -# Android build-tools version matching libs.versions.toml (buildTools = 36.0.0). ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0 -# Google's commandline-tools package. "latest" via the published zip; pinned to a -# known build number for reproducibility. Update from: -# https://developer.android.com/studio#command-line-tools-only +ARG ANDROID_NDK_VERSION=27.1.12297006 +ARG ANDROID_CMAKE_VERSION=3.22.1 ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 -# ---- Android SDK locations (both vars set for tool compatibility) ---------- -ENV ANDROID_HOME=/opt/android-sdk -ENV ANDROID_SDK_ROOT=/opt/android-sdk -# Put the SDK tools on PATH. cmdline-tools live under .../cmdline-tools/latest/bin -# (we deliberately install them into a directory literally named "latest", which -# is what sdkmanager expects). -ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools +ENV ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk \ + DEBIAN_FRONTEND=noninteractive \ + GRADLE_USER_HOME=/opt/gradle \ + BUN_INSTALL=/root/.bun +ENV PATH=/root/.bun/bin:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH} -# Non-interactive apt for clean CI logs. -ENV DEBIAN_FRONTEND=noninteractive - -# ---- OS packages ----------------------------------------------------------- -# - unzip/curl: fetch + extract the cmdline-tools zip -# - git: expo prebuild / some gradle tasks shell out to git -# - bash: scripts/ci-android-sign.sh is bash (the default sh would do, but be explicit) -# - ca-certificates: TLS for downloads (sdkmanager, gradle, HF, etc.) +# OS deps for SDK download + RN/CMake native builds. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - unzip \ - curl \ - git \ - bash \ - ca-certificates \ + unzip curl git bash ca-certificates \ && rm -rf /var/lib/apt/lists/* -# ---- Install Bun (JS package manager / task runner) ------------------------ -# Wisp uses Bun, not npm/yarn. The official installer drops Bun into ~/.bun. -# We run as root in this image, so HOME=/root. -ENV BUN_INSTALL=/root/.bun -ENV PATH=${BUN_INSTALL}/bin:${PATH} -RUN curl -fsSL https://bun.sh/install | bash \ - && bun --version +# Bun (Wisp's package manager / task runner). +RUN curl -fsSL https://bun.sh/install | bash && bun --version -# ---- Install the Android SDK commandline tools ----------------------------- -# Download the Linux cmdline-tools zip and unpack it into the directory layout -# sdkmanager requires: $ANDROID_HOME/cmdline-tools/latest/... +# Android commandline-tools into $ANDROID_HOME/cmdline-tools/latest. RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \ - && curl -fsSL -o /tmp/cmdline-tools.zip \ + && curl -fsSL -o /tmp/ct.zip \ "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \ - && unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools \ - # The zip extracts to a top-level "cmdline-tools" dir; move it to "latest". - && mv /tmp/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ - && rm -rf /tmp/cmdline-tools.zip /tmp/cmdline-tools + && unzip -q /tmp/ct.zip -d /tmp/ct \ + && mv /tmp/ct/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ + && rm -rf /tmp/ct.zip /tmp/ct -# ---- Accept licenses and install SDK packages ------------------------------ -# `yes |` auto-accepts every interactive license prompt. We install: -# - platform-tools (adb, etc.) -# - platforms;android-36 (compileSdk/targetSdk = 36) -# - build-tools;36.0.0 (aapt2, zipalign, apksigner, ...) -# The NDK (27.1.12297006) is intentionally NOT pre-installed: most JS-only Expo -# apps don't need it, and Gradle will download the exact required NDK on demand. -# If your native deps require it at build time, add: -# "ndk;27.1.12297006" -# to the sdkmanager line below to bake it in (saves time but enlarges the image). +# Licenses + SDK packages. NDK + CMake are required because whisper.rn compiles +# native C++ (whisper.cpp) via externalNativeBuild. RUN yes | sdkmanager --licenses > /dev/null \ && sdkmanager --install \ "platform-tools" \ "platforms;android-${ANDROID_PLATFORM_VERSION}" \ "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \ + "ndk;${ANDROID_NDK_VERSION}" \ + "cmake;${ANDROID_CMAKE_VERSION}" > /dev/null \ && yes | sdkmanager --licenses > /dev/null -# Gradle home lives outside the mounted repo so the daemon cache can persist via -# a Docker volume between builds (see docs/DEPLOY.md for the local-build command). -ENV GRADLE_USER_HOME=/opt/gradle - -# The repository is mounted here at run time (e.g. `-v "$PWD":/workspace`). WORKDIR /workspace -# Default to an interactive shell; CI overrides this with an explicit build -# command (`bash -lc "..."`). Documented usage is in docs/DEPLOY.md. -CMD ["bash"] +# --------------------------------------------------------------------------- +FROM toolchain AS build + +# Install JS deps first (cached unless package.json/bun.lock change). +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +# Bring in the rest of the source (.dockerignore excludes node_modules, android/, ios/, dist...). +COPY . . + +# Generate the native android/ project (autolinks whisper.rn, reanimated, etc.). +RUN bunx expo prebuild --platform android --no-install + +# Decode the keystore + wire release signing, then assemble the signed APK. +# Secrets are mounted as files under /run/secrets and exported only for this RUN. +RUN --mount=type=secret,id=ks_b64 \ + --mount=type=secret,id=ks_pass \ + --mount=type=secret,id=ks_alias \ + --mount=type=secret,id=ks_keypass \ + ANDROID_KEYSTORE_BASE64="$(cat /run/secrets/ks_b64)" \ + ANDROID_KEYSTORE_PASSWORD="$(cat /run/secrets/ks_pass)" \ + ANDROID_KEY_ALIAS="$(cat /run/secrets/ks_alias)" \ + ANDROID_KEY_PASSWORD="$(cat /run/secrets/ks_keypass)" \ + bash scripts/ci-android-sign.sh \ + && cd android \ + && ./gradlew assembleRelease --no-daemon -x lint --stacktrace + +# The signed APK (handy for `docker create` + `docker cp` extraction in CI). +# /workspace/android/app/build/outputs/apk/release/app-release.apk