Skip to content

ci: add Android APK signing for release builds #69

ci: add Android APK signing for release builds

ci: add Android APK signing for release builds #69

Workflow file for this run

name: Mobile Build
on:
push:
tags:
- 'v*' # Unified release: same tag as desktop
branches:
- 'main'
pull_request:
branches: [main]
workflow_dispatch: # Allow manual trigger anytime
jobs:
# Check for potential mobile sync issues — blocks mobile build if issues found
check-mobile-sync:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for mobile sync issues
env:
GH_TOKEN: ${{ github.token }}
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
PREV_TAG=$(git tag --sort=-version:refname | grep '^v[0-9]' | grep -v "^${CURRENT_TAG}$" | head -1)
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found, skipping sync check"
exit 0
fi
echo "Comparing $PREV_TAG → $CURRENT_TAG"
CHANGED_FILES=$(git diff --name-only "$PREV_TAG" "$CURRENT_TAG")
WARNINGS=0
ISSUES=""
echo ""
echo "═══════════════════════════════════════════════"
echo " Mobile Sync Check"
echo "═══════════════════════════════════════════════"
# ─── Check 1: Override files (mobile has its own version) ───
echo ""
echo "▶ Check 1: Override file sync"
OVERRIDE_MAP=(
"src/pages/SettingsPage.tsx|mobile/src/pages/SettingsPage.tsx"
)
for entry in "${OVERRIDE_MAP[@]}"; do
DESKTOP_FILE="${entry%%|*}"
MOBILE_FILE="${entry##*|}"
if echo "$CHANGED_FILES" | grep -q "^${DESKTOP_FILE}$"; then
WARNINGS=$((WARNINGS + 1))
MSG="**Override Sync**: \`${DESKTOP_FILE}\` was modified but mobile uses its own version at \`${MOBILE_FILE}\`. Changes will NOT apply to mobile — please review and manually sync."
ISSUES="${ISSUES}\n- ${MSG}"
echo "::warning file=${DESKTOP_FILE}::⚠️ ${MSG}"
fi
done
# ─── Check 2: New routes in desktop App.tsx ───
echo ""
echo "▶ Check 2: Route sync"
# Desktop-only routes that don't need mobile equivalents
DESKTOP_ONLY_ROUTES="workflow z-image"
if echo "$CHANGED_FILES" | grep -q "^src/App.tsx$"; then
PREV_ROUTES=$(git show "$PREV_TAG":src/App.tsx 2>/dev/null | grep -oP 'path="[^"]*"' | sort)
CURR_ROUTES=$(git show "$CURRENT_TAG":src/App.tsx 2>/dev/null | grep -oP 'path="[^"]*"' | sort)
NEW_ROUTES=$(comm -13 <(echo "$PREV_ROUTES") <(echo "$CURR_ROUTES"))
MOBILE_ROUTES=$(git show "$CURRENT_TAG":mobile/src/App.tsx 2>/dev/null | grep -oP 'path="[^"]*"' | sort)
if [ -n "$NEW_ROUTES" ]; then
MISSING_ROUTES=""
while IFS= read -r route; do
route_name=$(echo "$route" | sed 's/path="//;s/"//')
[ -z "$route_name" ] && continue
# Skip desktop-only routes
skip=false
for ignore in $DESKTOP_ONLY_ROUTES; do
if [[ "$route_name" == "$ignore" || "$route_name" == "$ignore/"* ]]; then
skip=true
break
fi
done
$skip && continue
# Check if route exists in mobile App.tsx
if ! echo "$MOBILE_ROUTES" | grep -qF "$route"; then
MISSING_ROUTES="$MISSING_ROUTES $route"
fi
done <<< "$NEW_ROUTES"
if [ -n "$MISSING_ROUTES" ]; then
WARNINGS=$((WARNINGS + 1))
MSG="**Route Sync**: New routes added to desktop \`src/App.tsx\` but missing from \`mobile/src/App.tsx\`:${MISSING_ROUTES}"
ISSUES="${ISSUES}\n- ${MSG}"
echo "::warning file=src/App.tsx::⚠️ ${MSG}"
fi
fi
fi
# ─── Check 3: New dependencies in root package.json ───
echo ""
echo "▶ Check 3: Dependency sync"
# Desktop-only dependencies that mobile doesn't need (e.g. workflow/electron-only)
DESKTOP_ONLY_DEPS="sql.js json-stable-stringify uuid @google/model-viewer better-sqlite3"
if echo "$CHANGED_FILES" | grep -q "^package.json$"; then
PREV_DEPS=$(git show "$PREV_TAG":package.json 2>/dev/null | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(Object.keys(d.dependencies||{}).sort().join('\n'))")
CURR_DEPS=$(git show "$CURRENT_TAG":package.json 2>/dev/null | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(Object.keys(d.dependencies||{}).sort().join('\n'))")
NEW_DEPS=$(comm -13 <(echo "$PREV_DEPS") <(echo "$CURR_DEPS"))
if [ -n "$NEW_DEPS" ]; then
MOBILE_DEPS=$(cat mobile/package.json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(Object.keys(d.dependencies||{}).sort().join('\n'))")
MISSING=""
while IFS= read -r dep; do
[ -z "$dep" ] && continue
# Skip desktop-only deps
skip=false
for ignore in $DESKTOP_ONLY_DEPS; do
if [ "$dep" == "$ignore" ]; then
skip=true
break
fi
done
$skip && continue
if ! echo "$MOBILE_DEPS" | grep -q "^${dep}$"; then
MISSING="$MISSING \`$dep\`"
fi
done <<< "$NEW_DEPS"
if [ -n "$MISSING" ]; then
WARNINGS=$((WARNINGS + 1))
MSG="**Dependency Sync**: New desktop dependencies missing from \`mobile/package.json\`:${MISSING}"
ISSUES="${ISSUES}\n- ${MSG}"
echo "::warning file=package.json::⚠️ ${MSG}"
fi
fi
fi
# ─── Check 4: New i18n keys used but not defined ───
echo ""
echo "▶ Check 4: i18n key consistency"
if echo "$CHANGED_FILES" | grep -q "^src/i18n/locales/en.json$"; then
if ! node -e "JSON.parse(require('fs').readFileSync('src/i18n/locales/en.json','utf8'))" 2>/dev/null; then
WARNINGS=$((WARNINGS + 1))
MSG="**i18n**: \`en.json\` has invalid JSON syntax"
ISSUES="${ISSUES}\n- ${MSG}"
echo "::warning file=src/i18n/locales/en.json::⚠️ ${MSG}"
fi
fi
# ─── Check 5: New pages created but not routed in mobile ───
echo ""
echo "▶ Check 5: New page detection"
NEW_PAGES=$(echo "$CHANGED_FILES" | grep "^src/pages/.*\.tsx$" | while read f; do
git show "$PREV_TAG":"$f" >/dev/null 2>&1 || echo "$f"
done)
if [ -n "$NEW_PAGES" ]; then
for page_file in $NEW_PAGES; do
page_name=$(basename "$page_file" .tsx)
if ! grep -q "$page_name" mobile/src/App.tsx 2>/dev/null; then
WARNINGS=$((WARNINGS + 1))
MSG="**New Page**: \`${page_name}\` was added but is not referenced in \`mobile/src/App.tsx\`. Add a route if this page should be accessible on mobile."
ISSUES="${ISSUES}\n- ${MSG}"
echo "::warning file=${page_file}::⚠️ ${MSG}"
fi
done
fi
# ─── Summary ───
echo ""
echo "═══════════════════════════════════════════════"
if [ "$WARNINGS" -gt 0 ]; then
echo " ⚠️ $WARNINGS sync warning(s) found"
echo " Mobile build is BLOCKED until these are resolved."
echo " Desktop build is NOT affected."
echo "═══════════════════════════════════════════════"
# Create GitHub Issue to notify team
ISSUE_BODY=$(printf "## ⚠️ Mobile Sync Check Failed for ${CURRENT_TAG}\n\nThe following issues were detected when comparing \`${PREV_TAG}\` → \`${CURRENT_TAG}\`:\n\n${ISSUES}\n\n---\n\n**Mobile build has been blocked.** Desktop release is not affected.\n\nPlease fix the issues above and re-tag to trigger a new mobile release.\n\nTriggered by: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}")
# Ensure label exists (ignore error if already exists)
gh label create "mobile-sync" --color "d93f0b" --description "Mobile sync check failures" 2>/dev/null || true
gh issue create \
--title "🚨 Mobile sync issues found in ${CURRENT_TAG}" \
--body "$ISSUE_BODY" \
--label "mobile-sync"
exit 1
else
echo " ✅ No sync issues detected"
fi
echo "═══════════════════════════════════════════════"
build-android:
needs: check-mobile-sync
# Run even if check-mobile-sync was skipped (non-tag builds), but not if it failed
if: always() && (needs.check-mobile-sync.result == 'success' || needs.check-mobile-sync.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: mobile/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Sync version from root package.json
if: startsWith(github.ref, 'refs/tags/v')
run: |
# Read version from root package.json
VERSION=$(node -p "require('./package.json').version")
echo "Syncing version: $VERSION"
# Update mobile/package.json
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('mobile/package.json', 'utf8'));
pkg.version = '$VERSION';
fs.writeFileSync('mobile/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "✅ mobile/package.json → $VERSION"
# Update build.gradle versionName and versionCode
# versionCode formula: major * 10000 + minor * 100 + patch
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" mobile/android/app/build.gradle
sed -i "s/versionName \"[^\"]*\"/versionName \"$VERSION\"/" mobile/android/app/build.gradle
echo "✅ build.gradle → versionName $VERSION, versionCode $VERSION_CODE"
- name: Install root dependencies
run: npm ci
- name: Install mobile dependencies
working-directory: mobile
run: npm ci
- name: Build web assets
working-directory: mobile
run: npm run build
- name: Sync Capacitor
working-directory: mobile
run: npx cap sync android
- name: Make gradlew executable
working-directory: mobile/android
run: chmod +x ./gradlew
- name: Build Debug APK
working-directory: mobile/android
run: ./gradlew assembleDebug
- name: Decode keystore
if: startsWith(github.ref, 'refs/tags/v')
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > mobile/android/app/release.keystore
- name: Build Release APK
if: startsWith(github.ref, 'refs/tags/v')
working-directory: mobile/android
run: ./gradlew assembleRelease
env:
ANDROID_KEYSTORE_FILE: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Rename APKs
run: |
# Extract version from tag, fallback to "dev"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
else
VERSION="dev"
fi
# Rename debug APK
if [ -f mobile/android/app/build/outputs/apk/debug/app-debug.apk ]; then
mv mobile/android/app/build/outputs/apk/debug/app-debug.apk \
mobile/android/app/build/outputs/apk/debug/WaveSpeed-Mobile-${VERSION}-debug.apk
fi
# Rename release APK + create a fixed-name copy for README download link
if [ -f mobile/android/app/build/outputs/apk/release/app-release.apk ]; then
mv mobile/android/app/build/outputs/apk/release/app-release.apk \
mobile/android/app/build/outputs/apk/release/WaveSpeed-Mobile-${VERSION}.apk
cp mobile/android/app/build/outputs/apk/release/WaveSpeed-Mobile-${VERSION}.apk \
mobile/android/app/build/outputs/apk/release/WaveSpeed-Mobile.apk
fi
# Also handle unsigned release APK
if [ -f mobile/android/app/build/outputs/apk/release/app-release-unsigned.apk ]; then
mv mobile/android/app/build/outputs/apk/release/app-release-unsigned.apk \
mobile/android/app/build/outputs/apk/release/WaveSpeed-Mobile-${VERSION}-unsigned.apk
fi
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: wavespeed-mobile-debug
path: mobile/android/app/build/outputs/apk/debug/*.apk
if-no-files-found: error
- name: Upload Release APK
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v4
with:
name: wavespeed-mobile-release
path: mobile/android/app/build/outputs/apk/release/*.apk
if-no-files-found: ignore
release:
needs: [check-mobile-sync, build-android]
if: always() && startsWith(github.ref, 'refs/tags/v') && needs.build-android.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts
run: find artifacts -type f -name "*.apk"
- name: Upload to Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/wavespeed-mobile-release/*.apk
draft: false
prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-rc') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}