Skip to content

Dev => Staging

Dev => Staging #2416

name: Mobile App Android Build
on:
pull_request:
paths:
- "mobile/**"
- ".github/workflows/mentraos-manager-android-build.yml"
push:
branches:
- main
- dev
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
build:
runs-on: self-hosted
steps:
- name: Update PR comment - Build started
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '<!-- pr-review-helper -->';
const comment = comments.data.find(c => c.body.includes(marker));
if (!comment) return;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
// Check for existing working build link to preserve
const existingDownloadMatch = comment.body.match(/\[📥 \*\*Download APK\*\*\]\((https:\/\/github\.com[^)]+\.apk)\)/);
const existingShaMatch = comment.body.match(/✅ \*\*Ready to test!\*\* \(commit `([a-f0-9]+)`\)/);
let buildStatus = `### 📱 Android Build\n⏳ **Building...** (commit \`${sha}\`)`;
// If there's a previous working build, show it
if (existingDownloadMatch && existingShaMatch && existingShaMatch[1] !== sha) {
buildStatus += `\n\n📦 **Previous build:** (commit \`${existingShaMatch[1]}\`) - [📥 **Download APK**](${existingDownloadMatch[1]})`;
}
const newBody = comment.body.replace(
/<!-- android-build-status -->[\s\S]*?<!-- \/android-build-status -->/,
`<!-- android-build-status -->\n${buildStatus}\n<!-- /android-build-status -->`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
body: newBody
});
- name: Get cache week number
id: cache-week
run: echo "week=$(date +%Y-%U)" >> $GITHUB_OUTPUT
- name: Clean build artifacts (preserve caches)
run: |
# Only clean build outputs, NOT dependency caches
rm -rf mobile/android/build mobile/android/app/build mobile/android/.gradle
# Clean node_modules to ensure fresh install (bun is fast anyway)
rm -rf mobile/node_modules
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v3
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ steps.cache-week.outputs.week }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-${{ steps.cache-week.outputs.week }}-
gradle-${{ runner.os }}-
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('mobile/bun.lockb') }}
restore-keys: |
bun-${{ runner.os }}-
- name: Install dependencies
working-directory: ./mobile
run: bun install
- name: Setup environment
working-directory: ./mobile
run: |
# Create .env from example (Expo reads from mobile/.env, not android/.env)
cp .env.example .env
- name: Run Expo prebuild
working-directory: ./mobile
run: bun expo prebuild --platform android
- name: Fix React Native symlinks
working-directory: ./mobile
run: |
if [ -f "./fix-react-native-symlinks.sh" ]; then
chmod +x ./fix-react-native-symlinks.sh
./fix-react-native-symlinks.sh
fi
- name: Build Android Release APK
id: gradle-build
working-directory: ./mobile/android
run: ./gradlew assembleRelease --build-cache --parallel
continue-on-error: true
env:
SENTRY_DISABLE_AUTO_UPLOAD: "true"
- name: Retry build with clean cache (if first attempt failed)
if: steps.gradle-build.outcome == 'failure'
working-directory: ./mobile/android
run: |
echo "First build failed, cleaning Gradle cache and retrying..."
# Stop Gradle daemons first to release file locks
./gradlew --stop || true
rm -rf ~/.gradle/caches ~/.gradle/wrapper || true
./gradlew assembleRelease --build-cache --parallel
env:
SENTRY_DISABLE_AUTO_UPLOAD: "true"
- name: Fail if both builds failed
if: steps.gradle-build.outcome == 'failure'
run: |
# Check if retry succeeded by looking for the APK
if [ ! -f "mobile/android/app/build/outputs/apk/release/app-release.apk" ]; then
echo "Both build attempts failed"
exit 1
fi
- name: Upload Release APK (artifact)
uses: actions/upload-artifact@v4
with:
name: app-release
path: mobile/android/app/build/outputs/apk/release/app-release.apk
- name: Upload APK to pr-builds release
if: github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const prNumber = context.payload.pull_request.number;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
const apkName = `pr-${prNumber}-${sha}.apk`;
const apkPath = 'mobile/android/app/build/outputs/apk/release/app-release.apk';
// Get the pr-builds release
const release = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: 'pr-builds'
});
// Delete existing asset with same name if it exists
for (const asset of release.data.assets) {
if (asset.name === apkName) {
console.log(`Deleting existing asset: ${asset.name}`);
await github.rest.repos.deleteReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
asset_id: asset.id
});
break;
}
}
// Read the APK file
const apkData = fs.readFileSync(apkPath);
// Upload the APK
console.log(`Uploading ${apkName} (${apkData.length} bytes)...`);
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.data.id,
name: apkName,
data: apkData
});
console.log(`Successfully uploaded ${apkName}`);
- name: Cleanup old release assets
if: github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const release = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: 'pr-builds'
});
for (const asset of release.data.assets) {
const createdAt = new Date(asset.created_at);
if (createdAt < sevenDaysAgo) {
console.log(`Deleting old asset: ${asset.name} (created ${asset.created_at})`);
await github.rest.repos.deleteReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
asset_id: asset.id
});
}
}
} catch (error) {
console.log('Cleanup skipped:', error.message);
}
- name: Update PR comment - Build succeeded
if: success() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '<!-- pr-review-helper -->';
const comment = comments.data.find(c => c.body.includes(marker));
if (!comment) return;
const prNumber = context.payload.pull_request.number;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
const apkName = `pr-${prNumber}-${sha}.apk`;
const downloadUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/releases/download/pr-builds/${apkName}`;
const buildStatus = `### 📱 Android Build\n✅ **Ready to test!** (commit \`${sha}\`)\n\n[📥 **Download APK**](${downloadUrl})`;
const newBody = comment.body.replace(
/<!-- android-build-status -->[\s\S]*?<!-- \/android-build-status -->/,
`<!-- android-build-status -->\n${buildStatus}\n<!-- /android-build-status -->`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
body: newBody
});
- name: Update PR comment - Build failed
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '<!-- pr-review-helper -->';
const comment = comments.data.find(c => c.body.includes(marker));
if (!comment) return;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
// Check if there was a previous working build
const previousDownloadMatch = comment.body.match(/\[📥 \*\*Download APK\*\*\]\((https:\/\/github\.com[^)]+\.apk)\)/);
const previousShaMatch = comment.body.match(/✅ \*\*Ready to test!\*\* \(commit `([a-f0-9]+)`\)/)
|| comment.body.match(/📦 \*\*Previous build:\*\* \(commit `([a-f0-9]+)`\)/);
let buildStatus = `### 📱 Android Build\n❌ **Build failed** (commit \`${sha}\`) - [View logs](${runUrl})`;
if (previousDownloadMatch && previousShaMatch && previousShaMatch[1] !== sha) {
buildStatus += `\n\n📦 **Previous build:** (commit \`${previousShaMatch[1]}\`) - [📥 **Download APK**](${previousDownloadMatch[1]})`;
}
const newBody = comment.body.replace(
/<!-- android-build-status -->[\s\S]*?<!-- \/android-build-status -->/,
`<!-- android-build-status -->\n${buildStatus}\n<!-- /android-build-status -->`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
body: newBody
});