-
-
Notifications
You must be signed in to change notification settings - Fork 570
575 lines (547 loc) · 29.5 KB
/
Copy pathandroid.yml
File metadata and controls
575 lines (547 loc) · 29.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
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."