AndroidGitHub ActionsGoogle PlayCI/CDKotlin

Android-Apps mit GitHub Actions bauen, signieren und automatisch im Google Play Console veröffentlichen

Sloth255
Sloth255
·5 min read·1,092 words

Einleitung

Veröffentlichst du deine Android-App noch manuell, indem du in Android Studio auf Generate Signed Bundle klickst, dann die Play Console öffnest und die AAB per Drag & Drop hochlädst?

Ich habe das eine Weile so gemacht, aber als die Veröffentlichungsfrequenz stieg, traten folgende Probleme auf:

  • Builds brechen aufgrund lokaler Umgebungsunterschiede (JDK-Versionskonflikte, Gradle-Cache-Probleme)
  • Keystore-Verwaltung wird zur Einzelperson-Abhängigkeit (existiert nur auf dem Mac eines bestimmten Entwicklers)
  • Release-Prozess hängt von Dokumentation ab, was zu menschlichen Fehlern führt

Um all diese Probleme auf einmal zu lösen, habe ich den gesamten Ablauf – Build, Signierung und Upload zur Google Play Console – mit GitHub Actions automatisiert. Hier ist die Schritt-für-Schritt-Anleitung.

Überblick

Die Pipeline, die wir aufbauen werden, sieht folgendermaßen aus:

flowchart TD
  A["git tag v1.0.0 pushen"]
  B["GitHub Actions wird gestartet"]
  C["JDK einrichten und Gradle-Cache wiederherstellen"]
  D["Keystore aus Secrets wiederherstellen"]
  E["AAB mit ./gradlew bundleRelease bauen und signieren"]
  F["\u00dcber r0adkll/upload-google-play im internen Test ver\u00f6ffentlichen"]
  G["Google empf\u00e4ngt, re-signiert mit App-Signaturschl\u00fcssel und liefert an Nutzer aus"]

  A --> B --> C --> D --> E --> F --> G

Die Begriffe „AAB" und „Upload-Schlüssel" werden im nächsten Abschnitt erläutert.

3 Schlüsselkonzepte vorab verstehen

Bevor wir mit den eigentlichen Schritten beginnen, klären wir die wichtigsten Begriffe aus diesem Artikel. Sind diese unklar, kann es bei Play App Signing schnell verwirrend werden.

APK vs. AAB

APK (Android Package Kit) AAB (Android App Bundle)
Inhalt Universalpaketes mit allen ABIs, Bildschirmdichten und Sprachressourcen in einer Datei Build-Artefakt vor der Optimierung für spezifische Geräte
Dateigröße Tendiert zur Größe, da alles enthalten ist Google Play generiert Split APKs zur Auslieferung, reduziert die Download-Größe der Nutzer im Schnitt um ~15 %
Haupteinsatz Direkte Verteilung / interne Verteilung / Drittanbieter-Stores Google Play Store Verteilung (für neue Apps seit August 2021 Pflicht)
Gradle-Task ./gradlew assembleRelease ./gradlew bundleRelease

Kurz gesagt: „APK = fertiges Produkt, das auf Nutzergeräten installiert wird" und „AAB = Blueprint, der an Google Play übermittelt wird". Google Play empfängt die AAB und baut für jedes Nutzergerät die passende APK zusammen.

Da es in diesem Artikel um die Play Store-Verteilung geht, verwenden wir AAB (bundleRelease). Für interne Verteilung oder andere Anwendungsfälle, die APKs erfordern, kann assembleRelease genutzt werden.

Upload-Schlüssel vs. App-Signaturschlüssel

Bei Play App Signing gibt es zwei Arten von Signaturschlüsseln:

Schlüsseltyp Wer verwaltet? Zweck
Upload-Schlüssel (Upload Key) Entwickler Signiert die AAB beim Hochladen zur Play Console
App-Signaturschlüssel (App Signing Key) Google Signiert das finale APK, das vom Play Store an Nutzer ausgeliefert wird

Mit anderen Worten: Die Play Console prüft, ob die AAB mit dem richtigen Upload-Schlüssel signiert ist. Deshalb müssen Entwickler mit ihrem eigenen Upload-Schlüssel = Keystore signieren. Was Google für dich übernimmt, ist nur die Re-Signierung mit dem App-Signaturschlüssel bei der Auslieferung.

flowchart TD
  Dev["Entwickler"] -->|"Mit Upload-Schl\u00fcssel signieren"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google pr\u00fcft Upload-Schl\u00fcssel"]
  Verify --> Resign["Google re-signiert mit App-Signaturschl\u00fcssel"]
  Resign --> User["Nutzer"]

Auch mit Play App Signing bleibt die Tatsache bestehen, dass „du noch immer deinen eigenen Keystore erstellen musst". Was sich geändert hat, ist das Sicherheitsnetz: „Du kannst einen Reset beantragen, wenn du deinen Upload-Schlüssel verlierst".

Debug-Signierung vs. Release-Signierung

Als du zum ersten Mal in Android Studio auf Run gedrückt hast, ohne irgendwelche Konfiguration vorzunehmen, wurde eine APK gebaut und lief auf deinem Gerät, weil AGP (Android Gradle Plugin) automatisch ~/.android/debug.keystore generiert und Debug-Builds automatisch signiert.

Debug-Signierung Release-Signierung
Keystore ~/.android/debug.keystore (von AGP automatisch generiert) Selbst erstellt und verwaltet (release.keystore in diesem Artikel)
Passwort Fest: android Selbst gesetzt
Alias Fest: androiddebugkey Selbst gesetzt
Ablauf ~30 Jahre Selbst gesetzt (~27 Jahre in diesem Artikel)
Einsatz Entwicklungstests / ./gradlew assembleDebug Play Store-Verteilung / ./gradlew bundleRelease
Git-Verwaltung Nicht nötig (OK, wenn pro Maschine vorhanden) Sicher aufbewahren (bei Play App Signing auch bei Verlust zurücksetzbar)

Zwei wichtige Eigenschaften:

  1. Play Console lehnt AABs mit Debug-Signierung grundsätzlich ab
    → CI benötigt einen separaten release.keystore
  2. Debug-Signierung und Release-Signierung sind vollständig unabhängig
    → Das Hinzufügen der Release-Signierungskonfiguration für CI hat keinerlei Auswirkung auf lokale ./gradlew assembleDebug-Aufrufe

Die bedingte signingConfigs.create("release") in den Abschnitten 5 und 6 nutzt genau diese Eigenschaften, um sicherzustellen, dass Entwickler ohne den Schlüssel trotzdem Debug-Builds normal ausführen können.

Voraussetzungen

  • Android-App (mit build.gradle oder build.gradle.kts)
  • GitHub-Repository
  • Google Play Console Entwicklerkonto
  • Das erste Release der App muss bereits manuell in der Play Console veröffentlicht worden sein (erstmalige Erstellung über die API ist nicht möglich)
  • Play App Signing muss für die Ziel-App aktiviert sein

Für Apps, die nach August 2021 erstellt wurden, ist Play App Signing automatisch aktiviert, sodass keine besonderen Schritte erforderlich sind. Für ältere Apps, die davor mit der Legacy-Methode erstellt wurden, ist zunächst eine Migration zu Play App Signing notwendig (behandelt in Abschnitt 7-4).

1. Upload-Schlüssel (Keystore) erstellen

Generiere den Keystore lokal zur Signierung der AAB. In einer Play App Signing-Konfiguration fungiert dieser Schlüssel als „Upload-Schlüssel" (= dein Identitätsnachweis beim Übermitteln der AAB an die Play Console). Die finale Auslieferung an Nutzer wird mit Googles „App-Signaturschlüssel" signiert, also kann dieser Schlüssel sogar bei Verlust über die Play Console zurückgesetzt werden (Details in 7-3).

Allerdings stoppt ein Verlust im Tagesgeschäft die Releases bis zum Abschluss des Resets, also bewahre ihn sicher auf.

# macOS / Linux
keytool -genkey -v \
  -keystore release.keystore \
  -alias my-release-key \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000

Unter Windows (PowerShell / Eingabeaufforderung) funktioniert die Backslash-Zeilenfortsetzung nicht, also führe den Befehl in einer Zeile aus oder verwende in PowerShell das Backtick ` zum Zeilenumbruch.

keytool -genkey -v `
  -keystore release.keystore `
  -alias my-release-key `
  -keyalg RSA `
  -keysize 2048 `
  -validity 10000

Passwort, Name, Organisation usw. werden interaktiv abgefragt.

2. Keystore mit Base64 kodieren

GitHub Secrets kann keine Binärdateien direkt speichern, also konvertiere es in einen Base64-String. Der Workflow entfernt beim Dekodieren Zeilenumbrüche mit tr -d '\n\r', aber eine Ausgabe ohne Zeilenumbrüche ist sicherer.

macOS

base64 -i release.keystore -o release.keystore.base64
cat release.keystore.base64 | pbcopy

Linux

base64 -w 0 release.keystore > release.keystore.base64
cat release.keystore.base64 | xclip -selection clipboard

Windows (PowerShell)

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Set-Clipboard

Zum Schreiben in eine Datei:

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Out-File -Encoding ascii -NoNewline release.keystore.base64

Windows (Eingabeaufforderung)

certutil funktioniert, fügt aber Kopfzeilen, Fußzeilen und Zeilenumbrüche hinzu, die entfernt werden müssen.

certutil -encode release.keystore release.keystore.base64

Entferne die Zeilen -----BEGIN CERTIFICATE----- und -----END CERTIFICATE----- sowie alle Zeilenumbrüche aus der Ausgabedatei. PowerShell ist empfehlenswert.

Kopiere den String aus der Zwischenablage und füge ihn im nächsten Schritt in GitHub Secrets ein.

3. Service-Account für Google Play Console erstellen

Für automatisierte Uploads zur Play Console ist ein Google Cloud Service-Account erforderlich.

3-1. Google Play Android Developer API aktivieren

Aktiviere zunächst die API auf der GCP-Seite. Das Überspringen dieses Schritts führt später zu einem 403-Fehler, also erledige es zuerst.

  1. Rufe die Google Play Android Developer API auf
  2. Klicke auf Aktivieren

3-2. Service-Account in der Google Cloud Console erstellen

  1. Rufe die Google Cloud Console auf
  2. Erstelle ein Projekt (oder wähle ein vorhandenes aus)
  3. Erstelle unter IAM & Verwaltung → Service-Accounts einen neuen. Weise keine IAM-Rollen zu (Berechtigungen werden auf der Play Console-Seite verwaltet)
  4. Wähle nach der Erstellung Schlüssel hinzufügen → Neuen Schlüssel erstellen → JSON und lade die JSON-Datei herunter

3-3. Berechtigungen in der Play Console erteilen

  1. Öffne die Play Console
  2. Öffne Nutzer und Berechtigungen, klicke auf Neue Nutzer einladen
  3. Gib die E-Mail-Adresse des in Schritt 3-2 erstellten Service-Accounts ein
  4. Füge im Tab App-Berechtigungen die Ziel-App hinzu und erteile folgende Berechtigungen:
    • In Test-Tracks veröffentlichen — für die Bereitstellung im internen Test-Track erforderlich
    • App-Informationen anzeigen und Massenberichte herunterladen — Leseberechtigungen

4. Werte in GitHub Secrets registrieren

Registriere unter Settings → Secrets and variables → Actions des Repositories folgendes:

Secret-Name Inhalt
KEYSTORE_BASE64 Base64-String aus Schritt 2
KEYSTORE_PASSWORD Keystore-Passwort
KEY_ALIAS z.B. my-release-key
KEY_PASSWORD Schlüsselpasswort
SERVICE_ACCOUNT_JSON Den Inhalt der in Schritt 3-2 heruntergeladenen JSON-Datei direkt einfügen

5. build.gradle so konfigurieren, dass Signierungsinformationen aus Umgebungsvariablen gelesen werden

Konfiguriere es so, dass CI über Umgebungsvariablen liest und die lokale Entwicklung über keystore.properties liest, und selbst Umgebungen ohne Keystore Debug-Builds normal ausführen können.

Bearbeite app/build.gradle.kts wie folgt:

import java.util.Properties
import java.io.FileInputStream

// --- Signierungsinformationen auflösen ---
// Priorität: Umgebungsvariablen (CI) > keystore.properties (lokal) > keines (nur Debug-Build)
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        load(FileInputStream(keystorePropertiesFile))
    }
}

fun resolveSigning(key: String, envKey: String): String? =
    System.getenv(envKey) ?: keystoreProperties.getProperty(key)

val releaseStoreFile = resolveSigning("storeFile", "KEYSTORE_FILE")
val releaseStorePassword = resolveSigning("storePassword", "KEYSTORE_PASSWORD")
val releaseKeyAlias = resolveSigning("keyAlias", "KEY_ALIAS")
val releaseKeyPassword = resolveSigning("keyPassword", "KEY_PASSWORD")

val hasReleaseSigning = listOf(
    releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword
).all { !it.isNullOrBlank() }

android {
    signingConfigs {
        if (hasReleaseSigning) {
            create("release") {
                storeFile = file(releaseStoreFile!!)
                storePassword = releaseStorePassword
                keyAlias = releaseKeyAlias
                keyPassword = releaseKeyPassword
            }
        }
    }

    buildTypes {
        getByName("debug") {
            // applicationIdSuffix hinzufügen, damit Release- und Debug-Builds
            // auf demselben Gerät koexistieren können
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"
            // signingConfig verwendet den von AGP automatisch generierten debug.keystore
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            // Release-signingConfig nur zuweisen, wenn Signierungsinformationen vorhanden sind
            // (= Entwickler ohne Schlüssel können trotzdem assembleDebug ausführen)
            if (hasReleaseSigning) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}

Die Kernpunkte dieses Codes:

  • signingConfigs.create("release") wird bewacht, sodass die Release-Konfiguration in Umgebungen ohne Signierungsinformationen nicht erstellt wird
  • Der debug-Build-Typ verwendet den von AGP automatisch generierten debug.keystore und wird von den Release-Signierungseinstellungen in keiner Weise beeinflusst
  • Umgebungsvariablen haben höchste Priorität, daher ist keine keystore.properties-Datei auf CI erforderlich

6. Zusätzliche Einrichtung, um die lokale Entwicklung nicht zu beeinträchtigen

„Ich habe build.gradle für CI geändert und jetzt funktionieren lokale Debug-Builds nicht mehr" ist ein häufiger Unfall in Android × CI-Setups. Um dies zu verhindern, richte folgendes ein:

6-1. keystore.properties erstellen (nur lokal)

local.properties ist für SDK-Pfade gedacht, und das Einmischen von Signierungsinformationen macht die Datei unübersichtlich. Eine separate keystore.properties-Datei für Signierungsinformationen zu erstellen wird auch in der offiziellen Android-Dokumentation empfohlen.

Erstelle keystore.properties im Projektstamm (eine Ebene über app/):

storeFile=/Users/yourname/keys/release.keystore
storePassword=your-store-password
keyAlias=my-release-key
keyPassword=your-key-password

Diese Datei darf niemals in Git aufgenommen werden, also füge sie zur .gitignore hinzu (siehe unten).

6-2. Was zur .gitignore hinzugefügt werden sollte

Signierungsbezogene Einträge werden oft vergessen, da die von Android Studio generierte .gitignore diese nicht enthält. Füge sie explizit hinzu:

# Signierung - niemals committen
*.keystore
*.jks
keystore.properties
release.keystore.base64

# Service-Account JSON
*-service-account*.json
play-publisher.json

# Temporäre Auth-Dateien von google-github-actions/auth (bei Verwendung von WIF)
gha-creds-*.json

# Von Android Studio erstellt, aber zur Sicherheit auch prüfen
local.properties

local.properties wird normalerweise von Android Studio hinzugefügt, aber in älteren Projekten kann es fehlen – überprüfe es.

6-3. Release und Debug mit applicationIdSuffix koexistieren lassen

Das build.gradle.kts-Snippet oben enthält:

debug {
    applicationIdSuffix = ".debug"
}

Dies ermöglicht es, die vom Play Store installierte Release-Version und eine lokal gebaute Debug-Version gleichzeitig auf demselben Gerät zu installieren. Ohne dies würde eine Debug-Build-Installation die Play Store-Version auf deinem Testgerät überschreiben.

Nicht direkt CI-bezogen, aber sobald du Releases automatisierst, hast du öfter die Play Store-Version auf deinem Testgerät laufen, daher empfehle ich dringend, dies hinzuzufügen.

6-4. Verwendung von ~/.gradle/gradle.properties

Eine weitere Option ist das Schreiben der Signierungsinformationen in die Gradle-Eigenschaften des Home-Verzeichnisses:

# macOS/Linux: ~/.gradle/gradle.properties
# Windows:     %USERPROFILE%\.gradle\gradle.properties

MYAPP_KEYSTORE_FILE=/Users/yourname/keys/release.keystore
MYAPP_KEYSTORE_PASSWORD=xxxx
MYAPP_KEY_ALIAS=my-release-key
MYAPP_KEY_PASSWORD=xxxx

In build.gradle.kts lese es mit findProperty("MYAPP_KEYSTORE_FILE") as String?.

Der Vorteil ist, dass überhaupt keine geheimen Informationen im Projektverzeichnis gespeichert werden. Der Nachteil ist, dass die Kommunikation der Einrichtungsschritte an neue Entwickler etwas aufwendiger ist. Meine Empfehlung: ~/.gradle/gradle.properties für Solo-Projekte, keystore.properties geschützt durch .gitignore für Team-Projekte.

6-5. Unsigned Release-Builds in Android Studio testen

Während ./gradlew assembleDebug immer funktioniert, möchtest du vielleicht ./gradlew assembleRelease ohne Signierung ausführen (z.B. um ProGuard-Einstellungen für Release-Builds zu überprüfen).

In diesem Fall, wenn hasReleaseSigning false ist, hat der release-Build-Typ keine signingConfig, sodass assembleRelease ein unsigniertes APK erzeugt (kann nicht auf einem Gerät installiert werden, aber Größe und mapping.txt können überprüft werden).

7. Ergänzungen zum Play App Signing-Betrieb

Mit den Schritten 1–6 ist die Play App Signing-Konfiguration bereits abgeschlossen. Der in Schritt 1 erstellte release.keystore fungiert im Play App Signing-Kontext als Upload-Schlüssel.

Dieser Abschnitt enthält ergänzende Hinweise, die im Betrieb nützlich sind.

7-1. Was beim ersten Upload passiert

Bei einer neuen App, wenn du die AAB zum ersten Mal in die Play Console hochlädst:

  • Das in dieser AAB verwendete Zertifikat wird automatisch als „Upload-Key-Zertifikat" registriert
  • Google generiert automatisch einen neuen „App-Signaturschlüssel"
  • Play App Signing wird aktiviert

Es ist also keine zusätzliche Play Console-Konfiguration erforderlich – wenn du die Schritte dieses Artikels genau befolgst, ist die Play App Signing-Konfiguration fertig.

flowchart TD
  CI["CI"] -->|"Mit release.keystore signieren"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google pr\u00fcft Upload-Key-Zertifikat"]
  Verify --> Resign["Google re-signiert mit App-Signaturschl\u00fcssel"]
  Resign --> Device["Nutzerger\u00e4t"]

7-2. Einen Schritt weiter: Dedizierten Upload-Schlüssel erstellen

Auch für neue Apps kannst du in der Play Console wählen, den „Upload-Schlüssel separat zu registrieren".

  • Wähle beim ersten Upload in der Play Console „Von Google generierten App-Signaturschlüssel verwenden"
  • Lade auf demselben Bildschirm das Upload-Key-Zertifikat separat hoch

In diesem Fall enthält release.keystore einen „vollständig dedizierten Nur-Upload-Schlüssel".

Standardmuster (7-1) Getrennter-Schlüssel-Ansatz (7-2)
Inhalt von release.keystore Upload-Schlüssel = Schlüssel, der die erste AAB signiert hat Reiner Upload-Schlüssel
Bei Kompromittierung Kann per Reset-Anfrage wiederhergestellt werden Kann per Reset-Anfrage wiederhergestellt werden
Sicherheitsniveau Ausreichend Strenger

Die CI-Schritte sind für beide Muster identisch. Der einzige Unterschied ist, wie du release.keystore erstellst.

7-3. Was tun, wenn der Upload-Schlüssel verloren geht

Dies ist der größte Vorteil von Play App Signing. Du kannst einen Reset über die Play Console beantragen.

  1. Erstelle lokal einen neuen Keystore

  2. Exportiere das Zertifikat im PEM-Format

    keytool -export -rfc \
      -keystore release.keystore \
      -alias my-release-key \
      -file upload_certificate.pem
    
  3. Lade in Play Console → Einrichtung → App-Integrität → App-Signatur → Reset des Upload-Schlüssels beantragen das Zertifikat hoch

  4. Der Google-Support überprüft (normalerweise 1–2 Werktage)

  5. Nach Genehmigung werden AABs, die mit dem neuen Schlüssel signiert sind, akzeptiert

Mit der alten Methode bedeutete „Schlüsselverlust, dass du denselben Paketnamen niemals mehr aktualisieren konntest" – eine permanente Sackgasse. Allein diese Wiederherstellungsoption macht Play App Signing es wert.

7-4. Bestehende (Legacy-)App migrieren

Für Apps, die vor August 2021 erstellt wurden und noch nicht zu Play App Signing migriert sind, musst du den aktuellen Signaturschlüssel an Google übergeben:

  1. Play Console → Ziel-App → Einrichtung → App-Integrität → App-Signatur

  2. Lade das von Google bereitgestellte PEPK (Play Encrypt Private Key) Tool herunter

  3. Exportiere den aktuellen Keystore in verschlüsselter Form mit dem PEPK-Tool

    java -jar pepk.jar \
      --keystore=existing-release.keystore \
      --alias=my-release-key \
      --output=encrypted-key.zip \
      --include-cert \
      --rsa-aes-encryption \
      --encryption-key-path=public-key.pem
    
  4. Lade die Ausgabedatei (encrypted-key.zip) in die Play Console hoch

  5. Erstelle nach der Migration bei Bedarf einen neuen Upload-Schlüssel (die Weiterverwendung des bestehenden Schlüssels als Upload-Schlüssel ist ebenfalls möglich)

public-key.pem ist der öffentliche Schlüssel, der auf dem Migrationsbildschirm in der Play Console angezeigt und als Datei gespeichert wird.

Nach der Migration können die Schritte 1–6 dieses Artikels direkt angewendet werden.

8. GitHub Actions Workflow schreiben

Nun zum Hauptthema. Erstelle .github/workflows/release.yml:

name: Android Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Wichtige Punkte

  • Tag-Trigger (v*) verhindert versehentliches Deployment bei jedem Main-Push
  • Gradle-Cache reduziert die Build-Zeit (von ~6 Minuten auf ~2 Minuten bei nachfolgenden Ausführungen)
  • Artifact-Upload dient als Backup – schlägt der Play Console-Upload fehl, kann die AAB manuell aus der Actions-Oberfläche heruntergeladen werden
  • tracks: internal kann in alpha, beta, production usw. geändert werden
  • whatsNewDirectory ist das Verzeichnis für Release-Notes nach Sprache, z.B. distribution/whatsnew/whatsnew-ja-JP

8-2. Sicherere Konfiguration: Workload Identity Federation verwenden

Anstatt langlebige JSON-Schlüssel in GitHub Secrets zu speichern, ermöglicht Workload Identity Federation (WIF) GitHub Actions, temporäre Anmeldedaten für den Zugriff auf die Play Console zu erhalten. Dies eliminiert das Risiko von JSON-Schlüssel-Lecks und wird für den Produktionsbetrieb empfohlen.

Voraussetzungen (GCP-Seite)

Die WIF-Einrichtung erfolgt einmalig und muss nicht wiederholt werden. Folge den Schritten in google-github-actions/auth. Mit diesem Ansatz wird kein JSON-Schlüssel für den Service-Account generiert, daher sind Schritt 4 in Abschnitt 3-2 (Schlüssel erstellen/herunterladen) und die Registrierung von SERVICE_ACCOUNT_JSON in Abschnitt 4 nicht erforderlich.

Konfiguriere mit den folgenden gcloud-Befehlen. Ersetze jeden Platzhalter durch deine tatsächlichen Werte:

Platzhalter Beschreibung
${PROJECT_ID} GCP-Projekt-ID
${GITHUB_ORG} GitHub-Organisationsname oder Benutzername
${REPO} Repository-Name im Format org/repo
${SERVICE_ACCOUNT} Service-Account-Name aus Schritt 3-2 (der Teil vor @ in der E-Mail)
${WORKLOAD_IDENTITY_POOL_ID} Vollständige Pool-ID aus Schritt 2
# 1. Workload Identity Pool erstellen
gcloud iam workload-identity-pools create "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# 2. Vollständige Pool-ID abrufen
gcloud iam workload-identity-pools describe "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --format="value(name)"
# Beispiel: projects/123456789/locations/global/workloadIdentityPools/github

# 3. Workload Identity Provider erstellen
#    --attribute-condition beschränkt auf Tokens nur aus dieser Organisation
gcloud iam workload-identity-pools providers create-oidc "my-repo" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="github" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"

# 4. roles/iam.workloadIdentityUser dem Service-Account zuweisen
#    principalSet's attribute.repository beschränkt auf dieses spezifische Repository
#    ${WORKLOAD_IDENTITY_POOL_ID} = vollständige ID aus Schritt 2
gcloud iam service-accounts add-iam-policy-binding \
  "${SERVICE_ACCOUNT}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${REPO}"

Notiere nach der Einrichtung die vollständige Provider-ID (projects/.../providers/...). Sie wird im Feld workload_identity_provider im GitHub Actions YAML verwendet.

Workflow YAML (WIF-Version)

Füge dem Repository die Berechtigung id-token: write und einen google-github-actions/auth-Schritt hinzu:

name: Android Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read
  id-token: write          # Für WIF-Token-Erwerb erforderlich

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Authenticate to Google Cloud (WIF)
        uses: google-github-actions/auth@v3
        id: auth
        with:
          workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
          service_account: SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Ersetze workload_identity_provider und service_account durch die Werte, die nach der WIF-Einrichtung in der GCP-Konsole bestätigt wurden. Der einzige Unterschied zur JSON-Schlüssel-Version besteht darin, dass der Pfad zur Anmeldedatei-Datei an serviceAccountJson statt an serviceAccountJsonPlainText übergeben wird – alles andere ist identisch.

9. Release-Notes-Verzeichnis erstellen

Erstelle distribution/whatsnew/ im Projektstamm und lege folgende Dateien ab:

distribution/whatsnew/
├── whatsnew-en-US
└── whatsnew-ja-JP

Jede Datei enthält Release-Notes in der jeweiligen Sprache (bis zu 500 Zeichen).

10. Ausprobieren

Von hier aus muss nur noch ein Tag gepusht werden:

git tag v1.0.0
git push origin v1.0.0

Öffne den Actions-Tab in GitHub, und der Workflow beginnt zu laufen. Bei Erfolg erscheint ein neues Release unter Interne Tests in der Play Console.

11. Häufige Fallstricke und Lösungen

Ein paar Fallen, in die ich während des tatsächlichen Betriebs getappt bin:

versionCode zu erhöhen vergessen

Play Console erlaubt keine doppelten versionCode-Werte. Eine automatische Nummerierung mit der GitHub Actions Run-Nummer (GITHUB_RUN_NUMBER) verhindert Unfälle. Verwende den Tag-Namen ohne v für versionName.

android {
    defaultConfig {
        // Auf CI GITHUB_RUN_NUMBER als versionCode verwenden (Fallback auf 1 lokal)
        versionCode = (System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1) + 1000
        // Auf CI git tag (v1.0.0 → 1.0.0) als versionName verwenden
        versionName = System.getenv("GITHUB_REF_NAME")?.removePrefix("v") ?: "1.0.0-local"
    }
}

Verzögerung bei der Weitergabe von Service-Account-Berechtigungen

Das Ausführen von Actions unmittelbar nach dem Erteilen von Berechtigungen kann zu 403 The caller does not have permission führen. Berechtigungsänderungen brauchen etwas Zeit zum Propagieren, also sei beim ersten Lauf geduldig.

Zeilenumbrüche im Base64-Decode

Der base64-Befehl verhält sich auf verschiedenen Betriebssystemen unterschiedlich:

  • macOS: Fügt automatisch alle 76 Zeichen Zeilenumbrüche ein → verwende -i / -o für saubere Ausgabe
  • Linux: Fügt standardmäßig Zeilenumbrüche ein → verwende -w 0 für keine Zeilenumbrüche
  • Windows (certutil): Kopf-, Fußzeilen und Zeilenumbrüche sind alle enthalten → Nachbearbeitung erforderlich; PowerShells [Convert]::ToBase64String ist sicherer

Überprüfe immer, ob keine Zeilenumbrüche vorhanden sind, bevor du in GitHub Secrets einfügst. Das Hinzufügen von tr -d '\n\r' beim Dekodieren in CI ist gängige Praxis.

- name: Decode Keystore
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > release.keystore

Fazit

Mit GitHub Actions wird der Release-Prozess zu:

„Einfach einen Tag erstellen und pushen"

Dies ermöglicht es, die Release-Frequenz zu erhöhen, ohne den Arbeitsaufwand wesentlich zu steigern.

Für Teams mit mehreren Entwicklern lohnt sich die Einführung allein schon für die Lösung des Problems „Releases nur von diesem einen Mac aus möglich". Wenn du mit denselben Problemen in der Android-App-Entwicklung kämpfst, probiere es aus.

Referenzen

Android / Signierung

Google Play Console

GitHub Actions

Gradle