-
Notifications
You must be signed in to change notification settings - Fork 0
412 lines (384 loc) · 19.8 KB
/
Copy pathandroid.yml
File metadata and controls
412 lines (384 loc) · 19.8 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
name: Android
on:
push:
branches: [main]
paths:
- "crates/rustysnes-android/**"
- "crates/rustysnes-mobile/**"
- "crates/rustysnes-monetization/**"
- "crates/rustysnes-gfx-shaders/**"
- "android/**"
- "Cargo.lock"
- ".github/workflows/android.yml"
pull_request:
paths:
- "crates/rustysnes-android/**"
- "crates/rustysnes-mobile/**"
- "crates/rustysnes-monetization/**"
- "crates/rustysnes-gfx-shaders/**"
- "android/**"
- "Cargo.lock"
- ".github/workflows/android.yml"
schedule:
# A ~60-day refresh, matching `ios.yml`'s cadence and for the same reason: the NDK and the
# Android Gradle Plugin move under us, and this workflow is the project's only exercise of
# either. Offset from `ios.yml` (1st) to the 15th so the two mobile jobs do not collide.
- cron: "0 8 15 */2 *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
env:
CARGO_NET_RETRY: "10"
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
# The four ABIs Play accepts for a 64-bit-required listing plus the two 32-bit ones the
# `minSdk = 21` floor still reaches. `cargo ndk` maps each to its NDK triple.
- name: Add the Android targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android \
i686-linux-android
- name: Confirm the Android targets actually installed
run: rustup target list --installed | grep -E 'android' | sort
# The NDK comes from the runner image's own Android SDK rather than a third-party action.
# `ubuntu-latest` ships `$ANDROID_HOME` with `sdkmanager`, so this adds no new supply-chain
# surface — the v1.26.0 hardening pass is the reason that matters here.
#
# r27 is the floor. Note that pinning the NDK is NOT by itself what produces 16 KB-aligned
# libraries — see the build step below, which passes the alignment explicitly because the
# first CI run proved the NDK version does not decide it.
- name: Install the NDK from the runner's Android SDK
run: |
set -euo pipefail
# `sdkmanager` is NOT on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set —
# it lives under the SDK's own cmdline-tools. Found by this workflow's first CI run.
: "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}"
sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sdk" ]; then
echo "::error::no sdkmanager at $sdk"
ls -la "$ANDROID_HOME/cmdline-tools" || true
exit 1
fi
"$sdk" --install "ndk;27.2.12479018"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV"
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked --version ^3
# `--build-std` is deliberately NOT used: the shipped build must be reproducible from a
# stable toolchain, and `rust-toolchain.toml` pins 1.96 stable.
#
# `max-page-size=16384` is passed EXPLICITLY rather than relied upon as an NDK default. This
# workflow's first CI run failed the gate below with 4 KB-aligned LOAD segments, and the
# cause was measured, not guessed: with the same NDK r27c, `cargo-ndk` 3.5.4 emits 0x1000 and
# 4.1.2 emits 0x4000 — the linker invocation decides it, not the NDK. Passing the flag makes
# the result independent of which `cargo-ndk` resolves, which a version range cannot promise.
# Applied to the 64-bit ABIs only, matching what the gate checks and what Play requires; the
# 32-bit libraries stay 4 KB by design.
- name: Build the JNI libraries (64-bit ABIs, 16 KB-aligned)
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
run: |
cargo ndk \
-t arm64-v8a -t x86_64 \
-o android/app/src/main/jniLibs \
build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization
# Only the two 32-bit ABIs here. Re-listing the 64-bit ones would relink them without the
# flag above and overwrite the aligned output with 4 KB libraries — the gate would catch it,
# but silently undoing the previous step is worth not writing in the first place.
- name: Build the JNI libraries (32-bit ABIs)
run: |
cargo ndk \
-t armeabi-v7a -t x86 \
-o android/app/src/main/jniLibs \
build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization
- name: Show what was produced
run: find android/app/src/main/jniLibs -name '*.so' -printf '%p %s bytes\n' | sort
# ---------------------------------------------------------------------------------------
# The 16 KB page-alignment gate.
#
# Play requires every shipped `.so` to have 16 KB-aligned LOAD segments for devices with a
# 16 KB page size; a 4 KB-aligned library simply fails to load there.
#
# This gate earned its place on its first run: it failed, and the 4 KB libraries were real.
# It was written expecting to be a formality that confirmed an NDK default — the build step
# above records what the measurement actually showed. Treat a failure here as a genuine
# finding, not as a broken check.
#
# Checked on the 64-bit ABIs only: the requirement is a property of 64-bit page sizes, and
# the 32-bit libraries are 4 KB-aligned by design.
# ---------------------------------------------------------------------------------------
- name: Assert 16 KB ELF page alignment on the 64-bit ABIs
run: |
set -euo pipefail
# The requirement is "aligned to AT LEAST 16 KB", so the test is divisibility, not
# equality. An exact `== 0x4000` match rejects a library that is MORE strictly aligned --
# JNA's `libjnidispatch.so` ships at `0x10000` (64 KB), which is perfectly valid, and the
# equality test flagged it as a violation on this workflow's first APK-level run. Bash
# parses the `0x` prefix inside `$(( ))`.
aligned16k() {
local a
for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do
[ $(( a % 16384 )) -eq 0 ] || return 1
done
return 0
}
fail=0
checked=0
for abi in arm64-v8a x86_64; do
for so in android/app/src/main/jniLibs/"$abi"/*.so; do
[ -e "$so" ] || { echo "::error::no .so produced for $abi"; exit 1; }
checked=$((checked + 1))
if aligned16k "$so"; then
echo "ok: $so"
else
echo "::error::$so has LOAD segment(s) not a multiple of 16 KB"
readelf -lW "$so" | awk '$1 == "LOAD"'
fail=1
fi
done
done
if [ "$checked" -eq 0 ]; then
echo "::error::the alignment gate checked nothing — the .so glob matched no files"
exit 1
fi
exit "$fail"
# The JDK is preinstalled on `ubuntu-latest`; Gradle comes from the committed wrapper rather
# than the runner's copy, so CI and a developer's machine build with the SAME Gradle. The
# runner image's preinstalled version moves under us, which is the drift a wrapper exists to
# remove — and until now this project had no wrapper at all, so every build depended on
# whatever Gradle happened to be installed.
- name: Show the Java, and the Gradle the wrapper pins
working-directory: android
run: |
java -version
./gradlew --version
# `assembleDebug`, not `assembleRelease`: a release build needs signing material this
# workflow deliberately does not hold (see `docs/mobile-readiness.md`). What this proves is
# that the Kotlin sources, the manifest, and the freshly built `.so`s package together.
# `RUSTFLAGS` again, because Gradle's own `cargoNdkBuild` task re-runs `cargo ndk` for all
# three crates and does NOT inherit the flag from the steps above -- it inherits this process
# environment instead. Without it the APK ships 4 KB-aligned libraries even though the
# standalone build above produced 16 KB ones, which is exactly the false pass the APK-level
# gate below now catches.
- name: Assemble the debug APK
working-directory: android
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
run: ./gradlew --no-daemon assembleDebug
- name: Confirm the APK carries every ABI
run: |
set -euo pipefail
apk=$(find android -name '*-debug.apk' | head -1)
[ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; }
echo "APK: $apk"
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
if unzip -l "$apk" | grep -q "lib/$abi/"; then
echo "ok: $abi present"
else
echo "::error::the APK is missing lib/$abi/ — jniLibs packaging regressed"
exit 1
fi
done
# The authoritative alignment gate: the APK is what ships, and it carries THREE libraries per
# ABI (`librustysnes_android.so`, `librustysnes_mobile.so`, `librustysnes_monetization.so`),
# built by Gradle's own `cargoNdkBuild`. The earlier jniLibs gate checks a pre-Gradle build and
# would pass while the packaged libraries were misaligned -- a false pass CodeRabbit caught on
# this PR. Both are kept: the early one fails fast, this one is the truth.
- name: Assert 16 KB page alignment inside the APK
run: |
set -euo pipefail
apk=$(find android -name '*-debug.apk' | head -1)
[ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; }
# Same divisibility test as the jniLibs gate above -- see its comment for why an exact
# `0x4000` match is wrong.
aligned16k() {
local a
for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do
[ $(( a % 16384 )) -eq 0 ] || return 1
done
return 0
}
work=$(mktemp -d)
unzip -q "$apk" 'lib/*' -d "$work"
fail=0
# EVERY library in the APK is checked, not only ours: Play's requirement is a property of
# the shipped package, so a misaligned third-party dependency fails the listing just as
# surely as a misaligned one of ours.
for abi in arm64-v8a x86_64; do
for so in "$work/lib/$abi"/*.so; do
[ -e "$so" ] || { echo "::error::the APK has no .so for $abi"; exit 1; }
if aligned16k "$so"; then
echo "ok: $abi/$(basename "$so")"
else
echo "::error::$(basename "$so") ($abi) has LOAD segment(s) not a multiple of 16 KB"
readelf -lW "$so" | awk '$1 == "LOAD"'
fail=1
fi
done
# Presence, by name, for the three this project builds. A count would have to be
# revised every time a dependency adds or drops a native library -- and a wrong count
# is what this gate got wrong first time round, failing on a correct APK.
for want in librustysnes_android.so librustysnes_mobile.so librustysnes_monetization.so; do
[ -e "$work/lib/$abi/$want" ] || {
echo "::error::the APK is missing lib/$abi/$want -- jniLibs packaging regressed"
fail=1
}
done
done
exit "$fail"
# The RELEASE build path, and it is deliberately UNSIGNED.
#
# Signing material is the project owner's to provision and this workflow does not hold it, so
# a *signed* release build is out of reach here. An unsigned one is not: `assembleRelease`
# still runs R8, resource shrinking and the release manifest merge, which is where
# release-only breakage actually lives -- a missing keep rule strips a class the UniFFI
# bindings reach reflectively, and the debug build never notices because it does not minify.
#
# `isMinifyEnabled` is `false` in `app/build.gradle.kts` today, so this currently proves the
# release variant assembles at all. It is wired now rather than when minification is turned
# on, because the moment it is turned on this step is what catches the fallout.
- name: Assemble the release APK (unsigned)
working-directory: android
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
run: ./gradlew --no-daemon assembleRelease
# The same 16 KB gate as the debug APK's, on the release variant. Not redundant: the release
# variant has its own packaging and its own shrinking, so alignment has to be proven on the
# artifact that would actually ship, not inferred from the one that would not.
- name: Assert 16 KB page alignment inside the RELEASE APK
run: |
set -euo pipefail
apk=$(find android/app/build/outputs/apk/release -name '*.apk' | head -1)
test -n "$apk" || { echo "no release APK was produced"; exit 1; }
work=$(mktemp -d)
unzip -q "$apk" 'lib/*' -d "$work"
# The SAME three properties the debug gate above settled on, and the first version of
# this step got all three wrong by re-implementing instead of reusing:
# 1. DIVISIBILITY, not string equality. `0x8000` and `0x20000` are valid -- the
# requirement is "at least 16 KB". The debug gate's own comment records that an
# equality test wrongly flagged JNA's 64 KB-aligned libjnidispatch.so.
# 2. EVERY LOAD segment, not just the first (`awk ... exit` read one and stopped).
# 3. A `checked == 0` guard. Without it a missing ABI directory makes the glob match
# nothing and the gate PASSES on an APK with no libraries in it at all.
aligned16k() {
local a
for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do
[ $(( a % 16384 )) -eq 0 ] || return 1
done
return 0
}
fail=0
checked=0
for abi in arm64-v8a x86_64; do
for so in "$work"/lib/"$abi"/*.so; do
[ -e "$so" ] || { echo "::error::the release APK carries no .so for $abi"; exit 1; }
checked=$((checked + 1))
if aligned16k "$so"; then
echo "ok: $abi/$(basename "$so")"
else
echo "::error::$so has LOAD segment(s) not a multiple of 16 KB"
readelf -lW "$so" | awk '$1 == "LOAD"'
fail=1
fi
done
done
if [ "$checked" -eq 0 ]; then
echo "::error::the release alignment gate checked nothing"
exit 1
fi
exit "$fail"
- name: Upload the APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rustysnes-debug-apk
path: android/app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 14
# The UniFFI RUNTIME smoke test, in its own job on purpose.
#
# `build` above proves the bindings COMPILE -- `MainActivity` calls `MobileCore` directly, so
# bindgen output that drifted from the Rust API fails the Kotlin compile there. What no build can
# prove is that `System.loadLibrary` finds the `.so` for the device's ABI, that JNA's mapping
# matches the symbols in it, and that a call marshals across and returns. This project has already
# shipped one native Android crash that a build could not have caught.
#
# A separate job, not another step in `build`: an emulator is the flakiest thing in this workflow,
# and a flaky step inside `build` would put the 16 KB alignment gates -- which are not flaky, and
# which gate a real Play requirement -- behind an AVD boot.
#
# The emulator runs x86_64, but that is NOT the set of Rust targets this job needs. Gradle's
# `cargoNdkBuild` builds every ABI in `app/build.gradle.kts`'s `cargoAbis` map -- arm64-v8a AND
# x86_64 -- before the instrumented test can install anything, so both targets must be installed
# or the task fails on `can't find crate for core`. Installing only x86_64 because "the emulator
# runs the host ABI" confuses what the emulator RUNS with what the build COMPILES; that is
# exactly how this job failed on its first run.
smoke:
runs-on: ubuntu-latest
env:
CARGO_NET_RETRY: "10"
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
- name: Add the Android targets Gradle's cargoNdkBuild needs
run: rustup target add x86_64-linux-android aarch64-linux-android
# The same NDK and the same discovery as the `build` job, deliberately: `sdkmanager` is NOT
# on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set, which that job found the
# hard way on its first run. Two jobs building the same libraries with different NDKs would
# also make a divergence between them impossible to attribute.
- name: Install the NDK from the runner's Android SDK
run: |
set -euo pipefail
: "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}"
sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sdk" ]; then
echo "::error::no sdkmanager at $sdk"
ls -la "$ANDROID_HOME/cmdline-tools" || true
exit 1
fi
"$sdk" --install "ndk;27.2.12479018"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV"
- name: Install cargo-ndk
run: cargo install cargo-ndk --locked --version ^3
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v5
with:
distribution: temurin
java-version: "17"
# KVM has to be enabled explicitly on GitHub's Linux runners, or the AVD falls back to
# software rendering and the boot times out rather than failing with a clear reason.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# `RUSTFLAGS` for the same reason the `build` job sets it on its Gradle step: Gradle's
# `cargoNdkBuild` re-runs `cargo ndk` in its own process and inherits this environment, not
# the flags of any earlier step.
# Bounded, for the reason the job exists: the split contains the blast radius of a flaky
# emulator but does not bound its runtime, and an AVD that never reaches boot-complete would
# otherwise burn the whole job timeout. The observed run is ~6.5 minutes.
- name: Run the instrumented UniFFI smoke test
timeout-minutes: 25
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
env:
RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384"
with:
api-level: 34
arch: x86_64
target: google_apis
disable-animations: true
working-directory: android
script: ./gradlew --no-daemon connectedDebugAndroidTest