From 00d71e6ac9aa83a43fe4fdaf621b36e151849b06 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:37:38 -0300 Subject: [PATCH 1/5] fix(rn_cli_wallet): pin Android NDK version to stop flaky E2E builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android E2E job intermittently failed with a corrupted NDK download (ndk;27.1.12297006 -> "Archive is not a ZIP archive" -> InstallFailedException), passing on rerun. The action already pre-installed the NDK to avoid the on-the-fly fetch, but its hardcoded 27.0.12077973 had drifted from the 27.1.12297006 the Expo SDK 56 build now wants — so it seeded the wrong NDK and Gradle re-fetched the right one at build time (the flaky path). Make app.json the single source of truth via a new withAndroidNdkVersion config plugin: it injects ext.ndkVersion into the generated android/build.gradle (before expo-root-project applies, so Expo's setIfNotExist default is a no-op), and the E2E action reads the same app.json entry to pre-install that exact NDK (now with a retry so a corrupt archive self-heals). Documents the upgrade-time check in AGENTS.md. Co-Authored-By: Claude Opus 4.8 --- .../walletkit-build-and-maestro/action.yml | 52 +++++++++++++---- wallets/rn_cli_wallet/AGENTS.md | 20 +++++++ wallets/rn_cli_wallet/app.json | 4 ++ .../plugins/withAndroidNdkVersion.js | 57 +++++++++++++++++++ 4 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js diff --git a/.github/actions/walletkit-build-and-maestro/action.yml b/.github/actions/walletkit-build-and-maestro/action.yml index cbf17d30..bf2ad889 100644 --- a/.github/actions/walletkit-build-and-maestro/action.yml +++ b/.github/actions/walletkit-build-and-maestro/action.yml @@ -340,21 +340,49 @@ 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. + set +o pipefail + yes | "$SDKMANAGER" --install "ndk;$NDK_VERSION" + status=$? + set -o pipefail + [ "$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' diff --git a/wallets/rn_cli_wallet/AGENTS.md b/wallets/rn_cli_wallet/AGENTS.md index 66e51f45..755e3e24 100644 --- a/wallets/rn_cli_wallet/AGENTS.md +++ b/wallets/rn_cli_wallet/AGENTS.md @@ -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 diff --git a/wallets/rn_cli_wallet/app.json b/wallets/rn_cli_wallet/app.json index 241ea260..33b85b82 100644 --- a/wallets/rn_cli_wallet/app.json +++ b/wallets/rn_cli_wallet/app.json @@ -83,6 +83,10 @@ "project": "w3w-react-native" } ], + [ + "./plugins/withAndroidNdkVersion.js", + { "ndkVersion": "27.1.12297006" } + ], "./plugins/withAndroidVariants.js", "./plugins/withAndroidVariantIcons.js" ] diff --git a/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js b/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js new file mode 100644 index 00000000..0abdcaeb --- /dev/null +++ b/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js @@ -0,0 +1,57 @@ +// 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 => { + if (cfg.modResults.language === 'groovy') { + 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; From 815042fd5033527c6e8464cd85d415bf60b17c1b Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:18:18 -0300 Subject: [PATCH 2/5] fix(rn_cli_wallet): throw on non-groovy build.gradle in NDK plugin Address PR review: the plugin silently no-op'd when the generated root build.gradle wasn't groovy. If a future Expo SDK emits a Kotlin DSL (build.gradle.kts) root project, that skip would leave the NDK unpinned and quietly reintroduce the corrupt-download flake this plugin exists to prevent. Throw loudly instead so the failure is immediate and obvious. Co-Authored-By: Claude Opus 4.8 --- .../rn_cli_wallet/plugins/withAndroidNdkVersion.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js b/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js index 0abdcaeb..08cdf550 100644 --- a/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js +++ b/wallets/rn_cli_wallet/plugins/withAndroidNdkVersion.js @@ -45,9 +45,16 @@ const withAndroidNdkVersion = (config, { ndkVersion } = {}) => { ); } return withProjectBuildGradle(config, cfg => { - if (cfg.modResults.language === 'groovy') { - cfg.modResults.contents = setNdkVersion(cfg.modResults.contents, ndkVersion); + // 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; }); }; From 3ee5c6e9a73e07331f71e32ecd9acb2a0f424ede Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:39:58 -0300 Subject: [PATCH 3/5] fix(ci): make NDK install retry work under set -e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review (Copilot): the retry loop disabled pipefail but left errexit on, so a non-zero `yes | sdkmanager` aborted the step before `status=$?` ran — attempts 2/3 never executed. Capture the result via an `if` (exempt from set -e) so failures are caught and retried as intended. Co-Authored-By: Claude Opus 4.8 --- .../actions/walletkit-build-and-maestro/action.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/actions/walletkit-build-and-maestro/action.yml b/.github/actions/walletkit-build-and-maestro/action.yml index bf2ad889..21b31e06 100644 --- a/.github/actions/walletkit-build-and-maestro/action.yml +++ b/.github/actions/walletkit-build-and-maestro/action.yml @@ -373,10 +373,15 @@ runs: 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. + # 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 - yes | "$SDKMANAGER" --install "ndk;$NDK_VERSION" - status=$? + if yes | "$SDKMANAGER" --install "ndk;$NDK_VERSION"; then + status=0 + else + status=$? + fi set -o pipefail [ "$status" -eq 0 ] && break echo "NDK install attempt $attempt failed (exit $status); clearing partial download and retrying" From 6173baf905603bda84df6d9a04bc81f856b15f7f Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:38:28 -0300 Subject: [PATCH 4/5] fix(ci): pre-install NDK in release-android-base to stop flaky release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared release-android-base workflow (used by Release WalletKit / POS / AppKit) fetched the NDK on the fly during Build APK, hitting the same corrupt download flake ("Archive is not a ZIP archive" -> InstallFailedException) that PR #564 already fixed for the E2E path — but only for the maestro composite action. Add the same sdkmanager pre-install + retry here, reading the exact version from the generated android/build.gradle so it works for every caller and can't drift. Apps that don't pin ndkVersion are skipped (no regression). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-android-base.yaml | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/release-android-base.yaml b/.github/workflows/release-android-base.yaml index bbf18450..d74efe1c 100644 --- a/.github/workflows/release-android-base.yaml +++ b/.github/workflows/release-android-base.yaml @@ -139,6 +139,51 @@ jobs: restore-keys: | ${{ runner.os }}-gradle-${{ inputs.name }}- + # RN/AGP fetches the build-requested NDK on the fly during Build APK, and + # that 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 sdkmanager so the build + # finds it locally. The version is read from the generated + # android/build.gradle — the exact one the build resolves (Expo apps: pinned + # by plugins/withAndroidNdkVersion.js; committed-native apps: their + # buildscript ext) — so it can't drift. Apps that don't pin it are skipped + # (no regression). Retry so a corrupted archive self-heals. Mirrors the + # E2E action's Install Android NDK step. + - name: Pre-install Android NDK + run: | + set -euo pipefail + ANDROID_DIR="${{ inputs.root-path }}/android" + NDK_VERSION="$(grep -rhoE 'ndkVersion[^"]*"[0-9][0-9.]+"' \ + "$ANDROID_DIR/build.gradle" "$ANDROID_DIR/app/build.gradle" 2>/dev/null \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + if [ -z "${NDK_VERSION:-}" ]; then + echo "No pinned ndkVersion found under $ANDROID_DIR — skipping NDK pre-install." + exit 0 + fi + echo "Resolved ndkVersion=$NDK_VERSION (from generated android/build.gradle)" + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + # Accept licenses so the install runs unattended; `yes` feeds the prompts + # (and takes SIGPIPE on exit, hence `|| true`). + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + status=1 + for attempt in 1 2 3; do + echo "=== sdkmanager NDK install attempt $attempt/3 (ndk;$NDK_VERSION) ===" + # pipefail off: `yes` takes SIGPIPE when sdkmanager exits, which would + # otherwise mask sdkmanager's exit code. The `if` is exempt from set -e, + # so a failed install is captured and retried instead of aborting. + set +o pipefail + if yes | "$SDKMANAGER" --install "ndk;$NDK_VERSION"; then + status=0 + else + status=$? + fi + set -o pipefail + [ "$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: Build APK id: build run: | From 4ed13c9100f71a644fb894cf5da4a4bc4e9d23e9 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:55:58 -0300 Subject: [PATCH 5/5] fix(ci): don't abort NDK pre-install when no ndkVersion is pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction ran under `set -e -o pipefail`, so `grep` matching nothing (POS / AppKit — Expo apps that don't pin a literal ndkVersion) made the command substitution non-zero and killed the step before the graceful skip check. Disable pipefail + `|| true` around the extraction so an empty result flows through to the skip, restoring no-regression behavior for callers that don't pin an NDK version. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-android-base.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-android-base.yaml b/.github/workflows/release-android-base.yaml index d74efe1c..c3dad531 100644 --- a/.github/workflows/release-android-base.yaml +++ b/.github/workflows/release-android-base.yaml @@ -153,9 +153,16 @@ jobs: run: | set -euo pipefail ANDROID_DIR="${{ inputs.root-path }}/android" + # Extraction must not abort the step: grep exits non-zero when nothing + # matches (apps that don't pin a literal ndkVersion, or a missing + # android/build.gradle), and under `set -e -o pipefail` that would kill + # the step before the skip check below. Disable pipefail and `|| true` + # so an empty result flows through to the graceful skip. + set +o pipefail NDK_VERSION="$(grep -rhoE 'ndkVersion[^"]*"[0-9][0-9.]+"' \ "$ANDROID_DIR/build.gradle" "$ANDROID_DIR/app/build.gradle" 2>/dev/null \ - | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + set -o pipefail if [ -z "${NDK_VERSION:-}" ]; then echo "No pinned ndkVersion found under $ANDROID_DIR — skipping NDK pre-install." exit 0