Android : keystore, signing, et tester sans compte Play Store
Le compte développeur Google Play coûte de l’argent et demande une validation qui peut prendre du temps. En attendant, il faut pouvoir donner un build à tester à de vraies personnes sur de vrais téléphones. Voici comment on a mis ça en place pour Foodproof, avec l’historique honnête des erreurs de CI qu’on a corrigées une par une.
Le signing, en local
Dans android/app/build.gradle, la config de signing lit ses valeurs depuis des propriétés Gradle plutôt que de les avoir en dur :
signingConfigs {
release {
if (project.hasProperty('FOODPROOF_RELEASE_STORE_FILE')) {
storeFile file(FOODPROOF_RELEASE_STORE_FILE)
storePassword FOODPROOF_RELEASE_STORE_PASSWORD
keyAlias FOODPROOF_RELEASE_KEY_ALIAS
keyPassword FOODPROOF_RELEASE_KEY_PASSWORD
}
}
}
Ces quatre propriétés vivent dans android/gradle.properties, qui est gitignored (tout comme les fichiers *.keystore). L’alias de la clé, c’est simplement foodproof. Rien de plus : pas de système compliqué, juste un fichier qui ne doit jamais atterrir dans git.
La CI : le vrai historique des erreurs
On a une GitHub Action qui build l’APK release et le pousse vers Firebase App Distribution à chaque push sur main qui touche src/, android/, ou les fichiers de dépendances. Le pipeline décode le keystore et le google-services.json depuis des secrets base64, écrit android/gradle.properties à la volée, puis lance ./gradlew assembleRelease.
Ce qui suit, c’est l’ordre réel dans lequel les problèmes sont apparus, pas une version nettoyée après coup :
1. gradle.properties généré incomplet. La toute première version du workflow écrivait les quatre propriétés de signing mais oubliait hermesEnabled=true. Le build passait en local (où le fichier complet existe déjà) et cassait en CI. Correction : générer un gradle.properties complet dans le workflow, avec tous les flags dont Gradle a besoin, pas juste ceux liés au signing.
2. Out of memory sur bundleRelease. Les runners GitHub Actions par défaut n’ont pas assez de RAM pour le build Gradle d’une app React Native avec Hermes activé. Fix : monter le tas Gradle à 4 Go côté CI.
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
3. Timeout dépassé. Une fois l’OOM réglé, le build entier dépassait quand même le temps alloué. On a restreint la compilation à arm64-v8a uniquement (pas besoin de builder toutes les architectures pour un test de distribution interne) et monté le timeout du job à 60 minutes.
4. Le keystore corrompu après décodage. Le pire à déboguer : le keystore décodé en CI ne correspondait pas à celui encodé en local. La cause, c’est que base64 sur macOS insère des retours à la ligne dans sa sortie, que GitHub Actions ne gère pas de la même façon à la décode. La commande qui règle ça pour ré-encoder proprement :
base64 -i ~/foodproof-release-key.keystore | tr -d '\n' | pbcopy
5. AAB vs APK. Firebase App Distribution attend un AAB (Android App Bundle) si tu veux passer par la case “lié à Play Store”, mais on n’a pas de compte Play. Solution : builder un APK classique (assembleRelease au lieu de bundleRelease) et l’envoyer directement, sans passer par Play du tout.
6. Le mauvais nom de groupe. Firebase App Distribution n’a pas renvoyé d’erreur claire quand le nom du groupe de testeurs ne correspondait pas exactement à celui configuré côté Firebase Console. Après pas mal de temps perdu à chercher ailleurs, la correction a été triviale : le nom du groupe était mal orthographié dans le YAML.
7. Déclencher au bon moment. Dernier ajustement : ne relancer la distribution que quand du code source change, pas à chaque modif de README ou de doc.
Petit aveu honnête : le nom du job dans le YAML dit encore “Build AAB & Distribute to Testers”, alors qu’il build un APK depuis le fix numéro 5. Un vrai reliquat qu’on n’a pas encore renommé.
Le workflow complet
Voici le fichier réel, tel qu’il tourne aujourd’hui après tous les correctifs ci-dessus (.github/workflows/firebase-distribution.yml) :
name: Android — Firebase App Distribution
on:
push:
branches: [main]
paths:
- "src/**"
- "android/**"
- "package.json"
- "package-lock.json"
workflow_dispatch:
inputs:
release_notes:
description: "Release notes (shown to testers)"
required: false
default: "New build available"
jobs:
build-and-distribute:
name: 🤖 Build AAB & Distribute to Testers
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: 🔧 Install npm dependencies
run: npm ci
- name: ☕ Setup Java 17
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
cache: "gradle"
- name: 🔐 Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | tr -d ' \n' | base64 -d > android/app/foodproof-release-key.keystore
- name: 🔑 Decode google-services.json
run: |
echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > android/app/google-services.json
- name: 📝 Create gradle.properties
run: |
cat > android/gradle.properties << EOF
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
hermesEnabled=true
newArchEnabled=true
reactNativeArchitectures=arm64-v8a
FOODPROOF_RELEASE_STORE_FILE=foodproof-release-key.keystore
FOODPROOF_RELEASE_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
FOODPROOF_RELEASE_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}
FOODPROOF_RELEASE_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: 🏗️ Build release APK
run: cd android && ./gradlew assembleRelease --no-daemon
- name: 🚀 Upload to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
groups: friends-&-family
file: android/app/build/outputs/apk/release/app-release.apk
releaseNotes: ${{ github.event.inputs.release_notes || format('Build {0} — {1}', github.run_number, github.sha) }}
- name: 📦 Upload APK artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: app-release-apk
path: android/app/build/outputs/apk/release/app-release.apk
retention-days: 30
- name: 🧹 Cleanup secrets
if: always()
run: |
rm -f android/app/foodproof-release-key.keystore
rm -f android/app/google-services.json
- name: ✅ Distribution successful
run: echo "✓ Android build distributed to Firebase testers!"
Sept secrets à configurer dans les paramètres du repo GitHub (Settings → Secrets and variables → Actions) pour que ça tourne : ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD, FIREBASE_ANDROID_APP_ID, FIREBASE_SERVICE_ACCOUNT_JSON, GOOGLE_SERVICES_JSON_BASE64. Et oui, le nom du job ligne 20 dit toujours “Build AAB” comme mentionné plus haut, un copier-coller de ce fichier reproduira ce petit mensonge chez toi aussi.
Ce que ça donne au final
Un push sur main qui touche le code déclenche automatiquement un build signé, distribué à un groupe de testeurs via l’app Firebase App Distribution, sans jamais toucher au Play Store. Pour un projet solo qui veut juste faire tester son app Android à quelques personnes avant de payer les frais Google, c’est largement suffisant.