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

C'est quoi un keystore ? Un fichier qui contient la clé privée utilisée pour signer cryptographiquement ton app. Android refuse d'installer un build release qui n'est pas signé, et si tu perds ce fichier, tu ne pourras plus jamais publier de mise à jour sous le même nom d'app.

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

C'est quoi Firebase App Distribution ? Un service gratuit de Google qui distribue un build Android (ou iOS) à une liste de testeurs par email, sans passer par le Play Store. Les testeurs installent une app compagnon et reçoivent une notification à chaque nouveau build.

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.

Pipeline CI : git push vers main, GitHub Actions décode les secrets, gradlew assembleRelease, puis envoi vers Firebase App Distribution

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.