v2.4.0 #30
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Android | |
| # Android builds run on published releases (matching release.yml), not on every | |
| # PR — the Rust cross-compile + Gradle build is slow and rarely needs per-PR | |
| # coverage. Use the manual "Run workflow" button (workflow_dispatch) to build a | |
| # signed APK on demand from any branch. | |
| on: | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| # Least privilege at the workflow level; the build job below elevates to | |
| # contents: write only where it needs to attach release assets. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: android-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| # Keep these in sync with docs/android.md and the local setup. | |
| # | |
| # API 36 (Android 16), not 34: the Tauri v2.11 Android template generates | |
| # `compileSdk = 36` / `targetSdk = 36`, so Gradle needs the matching platform | |
| # installed. It is also the Google Play floor — from 2026-08-31 new apps and | |
| # updates must target API 36 or the upload is rejected. | |
| ANDROID_PLATFORM: "platforms;android-36" | |
| ANDROID_BUILD_TOOLS: "build-tools;36.0.0" | |
| # NDK r27 LTS — Tauri v2's supported line. Bump deliberately. | |
| ANDROID_NDK_VERSION: "27.3.13750724" | |
| # 16 KB page-size alignment for the Rust .so files (a Google Play requirement | |
| # since 2025-11-01 for apps targeting Android 15+; GeoLibre targets 36). | |
| # NDK r28+ emits aligned segments by default, r27 does not, so ask explicitly. | |
| # | |
| # This MUST be the RUSTFLAGS env var rather than `target.<triple>.rustflags` | |
| # in a .cargo/config.toml: the Tauri CLI sets RUSTFLAGS itself when invoking | |
| # cargo for Android, and an env RUSTFLAGS overrides the config file outright, | |
| # so the config-file form is silently ignored and ships 4 KB-aligned libs. | |
| # Tauri appends to an inherited value, so setting it here survives. Verified | |
| # against real APKs: without it the shipped .so are 2**12, with it 2**14. | |
| # | |
| # Set at workflow level so the APK and AAB steps agree — a mismatch changes | |
| # the cargo fingerprint and forces a full rebuild between them. | |
| RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384" | |
| # The Android package id, asserted twice below. It comes from `identifier` in | |
| # src-tauri/tauri.android.conf.json, which overrides the desktop | |
| # `org.geolibre.desktop` for Android only. Google Play burns this permanently | |
| # on first upload — it cannot be changed or reused afterwards — so it is | |
| # verified rather than trusted. | |
| EXPECTED_PACKAGE: "org.geolibre.app" | |
| jobs: | |
| build: | |
| name: Build Android APK (release) | |
| runs-on: ubuntu-22.04 | |
| # Elevated here (not workflow-level) so only this job can write release | |
| # assets; workflow_dispatch runs never attach but the grant is harmless. | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: "22" | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - name: Set up JDK | |
| uses: actions/setup-java@v5 | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| - name: Set up Android SDK | |
| # Third-party action pinned to a full commit SHA (v4.0.1) per the repo's | |
| # policy for non-official actions; a tag could be re-pointed. v4 runs on | |
| # Node.js 24 (v3 targeted the now-deprecated Node.js 20). | |
| uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 | |
| - name: Install Android NDK, platform, and build-tools | |
| run: | | |
| sdkmanager --install \ | |
| "platform-tools" \ | |
| "$ANDROID_PLATFORM" \ | |
| "$ANDROID_BUILD_TOOLS" \ | |
| "ndk;$ANDROID_NDK_VERSION" | |
| echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION" >> "$GITHUB_ENV" | |
| - name: Install Rust stable with Android targets | |
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: stable | |
| targets: >- | |
| aarch64-linux-android, | |
| armv7-linux-androideabi, | |
| i686-linux-android, | |
| x86_64-linux-android | |
| - name: Cache Rust dependencies | |
| uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| workspaces: apps/geolibre-desktop/src-tauri -> target | |
| - name: Install frontend dependencies | |
| run: npm ci | |
| - name: Generate Android project | |
| # gen/android is not committed; regenerate it on the clean runner. The | |
| # Tauri CLI is resolved through the geolibre-desktop workspace. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android init | |
| - name: Verify generated package id | |
| # Fail fast, before the ~30 min build: assert the tauri.android.conf.json | |
| # `identifier` override actually reached the generated Gradle project. | |
| # A silent regression here ships an irreversible wrong package id to | |
| # Play, so it is gated rather than assumed. The artifact itself is | |
| # re-checked after the APK build. | |
| working-directory: apps/geolibre-desktop | |
| run: | | |
| set -euo pipefail | |
| gradle_file=src-tauri/gen/android/app/build.gradle.kts | |
| # Tolerant of spacing so a cosmetic change to the Tauri template | |
| # (`applicationId="x"`, extra indentation) fails the build only when | |
| # the id is actually wrong, not when the formatting moved. | |
| # | |
| # The dots in the package id are regex metacharacters, so escape them | |
| # before interpolating: the point of this step is that the id is | |
| # *exactly* right (it is irreversible once uploaded to Play), and an | |
| # unescaped pattern would also accept e.g. `orgXgeolibreYapp`. | |
| escaped_package="${EXPECTED_PACKAGE//./\\.}" | |
| if ! grep -qE "applicationId[[:space:]]*=[[:space:]]*\"$escaped_package\"" "$gradle_file"; then | |
| echo "::error::applicationId is not $EXPECTED_PACKAGE in $gradle_file" | |
| grep -nE 'applicationId|namespace' "$gradle_file" || true | |
| exit 1 | |
| fi | |
| echo "applicationId = $EXPECTED_PACKAGE (generated Gradle project)" | |
| - name: Apply GeoLibre launcher icons | |
| # `tauri android init` writes default Tauri icons; overwrite the generated | |
| # mipmaps with the GeoLibre launcher icons checked in under src-tauri/icons. | |
| working-directory: apps/geolibre-desktop | |
| run: cp -r src-tauri/icons/android/. src-tauri/gen/android/app/src/main/res/ | |
| - name: Build release APKs (per ABI) | |
| # Release (not --debug): the size-optimized + stripped Cargo profile keeps | |
| # each .so small. --split-per-abi emits one ~40 MB APK per architecture | |
| # instead of a single ~150 MB universal APK bundling all four ABIs. | |
| # Release APKs are unsigned by default; the next step signs them. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android build --apk --split-per-abi | |
| env: | |
| VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} | |
| - name: Verify built APK package id | |
| # Second assertion, on the shipped bytes rather than the build script. | |
| # `aapt2 dump packagename` reads the merged manifest, so this also | |
| # catches a package id changed by manifest merging after Gradle | |
| # configuration. | |
| run: | | |
| set -euo pipefail | |
| build_tools="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)" | |
| checked=0 | |
| while IFS= read -r apk; do | |
| checked=$((checked + 1)) | |
| pkg="$("$build_tools/aapt2" dump packagename "$apk")" | |
| if [ "$pkg" != "$EXPECTED_PACKAGE" ]; then | |
| echo "::error::$apk has package '$pkg', expected '$EXPECTED_PACKAGE'" | |
| exit 1 | |
| fi | |
| # Scoped to outputs/apk/ for the same reason the AAB lookup below is | |
| # scoped to outputs/bundle/: a bare `-name` searches the whole | |
| # gen/android tree, including AGP's intermediates. No duplicate | |
| # `*release-unsigned.apk` is known to exist there today, so this is | |
| # precautionary rather than a fix — but the unscoped form is the one | |
| # that already shipped the wrong artifact once for the AAB. | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/apk/*' -name '*release-unsigned.apk') | |
| if [ "$checked" -eq 0 ]; then | |
| echo "::error::No APKs found to verify the package id against" | |
| exit 1 | |
| fi | |
| echo "All $checked APKs report package $EXPECTED_PACKAGE." | |
| - name: Check for release keystore | |
| id: keystore | |
| # `secrets` cannot be used in a step-level `if:`, so presence is turned | |
| # into an output here and gated on below. Deliberately keyed on the | |
| # keystore rather than `event_name == 'release'` so a workflow_dispatch | |
| # run with the secrets set can still exercise the full Play path. | |
| # | |
| # Checks all three secrets the signing step needs, not just the | |
| # keystore: a partially-configured set (typo'd secret name, one value | |
| # missing) would otherwise report present=true, spend several minutes on | |
| # the Gradle bundle build, and only then hard-fail at signing. | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| run: | | |
| if [ -n "${KEYSTORE_BASE64:-}" ] && [ -n "${KEYSTORE_PASSWORD:-}" ] \ | |
| && [ -n "${KEY_ALIAS:-}" ]; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No complete release keystore — skipping the AAB build (it could not be signed or uploaded anyway)." | |
| fi | |
| - name: Build release AAB (universal) | |
| # Skipped without a release keystore: the AAB can only be signed and | |
| # uploaded on `sign_mode = release` runs, so building it otherwise is a | |
| # full Gradle bundle build for an artifact that is discarded. | |
| if: steps.keystore.outputs.present == 'true' | |
| # Google Play requires an Android App Bundle, not an APK, for new apps — | |
| # Play generates the per-device splits itself from this single artifact. | |
| # Deliberately NOT --split-per-abi: that would emit one AAB per ABI, and | |
| # Play wants the universal bundle containing all four. The per-ABI APKs | |
| # above remain the sideload/GitHub-release path. | |
| # The Rust objects are already built by the previous step, so this is | |
| # mostly a Gradle repackage rather than a second cross-compile. | |
| working-directory: apps/geolibre-desktop | |
| run: npx tauri android build --aab | |
| env: | |
| VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }} | |
| - name: Verify built AAB package id | |
| # Third assertion, on the artifact that actually reaches Play. The two | |
| # checks above cover the Gradle script and the sideload APKs; the AAB is | |
| # produced by a separate Gradle task, so on the "verify rather than | |
| # trust" principle the rest of this workflow follows, the file Play | |
| # burns the package id from is not left as the only unchecked one. | |
| # | |
| # bundletool rather than aapt2: an AAB's manifest is protobuf-encoded, | |
| # not binary XML, so aapt2 cannot read it. Grepping the raw protobuf for | |
| # the package string was rejected — it would match an occurrence | |
| # anywhere in the manifest and cannot assert it is the `package` | |
| # attribute, which is a weak check wearing the costume of a strong one. | |
| if: steps.keystore.outputs.present == 'true' | |
| env: | |
| # Pinned; bump deliberately like the other tool versions above. | |
| BUNDLETOOL_VERSION: "1.18.3" | |
| # Pinned by digest, not just version, for the same reason the third-party | |
| # Actions above are pinned to full commit SHAs: a release asset can be | |
| # deleted and re-uploaded under the same tag. This one is executed | |
| # (`java -jar`) inside a job that holds contents: write and, further | |
| # down, the release signing secrets, so an unpinned binary would be the | |
| # softest link in an otherwise pinned workflow. | |
| BUNDLETOOL_SHA256: "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29" | |
| run: | | |
| set -euo pipefail | |
| jar="$RUNNER_TEMP/bundletool.jar" | |
| curl -fsSL -o "$jar" \ | |
| "https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar" | |
| # Verify before the jar is ever handed to java. | |
| echo "$BUNDLETOOL_SHA256 $jar" | sha256sum -c - | |
| mapfile -t aabs < <(find \ | |
| apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/bundle/*' -name '*.aab' | sort) | |
| if [ "${#aabs[@]}" -ne 1 ]; then | |
| echo "::error::Expected exactly one AAB under outputs/bundle/, found ${#aabs[@]}" | |
| printf '::error:: %s\n' "${aabs[@]:-}" | |
| exit 1 | |
| fi | |
| pkg="$("$JAVA_HOME/bin/java" -jar "$jar" dump manifest \ | |
| --bundle="${aabs[0]}" --xpath=/manifest/@package)" | |
| if [ "$pkg" != "$EXPECTED_PACKAGE" ]; then | |
| echo "::error::${aabs[0]} has package '$pkg', expected '$EXPECTED_PACKAGE'" | |
| exit 1 | |
| fi | |
| echo "AAB ${aabs[0]} reports package $EXPECTED_PACKAGE." | |
| - name: Verify 16 KB page alignment | |
| # Play rejects apps targeting Android 15+ whose native libs are not | |
| # 16 KB-aligned. The RUSTFLAGS above are what produce the alignment; | |
| # this step proves they actually took effect, so a silent regression | |
| # (flags dropped, Tauri overriding them, NDK change) fails here rather | |
| # than at upload time. | |
| # | |
| # Inspects the .so *inside the built APKs* — the bytes Play receives — | |
| # rather than a path under gen/android. Do not be tempted back to | |
| # `find gen/android -path '*/release/*'`: Gradle's output dirs are | |
| # arm64Release / armRelease / x86Release / x86_64Release (capital R), | |
| # so that pattern matches nothing and the check never runs. | |
| run: | | |
| set -euo pipefail | |
| objdump="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objdump" | |
| checked=0 | |
| bad=0 | |
| workdir="$(mktemp -d)" | |
| trap 'rm -rf "$workdir"' EXIT | |
| # Unpack the native libs from every release APK *and* the AAB into one | |
| # tree, then check all of them. The AAB matters most — it is what Play | |
| # receives — and it stores libraries under `base/lib/<abi>/`, not | |
| # `lib/<abi>/`, so it needs its own extract pattern. Checking only the | |
| # APKs would leave the Play artifact unverified. | |
| # | |
| # Each archive extracts into its own subdirectory and is checked for a | |
| # non-empty result immediately. Exit 11 ("no matching files") is | |
| # tolerated rather than fatal, because it is exactly the symptom of an | |
| # archive shipping no native code at all — a worse regression than | |
| # misalignment, and one the aggregate `checked -eq 0` guard below | |
| # cannot catch, since the other archives still contribute libraries | |
| # and the total stays non-zero. Every *other* non-zero exit (a | |
| # truncated archive, a full disk) is a real failure and is reported as | |
| # itself, so it is not misattributed to "no native libraries". | |
| archives=0 | |
| while IFS= read -r apk; do | |
| archives=$((archives + 1)) | |
| dest="$workdir/$archives" | |
| # `|| status=$?`, not `; status=$?`: under `set -e` the bare form | |
| # aborts the step at the failing unzip and never reaches the | |
| # assignment, which would turn the tolerated exit 11 into a build | |
| # failure. | |
| status=0 | |
| unzip -o -q "$apk" 'lib/*/*.so' -d "$dest" || status=$? | |
| if [ "$status" -ne 0 ] && [ "$status" -ne 11 ]; then | |
| echo "::error::unzip failed on $apk (exit $status)" | |
| exit 1 | |
| fi | |
| if [ "$(find "$dest" -name '*.so' 2>/dev/null | wc -l)" -eq 0 ]; then | |
| echo "::error::$apk contains no native libraries under lib/" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/apk/*' -name '*release-unsigned.apk') | |
| if [ "$archives" -eq 0 ]; then | |
| echo "::error::No release APKs found to verify" | |
| exit 1 | |
| fi | |
| # The AAB is only built on release-keystore runs, so its absence here | |
| # is not an error — but when present it must be verified too. | |
| while IFS= read -r aab; do | |
| archives=$((archives + 1)) | |
| dest="$workdir/$archives" | |
| status=0 | |
| unzip -o -q "$aab" 'base/lib/*/*.so' -d "$dest" || status=$? | |
| if [ "$status" -ne 0 ] && [ "$status" -ne 11 ]; then | |
| echo "::error::unzip failed on $aab (exit $status)" | |
| exit 1 | |
| fi | |
| if [ "$(find "$dest" -name '*.so' 2>/dev/null | wc -l)" -eq 0 ]; then | |
| echo "::error::$aab contains no native libraries under base/lib/" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/bundle/*' -name '*.aab') | |
| while IFS= read -r so; do | |
| checked=$((checked + 1)) | |
| # Per-file segment count. If objdump errors or emits no LOAD lines | |
| # for this .so (corrupt artifact, foreign arch, tool change), the | |
| # inner loop never runs, so without this the file would be counted | |
| # as "checked" while nothing was actually inspected. | |
| segments=0 | |
| # `objdump -p` prints one line per segment ending in `align 2**N` | |
| # (readelf wraps LOAD across two lines, which makes it far easier to | |
| # parse the wrong column). 2**14 == 16384 is the required minimum. | |
| # awk strips the `2**` prefix, not the shell: in a `${x#2**}` | |
| # expansion `*` is a glob, so that form silently yields `**14` and | |
| # every comparison below becomes a no-op that passes. | |
| while read -r exp; do | |
| segments=$((segments + 1)) | |
| # Treat unparseable output as a failure, never as a pass — a | |
| # tooling change that breaks the format must not read as "aligned". | |
| case "$exp" in | |
| '' | *[!0-9]*) | |
| echo "::error::$so: could not parse segment alignment ('$exp')" | |
| bad=$((bad + 1)) | |
| ;; | |
| *) | |
| if [ "$exp" -lt 14 ]; then | |
| echo "::error::$so has a LOAD segment aligned to 2**$exp (< 2**14)" | |
| bad=$((bad + 1)) | |
| fi | |
| ;; | |
| esac | |
| done < <("$objdump" -p "$so" | | |
| awk '$1 == "LOAD" { split($NF, a, /\*\*/); print a[2] }') | |
| if [ "$segments" -eq 0 ]; then | |
| echo "::error::$so: objdump reported no LOAD segments — nothing was verified" | |
| bad=$((bad + 1)) | |
| fi | |
| done < <(find "$workdir" -name '*.so') | |
| if [ "$checked" -eq 0 ]; then | |
| echo "::error::No .so files found inside the APKs — the check would pass vacuously" | |
| exit 1 | |
| fi | |
| if [ "$bad" -gt 0 ]; then | |
| # "problem(s)", not "segment(s)": $bad now also counts archives that | |
| # shipped no native libraries and files objdump could not read, not | |
| # just misaligned segments. | |
| echo "::error::$bad problem(s) found; Play will reject this build" | |
| exit 1 | |
| fi | |
| echo "All $checked native libraries are 16 KB-aligned." | |
| - name: Sign APKs and AAB | |
| id: sign | |
| # With release-keystore secrets set, the artifacts are signed for | |
| # distribution. Without them, they're signed with a throwaway debug | |
| # keystore so the CI APKs are still installable for testing (do NOT | |
| # publish those). Emits signed=release|debug so the release-upload step | |
| # can refuse to attach debug-signed APKs to a public GitHub Release. | |
| # | |
| # For Play, the keystore secrets hold the *upload* key, not the app | |
| # signing key: Play App Signing re-signs the bundle with the key Google | |
| # holds. A debug-signed AAB is useless — Play rejects it — so the AAB is | |
| # only produced when the release keystore is present. | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| build_tools="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)" | |
| store_pass_file="$RUNNER_TEMP/ks.pass" | |
| key_pass_file="$RUNNER_TEMP/key.pass" | |
| # Shred the password files on *every* exit path. Explicit `rm -f` at | |
| # each error branch cannot cover an unexpected failure: under | |
| # `set -e` a non-zero zipalign/apksigner/jarsigner aborts the script | |
| # mid-step, leaving the keystore and key passwords readable in | |
| # $RUNNER_TEMP for the rest of the job. | |
| trap 'rm -f "$store_pass_file" "$key_pass_file"' EXIT | |
| if [ -n "${KEYSTORE_BASE64:-}" ]; then | |
| # Fail fast if the keystore secret is set but its companions are not, | |
| # instead of a cryptic apksigner error later. | |
| if [ -z "${KEYSTORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ]; then | |
| echo "::error::ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS must be set when ANDROID_KEYSTORE_BASE64 is provided" | |
| exit 1 | |
| fi | |
| echo "Signing with the release keystore from secrets." | |
| echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" | |
| keystore="$RUNNER_TEMP/release.jks" | |
| printf '%s' "$KEYSTORE_PASSWORD" > "$store_pass_file" | |
| printf '%s' "${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" > "$key_pass_file" | |
| alias="$KEY_ALIAS" | |
| sign_mode=release | |
| else | |
| echo "::warning::No ANDROID_KEYSTORE_BASE64 secret set — signing with a throwaway debug keystore. Installable for testing only, NOT for distribution." | |
| sign_mode=debug | |
| keystore="$RUNNER_TEMP/debug.jks" | |
| "$JAVA_HOME/bin/keytool" -genkeypair -v -keystore "$keystore" \ | |
| -storepass android -keypass android -alias androiddebugkey \ | |
| -keyalg RSA -keysize 2048 -validity 10000 \ | |
| -dname "CN=Android Debug,O=Android,C=US" | |
| printf '%s' android > "$store_pass_file" | |
| printf '%s' android > "$key_pass_file" | |
| alias=androiddebugkey | |
| fi | |
| out="$RUNNER_TEMP/apks"; mkdir -p "$out" | |
| found=0 | |
| while IFS= read -r unsigned; do | |
| found=1 | |
| # e.g. app-arm64-v8a-release-unsigned.apk -> geolibre-arm64-v8a.apk | |
| abi="$(basename "$unsigned" | sed -E 's/^app-(.*)-release-unsigned\.apk$/\1/')" | |
| aligned="$RUNNER_TEMP/aligned-$abi.apk" | |
| signed="$out/geolibre-android-$abi.apk" | |
| # -P 16, not -p: the 16 KB requirement has two independent axes. | |
| # The RUSTFLAGS above fix the ELF segment alignment *inside* the | |
| # .so; this fixes the .so's byte offset *within the APK zip*, which | |
| # is what lets the OS mmap it directly under extractNativeLibs=false. | |
| # Lowercase -p only guarantees 4 KB. AGP currently happens to emit | |
| # 16 KB-aligned offsets anyway, so -p passes today by luck; -P 16 | |
| # states the requirement so a future AGP change cannot silently | |
| # regress it. (-P needs build-tools 35+; this workflow pins 36.) | |
| "$build_tools/zipalign" -P 16 -f 4 "$unsigned" "$aligned" | |
| # Pass passwords via files (pass:file:) so they never appear in the | |
| # process argument list / CI logs. | |
| "$build_tools/apksigner" sign --ks "$keystore" \ | |
| --ks-pass "file:$store_pass_file" --key-pass "file:$key_pass_file" \ | |
| --ks-key-alias "$alias" --out "$signed" "$aligned" | |
| "$build_tools/apksigner" verify "$signed" | |
| # Verify the zip-entry axis on the final artifact. The ELF axis is | |
| # checked before signing; this is the one zipalign controls, and | |
| # checking it here (post-sign) covers the bytes Play receives. | |
| "$build_tools/zipalign" -c -P 16 -v 4 "$signed" > /dev/null || { | |
| echo "::error::$signed is not 16 KB zip-aligned; Play will reject it" | |
| exit 1 | |
| } | |
| echo "signed $signed ($(du -h "$signed" | cut -f1))" | |
| done < <(find apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/apk/*' -name '*release-unsigned.apk') | |
| if [ "$found" -eq 0 ]; then | |
| echo "::error::No release-unsigned APKs found"; exit 1 | |
| fi | |
| # AAB for Google Play. jarsigner, not apksigner: apksigner only handles | |
| # APKs. Only signed with a real upload key — a debug-signed bundle is | |
| # rejected by Play, so shipping one would just be a confusing artifact. | |
| if [ "$sign_mode" = release ]; then | |
| aab_out="$RUNNER_TEMP/aab"; mkdir -p "$aab_out" | |
| # Scope to outputs/bundle/. A bare `-name '*.aab'` is NOT enough: | |
| # the build also leaves AGP's internal | |
| # app/build/intermediates/intermediary_bundle/.../intermediary-bundle.aab | |
| # so an unscoped match plus `head -1` depends on find's traversal | |
| # order and can select the intermediate — signing and uploading the | |
| # wrong artifact to Play. Verified locally: two .aab exist after a | |
| # successful build. | |
| # | |
| # `outputs/bundle` are literal lowercase directories, so this does | |
| # not reintroduce the camelCase trap (`universalRelease/` is matched | |
| # by the wildcard, never spelled out). | |
| mapfile -t aab_candidates < <(find \ | |
| apps/geolibre-desktop/src-tauri/gen/android \ | |
| -path '*/outputs/bundle/*' -name '*.aab' | sort) | |
| if [ "${#aab_candidates[@]}" -eq 0 ]; then | |
| echo "::error::No AAB found under outputs/bundle/"; exit 1 | |
| fi | |
| if [ "${#aab_candidates[@]}" -gt 1 ]; then | |
| # Ambiguity must not be resolved by picking one arbitrarily. | |
| echo "::error::Expected exactly one AAB, found ${#aab_candidates[@]}:" | |
| printf '::error:: %s\n' "${aab_candidates[@]}" | |
| exit 1 | |
| fi | |
| aab="${aab_candidates[0]}" | |
| echo "Using AAB: $aab" | |
| signed_aab="$aab_out/geolibre-android.aab" | |
| cp "$aab" "$signed_aab" | |
| "$JAVA_HOME/bin/jarsigner" -keystore "$keystore" \ | |
| -storepass:file "$store_pass_file" -keypass:file "$key_pass_file" \ | |
| -sigalg SHA256withRSA -digestalg SHA-256 "$signed_aab" "$alias" | |
| "$JAVA_HOME/bin/jarsigner" -verify "$signed_aab" | |
| echo "signed $signed_aab ($(du -h "$signed_aab" | cut -f1))" | |
| else | |
| echo "::warning::Skipping AAB signing — no release keystore. The Play upload artifact is not produced on this run." | |
| fi | |
| # Emit the outcome only after every artifact has signed and verified, | |
| # so a downstream always() step can never read signed=release on a | |
| # failure. | |
| echo "signed=$sign_mode" >> "$GITHUB_OUTPUT" | |
| - name: Upload signed APKs | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: geolibre-android-release-apks | |
| path: ${{ runner.temp }}/apks/*.apk | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Upload Play AAB | |
| # Separate artifact from the APKs: this is the file you upload to the | |
| # Play Console, and it only exists on release-keystore runs. Deliberately | |
| # NOT attached to the GitHub Release — an .aab is not user-installable | |
| # and would only confuse people looking for a sideload download. | |
| if: steps.sign.outputs.signed == 'release' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: geolibre-android-play-aab | |
| path: ${{ runner.temp }}/aab/*.aab | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Attach APKs to GitHub Release | |
| # Only on a published release, and only when the APKs were signed with the | |
| # real release keystore — never publish debug-signed builds as official | |
| # downloads. workflow_dispatch runs still get the CI artifact above. | |
| if: github.event_name == 'release' && steps.sign.outputs.signed == 'release' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ github.event.release.tag_name }} | |
| run: gh release upload "$TAG" "$RUNNER_TEMP"/apks/*.apk --clobber | |
| - name: Note skipped release upload | |
| # Surface why a release run did not attach APKs (missing keystore secrets), | |
| # so it does not look like a silent failure. | |
| if: github.event_name == 'release' && steps.sign.outputs.signed != 'release' | |
| run: | | |
| echo "::warning::APKs were debug-signed (no ANDROID_KEYSTORE_BASE64 secret) and were NOT attached to the release. They are available as the CI artifact for testing only." |