Skip to content

Commit 0bfd755

Browse files
bsgrigorovalucardzomcursoragent
authored
ci(INFRA-3597): Phase 5 — Namespace APK fingerprint cache and artifact validation (#29886)
## **Description** INFRA-3597 Phase 5 — Cache and Artifact Architecture for Namespace runner migration. Replaces fragile caches without changing build-output contracts, covering all cache families across Android and iOS builds and E2E tests. **Changes:** ### Android Cache Architecture - **Gradle local cache**: `cache: gradle` + `cache: maven` via `nscloud-cache-action` in `build-android-e2e.yml` and `run-e2e-workflow.yml` - **Gradle remote build cache**: `nsc cache gradle setup` with branch-based write policy (`--push=false` for PR/fork branches) - **APK fingerprint cache**: Marker-based at `$GRADLE_USER_HOME/apk-cache/` — cache hit skips full build - **Yarn + .metamask + node_modules**: Cached via `nscloud-cache-action` in all relevant workflows - **E2E shards**: Share the `metamask-android-build` cache volume (single tag per Namespace recommendation for best convergence) ### iOS Cache Architecture - **CocoaPods**: `cache: cocoapods` in `build-ios-e2e.yml` and `run-e2e-workflow.yml` - **Xcode/DerivedData**: `cache: xcode` in `build-ios-e2e.yml` (replaces `cirruslabs/cache` on Namespace) - **Detox framework cache**: `~/Library/Detox` path in iOS E2E `nscloud-cache-action` - **macOS symlink limitation**: `node_modules`, `ios/vendor/bundle`, `~/.cocoapods/repos` excluded from explicit cache paths — on macOS `nscloud-cache-action` uses symlinks which break Xcode ScanDependencies and Ruby/Bundler require chains ### Cache Write Policy - Gradle remote build cache: only `main`, `release/*`, `stable/*` can push (`--push=true`); PR/fork branches read-only (`--push=false`) - Cache volumes (nscloud-cache-action): read+write for all branches per Namespace recommendation (convergence model benefits from all jobs contributing cache generations) ### Infrastructure Fixes - Skip overlapping `actions/cache` steps in `setup-e2e-env` when `runner_provider == 'namespace'` (Android system image, Yarn, Bundler, CocoaPods specs) - Remove `/opt/android-sdk/system-images/...` from `nscloud-cache-action` paths (pre-baked in Dockerfile base image, permission denied on bind-mount) - Cap Jest `--maxWorkers=50%` on Namespace unit shards to reduce OOM SIGKILL risk ### Rollback Safety - All Namespace-specific logic gated on `inputs.runner_provider == 'namespace'` - `runner_provider=current` path unchanged and validated ## **Acceptance Criteria Status** | # | Criterion | Status | |---|-----------|--------| | 1 | Cache write policy enforced (PR/fork read-only) | DONE | | 2 | Yarn + `.metamask` converted | DONE | | 3 | Gradle local + remote build cache | DONE | | 4 | Gradle remote cache hits verified (2+ builds) | DONE | | 5 | APK fingerprint cache | DONE | | 6 | CocoaPods/Bundler cache | DONE | | 7 | DerivedData/Xcode cache | DONE | | 8 | Detox framework cache | DONE | | 9 | `node_modules` tarball preserved | DONE | | 10 | `fail-on-cache-miss` not removed | DONE | | 11 | `nscloud-checkout-action` not adopted (INFRA-3628) | CORRECT | | 12 | Dashboard metrics after 2-day warm-up | Follow-up ticket (reviewed after warm-up) | ## **Validation Runs** | Run | Provider | Result | |-----|----------|--------| | [25792720168](https://github.com/MetaMask/metamask-mobile/actions/runs/25792720168) | namespace | Android 27/27 E2E pass, iOS 23/27 (3 confirmations flakes), builds pass | | [25795480025](https://github.com/MetaMask/metamask-mobile/actions/runs/25795480025) | current | Rollback validation (in progress) | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: INFRA-3597 (parent epic INFRA-3511) ## **Manual testing steps** 1. Dispatch `ci.yml` with `runner_provider=namespace` — all builds and E2E tests should pass (except known flakes) 2. Dispatch `ci.yml` with `runner_provider=current` — confirms existing Cirrus/GitHub runner path is unaffected 3. Check Namespace cache dashboard after 2-day warm-up for steady-state hit rates ## **Screenshots/Recordings** N/A — CI infrastructure PR. ## **Pre-merge author checklist** - [x] I've followed MetaMask Contributor Docs and Coding Standards. - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using JSDoc format if applicable - [x] I've applied the right labels on the PR ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR - [ ] I confirm that this PR addresses all acceptance criteria <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches CI build/test gating and caching behavior across Android/iOS and E2E workflows; misconfiguration could cause cache poisoning/misses or skipped builds that break downstream tests. > > **Overview** > Introduces Namespace-runner cache configuration via `namespacelabs/nscloud-cache-action` for Android (Gradle/Maven + `apk-cache`) and iOS (CocoaPods/Xcode + Detox cache in E2E runner), and skips redundant `actions/cache` restores/saves in `setup-e2e-env` when `runner_provider == 'namespace'`. > > Adds a **Namespace-only Android APK fingerprint cache** (marker + stored APKs under `${GRADLE_USER_HOME}/apk-cache`) that can short-circuit the native build path, plus Namespace Gradle remote build cache setup with branch-based push policy. > > Adjusts CI stability on Namespace Linux by appending `--maxWorkers=50%` to sharded Jest unit runs to reduce OOM kills. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9fc9d14. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Alejandro Som <560018+alucardzom@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b1c3a75 commit 0bfd755

5 files changed

Lines changed: 138 additions & 11 deletions

File tree

.github/actions/setup-e2e-env/action.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ runs:
161161
sudo chown -R "$(id -u):$(id -g)" "$CACHE_PATH" /opt/android-sdk/.temp
162162
shell: bash
163163

164-
# Restore exact system image from cache
164+
# Restore exact system image from cache (GitHub only — Namespace uses nscloud-cache-action in callers)
165165
- name: Restore Android system image from cache
166-
if: ${{ inputs.platform == 'android' && inputs.setup-simulator == 'true' }}
166+
if: ${{ inputs.platform == 'android' && inputs.setup-simulator == 'true' && inputs.runner_provider != 'namespace' }}
167167
id: android-system-image-cache
168168
uses: actions/cache/restore@v4
169169
with:
@@ -215,6 +215,7 @@ runs:
215215
${{
216216
inputs.platform == 'android' &&
217217
inputs.setup-simulator == 'true' &&
218+
inputs.runner_provider != 'namespace' &&
218219
steps.android-system-image-cache.outputs.cache-hit != 'true' &&
219220
github.event_name != 'pull_request' &&
220221
github.event_name != 'pull_request_target' &&
@@ -295,6 +296,7 @@ runs:
295296
command: ${{ steps.get-corepack-command.outputs.COREPACK_COMMAND }}
296297

297298
- name: Restore Yarn cache
299+
if: ${{ inputs.runner_provider != 'namespace' }}
298300
uses: actions/cache@v4
299301
with:
300302
path: |
@@ -342,7 +344,7 @@ runs:
342344
shell: bash
343345

344346
- name: Restore Bundler cache
345-
if: ${{ inputs.platform == 'ios' }}
347+
if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }}
346348
uses: actions/cache@v4
347349
with:
348350
path: ios/vendor/bundle
@@ -399,7 +401,7 @@ runs:
399401
shell: bash
400402

401403
- name: Restore CocoaPods specs cache
402-
if: ${{ inputs.platform == 'ios' }}
404+
if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }}
403405
id: cocoapods-specs-cache
404406
uses: actions/cache@v4
405407
with:

.github/workflows/build-android-e2e.yml

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,29 @@ jobs:
6464
if: ${{ inputs.runner_provider == 'namespace' }}
6565
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
6666
with:
67+
cache: |
68+
gradle
69+
maven
6770
path: |
6871
~/.cache/yarn
6972
.metamask
7073
node_modules
7174
.yarn/cache
7275
${{ env.GRADLE_USER_HOME }}/caches
7376
${{ env.GRADLE_USER_HOME }}/wrapper
77+
${{ env.GRADLE_USER_HOME }}/apk-cache
78+
79+
- name: Configure Gradle remote build cache
80+
if: ${{ inputs.runner_provider == 'namespace' }}
81+
run: |
82+
mkdir -p "$GRADLE_USER_HOME/init.d"
83+
REF="${GITHUB_REF_NAME}"
84+
PUSH=true
85+
if [[ "$REF" != "main" && "$REF" != release/* && "$REF" != stable/* ]]; then
86+
PUSH=false
87+
fi
88+
nsc cache gradle setup --push="$PUSH" --init-gradle "$GRADLE_USER_HOME/init.d/namespace-cache.init.gradle"
89+
echo "Gradle remote build cache configured (push=$PUSH) at $GRADLE_USER_HOME/init.d/namespace-cache.init.gradle"
7490
7591
- name: Report source fingerprint
7692
run: |
@@ -102,6 +118,50 @@ jobs:
102118
exit 1
103119
fi
104120
121+
- name: Check Namespace E2E APK cache
122+
id: namespace-apk-cache
123+
if: ${{ inputs.runner_provider == 'namespace' && steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }}
124+
shell: bash
125+
env:
126+
APK_TARGET: ${{ steps.determine-target-paths.outputs.apk-target-path }}
127+
TEST_APK_TARGET: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
128+
ARTIFACT_NAME: ${{ steps.determine-target-paths.outputs.artifact_name }}
129+
FINGERPRINT: ${{ inputs.source-fingerprint }}
130+
CACHE_GENERATION: ${{ env.CACHE_GENERATION }}
131+
GRADLE_FILES_HASH: ${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
132+
REF_NAME: ${{ github.ref_name }}
133+
BUILD_TYPE: ${{ inputs.build_type }}
134+
run: |
135+
MARKER="${GRADLE_USER_HOME}/apk-cache/.e2e-apk-cache-marker"
136+
APK_CACHED="${GRADLE_USER_HOME}/apk-cache/${ARTIFACT_NAME}.apk"
137+
TEST_APK_CACHED="${GRADLE_USER_HOME}/apk-cache/${ARTIFACT_NAME}-androidTest.apk"
138+
EXPECTED="${REF_NAME}"$'\n'"${BUILD_TYPE}"$'\n'"${CACHE_GENERATION}"$'\n'"${FINGERPRINT}"$'\n'"${GRADLE_FILES_HASH}"
139+
echo "--- Debug: APK cache marker diagnostics ---"
140+
echo "MARKER=$MARKER"
141+
echo "APK_CACHED=$APK_CACHED"
142+
echo "TEST_APK_CACHED=$TEST_APK_CACHED"
143+
echo "marker exists: $([[ -f "${MARKER}" ]] && echo yes || echo NO)"
144+
echo "apk exists: $([[ -f "${APK_CACHED}" ]] && echo yes || echo NO)"
145+
echo "test-apk exists: $([[ -f "${TEST_APK_CACHED}" ]] && echo yes || echo NO)"
146+
ls -la "${GRADLE_USER_HOME}/apk-cache/" 2>&1 || echo "apk-cache dir not found"
147+
if [[ -f "${MARKER}" ]]; then
148+
echo "--- marker contents ---"
149+
cat "${MARKER}"
150+
echo "--- expected ---"
151+
echo "${EXPECTED}"
152+
echo "--- match: $([[ "$(<"${MARKER}")" == "${EXPECTED}" ]] && echo YES || echo NO) ---"
153+
fi
154+
if [[ -f "${MARKER}" ]] && [[ -f "${APK_CACHED}" ]] && [[ -f "${TEST_APK_CACHED}" ]] && [[ "$(<"${MARKER}")" == "${EXPECTED}" ]]; then
155+
echo "cache-hit=true" >> "${GITHUB_OUTPUT}"
156+
echo "Namespace APK cache hit — restoring APKs to build output dirs."
157+
mkdir -p "${APK_TARGET}" "${TEST_APK_TARGET}"
158+
cp "${APK_CACHED}" "${APK_TARGET}/"
159+
cp "${TEST_APK_CACHED}" "${TEST_APK_TARGET}/"
160+
else
161+
echo "cache-hit=false" >> "${GITHUB_OUTPUT}"
162+
echo "Namespace APK cache miss — full build required."
163+
fi
164+
105165
- name: Find reusable build from prior run
106166
id: find-reusable-build
107167
if: ${{ inputs.runner_provider != 'namespace' && steps.force-builds.outputs.force != 'true' && inputs.source-fingerprint != '' }}
@@ -167,7 +227,10 @@ jobs:
167227
- name: Compute native-build gate
168228
id: gate
169229
run: |
170-
if [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" \
230+
if [[ "${{ steps.namespace-apk-cache.outputs.cache-hit }}" == "true" ]]; then
231+
echo "needs-native-build=false" >> "$GITHUB_OUTPUT"
232+
echo "Namespace APK cache hit; heavy Android setup + Gradle restore will be skipped."
233+
elif [[ "${{ steps.find-reusable-build.outputs.found }}" == "true" \
171234
&& "${{ steps.download-reusable-apk.outcome }}" == "success" \
172235
&& "${{ steps.download-reusable-test-apk.outcome }}" == "success" ]]; then
173236
echo "needs-native-build=false" >> "$GITHUB_OUTPUT"
@@ -315,6 +378,27 @@ jobs:
315378
MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }}
316379
MM_PREDICT_GTM_MODAL_ENABLED: 'false'
317380

381+
- name: Record Namespace E2E APK cache marker
382+
if: ${{ inputs.runner_provider == 'namespace' && steps.gate.outputs.needs-native-build == 'true' && inputs.source-fingerprint != '' }}
383+
shell: bash
384+
env:
385+
APK_SOURCE: ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk
386+
TEST_APK_SOURCE: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk
387+
ARTIFACT_NAME: ${{ steps.determine-target-paths.outputs.artifact_name }}
388+
FINGERPRINT: ${{ inputs.source-fingerprint }}
389+
CACHE_GENERATION: ${{ env.CACHE_GENERATION }}
390+
GRADLE_FILES_HASH: ${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
391+
REF_NAME: ${{ github.ref_name }}
392+
BUILD_TYPE: ${{ inputs.build_type }}
393+
run: |
394+
CACHE_DIR="${GRADLE_USER_HOME}/apk-cache"
395+
mkdir -p "${CACHE_DIR}"
396+
cp "${APK_SOURCE}" "${CACHE_DIR}/${ARTIFACT_NAME}.apk"
397+
cp "${TEST_APK_SOURCE}" "${CACHE_DIR}/${ARTIFACT_NAME}-androidTest.apk"
398+
EXPECTED="${REF_NAME}"$'\n'"${BUILD_TYPE}"$'\n'"${CACHE_GENERATION}"$'\n'"${FINGERPRINT}"$'\n'"${GRADLE_FILES_HASH}"
399+
printf '%s\n' "${EXPECTED}" > "${CACHE_DIR}/.e2e-apk-cache-marker"
400+
echo "Namespace APK cache marker + APKs written to ${CACHE_DIR}."
401+
318402
- name: Repack APK with JS updates using @expo/repack-app
319403
if: ${{ steps.gate.outputs.needs-native-build != 'true' }}
320404
run: |

.github/workflows/build-ios-e2e.yml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ jobs:
7878
- name: Checkout repo
7979
uses: actions/checkout@v6
8080

81+
- name: Configure Namespace cache
82+
if: ${{ inputs.runner_provider == 'namespace' }}
83+
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
84+
with:
85+
cache: |
86+
cocoapods
87+
xcode
88+
path: |
89+
~/.cache/yarn
90+
.metamask
91+
.yarn/cache
92+
8193
- name: Check force-builds override
8294
id: force-builds
8395
uses: ./.github/actions/check-force-builds
@@ -145,7 +157,7 @@ jobs:
145157

146158
- name: Restore Xcode derived data from branch cache
147159
id: xcode-restore-cache
148-
if: ${{ steps.gate.outputs.needs-native-build == 'true' && inputs.runner_provider != 'namespace' }}
160+
if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' }}
149161
# This action automatically updates the cache at the end of the workflow
150162
uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4
151163
with:
@@ -155,9 +167,8 @@ jobs:
155167
key: ${{ runner.os }}-xcode-${{ github.ref_name }}-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }}
156168

157169
- name: Restore Xcode derived data from main cache
158-
if: ${{ steps.gate.outputs.needs-native-build == 'true' && steps.xcode-restore-cache.outputs.cache-hit != 'true' && github.ref_name != 'main' && inputs.runner_provider != 'namespace' }}
170+
if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' && steps.xcode-restore-cache.outputs.cache-hit != 'true' && github.ref_name != 'main' }}
159171
id: xcode-restore-cache-main
160-
# This will only restore the cache, not update it
161172
uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4
162173
with:
163174
path: |
@@ -205,7 +216,7 @@ jobs:
205216
run: find ios -name "*.plist" -exec xattr -c {} \;
206217

207218
- name: Restore .metamask folder
208-
if: ${{ steps.gate.outputs.needs-native-build == 'true' }}
219+
if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build == 'true' }}
209220
id: restore-metamask
210221
uses: actions/cache@v4
211222
with:
@@ -258,7 +269,7 @@ jobs:
258269
YARN_ENABLE_GLOBAL_CACHE: 'true'
259270

260271
- name: Restore .metamask folder (reuse-hit path)
261-
if: ${{ steps.gate.outputs.needs-native-build != 'true' }}
272+
if: ${{ inputs.runner_provider != 'namespace' && steps.gate.outputs.needs-native-build != 'true' }}
262273
id: restore-metamask-lean
263274
uses: actions/cache@v4
264275
with:

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,8 @@ jobs:
571571
run: mkdir -p tests/results
572572
# The "10" in this command is the total number of shards. It must be kept
573573
# in sync with the length of matrix.shard
574-
- run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json
574+
# Namespace Linux: cap Jest workers to reduce cgroup OOM SIGKILL without tuning heap.
575+
- run: yarn test:unit --shard=${{ matrix.shard }}/10${{ inputs.runner_provider == 'namespace' && ' --maxWorkers=50%' || '' }} --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json
575576
env:
576577
NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max_old_space_size=12288' || '--max_old_space_size=20480' }}
577578
- name: Rename coverage report and extract test count for this shard

.github/workflows/run-e2e-workflow.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,36 @@ jobs:
103103
- name: Checkout
104104
uses: actions/checkout@v6
105105

106+
- name: Configure Namespace cache (Android)
107+
if: ${{ inputs.runner_provider == 'namespace' && inputs.platform == 'android' }}
108+
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
109+
with:
110+
cache: |
111+
gradle
112+
maven
113+
path: |
114+
~/.cache/yarn
115+
.metamask
116+
node_modules
117+
.yarn/cache
118+
/home/runner/_work/.gradle/caches
119+
/home/runner/_work/.gradle/wrapper
120+
/home/runner/_work/.gradle/apk-cache
121+
122+
- name: Configure Namespace cache (iOS)
123+
if: ${{ inputs.runner_provider == 'namespace' && inputs.platform == 'ios' }}
124+
uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1
125+
with:
126+
cache: |
127+
cocoapods
128+
path: |
129+
~/.cache/yarn
130+
.metamask
131+
.yarn/cache
132+
~/Library/Detox
133+
106134
- name: Restore .metamask folder
135+
if: ${{ inputs.runner_provider != 'namespace' }}
107136
uses: actions/cache@v4
108137
with:
109138
path: .metamask

0 commit comments

Comments
 (0)