# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on # workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on # a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and # assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP). # # Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without # them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline). # The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook). # The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs # nothing pre-installed. name: android-build run-name: "android build ${{ github.sha }}" on: workflow_dispatch: inputs: confirm: description: 'Type "build" to confirm an APK build from master.' required: true default: "" permissions: contents: read env: NO_COLOR: "1" # The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It # both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__). DICT_VERSION: ${{ vars.DICT_VERSION }} VITE_DICT_VERSION: ${{ vars.DICT_VERSION }} # Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset). VITE_PAYMENTS_DISABLED: "1" # The update overlay's store target; empty until publication (the button no-ops, and the version gate # is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live. VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }} # Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it # lives elsewhere (the default matches deploy/README.md's install path). ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} jobs: build: if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }} runs-on: ubuntu-latest defaults: run: shell: bash steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java # below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is # missing — this doubles as the runner-access check (deploy/README.md). - name: Verify the host Android SDK run: | sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" if [ ! -x "$sm" ]; then echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1 fi for pkg in "platforms/android-36" "build-tools"; do if [ ! -d "$ANDROID_HOME/$pkg" ]; then echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1 fi done echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version - name: Compute version + native build env id: prep env: PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} run: | # A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the # versionCode is deterministic and strictly increasing across uploads. Refuse anything else # rather than derive a versionCode from a "-N-gSHA" describe. desc="$(git describe --tags --exact-match 2>/dev/null || true)" case "$desc" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;; esac v="${desc#v}" IFS=. read -r MA MI PA <<< "$v" # 10# forces base-10 so a zero-padded part is never read as octal. code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA )) # The native SPA talks to the production origin (reuse the prod public base URL); strip any # trailing slash so the Connect endpoint never doubles it. gateway="${PUBLIC_BASE_URL%/}" { echo "tag=$desc" echo "name=$v" echo "code=$code" echo "gateway=$gateway" } >> "$GITHUB_OUTPUT" echo "release $desc -> versionName $v versionCode $code, gateway $gateway" # Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball, # NOT the scrabble-solver sibling (ui is a Node project outside go.work). - name: Fetch dictionary DAWGs run: | mkdir -p "${GITHUB_WORKSPACE}/dawg" curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" ls -la "${GITHUB_WORKSPACE}/dawg" - name: Set up Node uses: actions/setup-node@v4 with: node-version: 22 - name: Install pnpm run: npm install -g pnpm@11.0.9 - name: Install deps working-directory: ui run: pnpm install --frozen-lockfile - name: Build the SPA (native flavour) working-directory: ui env: VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }} VITE_APP_VERSION: ${{ steps.prep.outputs.tag }} run: pnpm run build # Copy the release DAWGs into dist/dict/@.dawg for the offline-first bundled tier # (after the build, before cap sync copies dist/ into the native assets). - name: Bundle the dictionaries working-directory: ui env: DICT_DIR: ${{ github.workspace }}/dawg run: node scripts/bundle-dicts.mjs - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" # Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps. - name: Sync the native project working-directory: ui run: node_modules/.bin/cap sync android - name: Decode the release keystore id: keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }} run: | if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks" echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT" echo "keystore decoded -> signed release build" else echo "file=" >> "$GITHUB_OUTPUT" echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)" fi - name: Assemble the release APK working-directory: ui/android env: ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }} run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain - name: Upload the APK artifact uses: actions/upload-artifact@v4 with: name: erudit-${{ steps.prep.outputs.name }}-apk path: ui/android/app/build/outputs/apk/release/*.apk if-no-files-found: error