Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions .github/actions/walletkit-build-and-maestro/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,21 +340,54 @@ runs:
if: inputs.platform == 'android'
uses: SimonMarquis/android-accept-licenses@8d8deab5b7ab2aaea9ed69f0b802e53173f21fd1 # v1

# Gradle/AGP auto-installs the build.gradle-requested NDK at build time, and
# that download fails on the CI runner (InstallFailedException: ndk;27.0.12077973).
# Pre-install it explicitly via the cmdline-tools sdkmanager (licenses accepted
# above) so the build finds it instead of trying — and failing — to fetch it.
# Keep the version in lockstep with the wallet's build.gradle ndkVersion.
- name: Install Android NDK 27.0.12077973
# RN/AGP auto-installs the build-requested NDK at build time, and that
# on-the-fly download intermittently lands corrupted on CI runners
# ("Archive is not a ZIP archive" -> InstallFailedException), failing the
# build but passing on rerun. Pre-install it via the cmdline-tools sdkmanager
# (licenses accepted above) so the build finds it locally instead of fetching.
#
# The version is pinned once, in the wallet's app.json via the
# withAndroidNdkVersion config plugin. That single source of truth drives
# both the native build (the plugin injects ext.ndkVersion into the generated
# android/build.gradle at prebuild) and this pre-install (read below), so the
# installed NDK can't drift from the one the build wants — the drift that
# broke CI when the version was hardcoded separately here. Retry the install
# so a corrupted archive self-heals instead of failing the run.
- name: Install Android NDK
if: inputs.platform == 'android'
shell: bash
# Licenses are accepted by the previous step, so `sdkmanager` reads little
# stdin and `yes` takes SIGPIPE when it exits. Disable pipefail (the default
# shell runs with -e -o pipefail) so that broken pipe doesn't fail the step —
# `sdkmanager`'s own exit code (last in the pipe) is what governs.
working-directory: ${{ steps.paths.outputs.wallet_root }}
run: |
set +o pipefail
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;27.0.12077973"
set -euo pipefail
# Read ndkVersion from the withAndroidNdkVersion plugin entry in app.json
# (the same value the plugin applies to the build).
NDK_VERSION="$(node -e "const p=(require('./app.json').expo.plugins||[]).find(x=>Array.isArray(x)&&x[0]==='./plugins/withAndroidNdkVersion.js');process.stdout.write((p&&p[1]&&p[1].ndkVersion)||'')")"
if [ -z "${NDK_VERSION:-}" ]; then
echo "Error: ndkVersion not set via ./plugins/withAndroidNdkVersion.js in app.json"
exit 1
fi
echo "Resolved ndkVersion=$NDK_VERSION (from app.json withAndroidNdkVersion plugin)"
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
status=1
for attempt in 1 2 3; do
echo "=== sdkmanager NDK install attempt $attempt/3 (ndk;$NDK_VERSION) ==="
# Licenses accepted above, so `sdkmanager` reads little stdin and `yes`
# takes SIGPIPE when it exits — disable pipefail so that broken pipe
# doesn't mask sdkmanager's own (last-in-pipe) exit code. The `if`
# condition is also exempt from `set -e`, so a failed install is
# captured and retried instead of aborting the step.
set +o pipefail
if yes | "$SDKMANAGER" --install "ndk;$NDK_VERSION"; then
status=0
else
status=$?
fi
set -o pipefail
Comment thread
Copilot marked this conversation as resolved.
[ "$status" -eq 0 ] && break
echo "NDK install attempt $attempt failed (exit $status); clearing partial download and retrying"
rm -rf "$ANDROID_HOME/ndk/$NDK_VERSION"
done
[ "$status" -eq 0 ] || { echo "Error: NDK install failed after 3 attempts"; exit 1; }

- name: Expo Prebuild (Android)
if: inputs.platform == 'android'
Expand Down
20 changes: 20 additions & 0 deletions wallets/rn_cli_wallet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ from `app.json` / `app.config.js` / `plugins/` / `assets/`. `yarn ios` / `yarn a
run prebuild automatically when the folders are missing. Never hand-edit `ios/`
or `android/`; change the Expo config or a config plugin and re-run prebuild.

### Android NDK version — re-check on every RN/Expo upgrade
The Android NDK version is pinned in `app.json` via `plugins/withAndroidNdkVersion.js`
(a plugin arg: `["./plugins/withAndroidNdkVersion.js", { "ndkVersion": "…" }]`). This
one value is the single source of truth: the plugin injects it into the generated
`android/build.gradle`, and the E2E workflow
(`.github/actions/walletkit-build-and-maestro`) reads the same `app.json` entry to
pre-install that exact NDK via `sdkmanager`. The pre-install exists because RN
otherwise fetches the NDK on the fly at build time, and that download intermittently
lands corrupted on CI runners (`Archive is not a ZIP archive` →
`InstallFailedException`) — failing the build but passing on rerun.

**On any `react-native` / `expo` bump, verify this value still matches Expo's default
NDK and update it if not.** Expo owns the default (`ExpoRootProjectPlugin`:
`setIfNotExist("ndkVersion") { … }`) and bumps it across SDK versions. If our pin
drifts below what the new build wants, the pre-install seeds the wrong NDK, Gradle
re-fetches the right one at build time, and the corrupt-download flake returns. To
confirm the current default: run `yarn prebuild` and check the `ndkVersion` line in
`android/build.gradle`, or read the default in
`node_modules/expo-modules-autolinking/.../ExpoRootProjectPlugin.kt`.

### Setup
```bash
yarn install
Expand Down
4 changes: 4 additions & 0 deletions wallets/rn_cli_wallet/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
"project": "w3w-react-native"
}
],
[
"./plugins/withAndroidNdkVersion.js",
{ "ndkVersion": "27.1.12297006" }
],
"./plugins/withAndroidVariants.js",
"./plugins/withAndroidVariantIcons.js"
]
Expand Down
64 changes: 64 additions & 0 deletions wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Expo config plugin: pins the Android NDK version in the prebuild-generated
// android/build.gradle so it can't drift from what CI pre-installs.
//
// Why: React Native's Android build fetches the NDK on the fly at build time,
// and that download intermittently lands corrupted on CI runners
// ("Archive is not a ZIP archive" -> InstallFailedException) — failing the
// build but passing on rerun. CI works around it by pre-installing the NDK via
// sdkmanager, which means CI must know the exact version. Expo owns the default
// (ExpoRootProjectPlugin: `setIfNotExist("ndkVersion") { ... "27.1.12297006" }`)
// and bumps it on SDK/RN upgrades, so hardcoding it separately in the CI action
// silently drifted and reintroduced the flaky fetch.
//
// This plugin makes app.json the single source of truth: the version passed here
// ["./plugins/withAndroidNdkVersion.js", { "ndkVersion": "27.1.12297006" }]
// drives the native build (injected below) AND the CI pre-install (which reads
// the same app.json entry). Because android/build.gradle sets rootProject.ext
// .ndkVersion BEFORE `apply plugin: "expo-root-project"`, Expo's setIfNotExist
// default is a no-op and our value wins — while app/build.gradle keeps reading
// `ndkVersion rootProject.ext.ndkVersion` unchanged.
const { withProjectBuildGradle } = require('@expo/config-plugins');

const EXPO_ROOT_APPLY = 'apply plugin: "expo-root-project"';

function setNdkVersion(buildGradle, ndkVersion) {
const line = `ext.ndkVersion = "${ndkVersion}"`;
// Idempotent — prebuild regenerates build.gradle, but guard double application.
if (buildGradle.includes(line)) {
return buildGradle;
}
if (!buildGradle.includes(EXPO_ROOT_APPLY)) {
throw new Error(
`withAndroidNdkVersion: could not find '${EXPO_ROOT_APPLY}' in android/build.gradle`,
);
}
// Insert before expo-root-project applies so its setIfNotExist("ndkVersion")
// keeps our value instead of overwriting it with the Expo default.
return buildGradle.replace(EXPO_ROOT_APPLY, `${line}\n${EXPO_ROOT_APPLY}`);
}

const withAndroidNdkVersion = (config, { ndkVersion } = {}) => {
if (!ndkVersion) {
throw new Error(
'withAndroidNdkVersion: an { ndkVersion } option is required, e.g. ' +
'["./plugins/withAndroidNdkVersion.js", { "ndkVersion": "27.1.12297006" }]',
);
}
return withProjectBuildGradle(config, cfg => {
// Throw rather than silently no-op: if a future Expo SDK generates a Kotlin
// DSL (build.gradle.kts) root project, skipping would leave the NDK unpinned
// and quietly reintroduce the corrupt-download flake this plugin prevents.
if (cfg.modResults.language !== 'groovy') {
throw new Error(
`withAndroidNdkVersion: expected a groovy build.gradle, got '${cfg.modResults.language}'. ` +
'Update this plugin to support Kotlin DSL (build.gradle.kts).',
);
}
cfg.modResults.contents = setNdkVersion(cfg.modResults.contents, ndkVersion);
return cfg;
});
};

module.exports = withAndroidNdkVersion;
// Exported for unit testing the transform in isolation.
module.exports.setNdkVersion = setNdkVersion;
Loading