Eine Android-App lokal zu bauen, ist zunächst relativ einfach. Schwieriger wird es allerdings, wenn der Build-Prozess reproduzierbar sein und nicht von einem einzelnen Entwicklerrechner abhängen soll.
Schnell entsteht ansonsten eine Situation, in der eine Person lokal eine .aab-Datei erstellt, jemand anderes eine .apk exportiert und einige Wochen später niemand mehr genau weiß, welcher Keystore, welches Passwort oder welcher Befehl für das finale Release verwendet wurde.
Genau hier können uns GitHub Actions unterstützen.
In diesem Beitrag zeige ich Schritt für Schritt, wie wir eine .NET MAUI Android App innerhalb einer GitHub Action bauen und signieren können. Am Ende erhalten wir sowohl eine .aab- als auch eine .apk-Datei, die direkt über den jeweiligen Workflow-Run heruntergeladen werden können.
Unser Ziel ist also:
- eine .NET MAUI Android App über GitHub Actions bauen
- die Anwendung mit einem Android Keystore signieren
- sowohl eine
.aab- als auch eine.apk-Datei erzeugen - die erzeugten Dateien als Artifacts des Workflows bereitstellen
Für das Beispiel verwende ich .NET 10, .NET MAUI und einen Windows-basierten GitHub Actions Runner.
Warum sollten wir Android Builds mit GitHub Actions automatisieren?
Ein automatisierter Build bietet uns einige Vorteile.
Jeder Build verwendet dieselben SDK- und Workload-Versionen, der Signing-Prozess läuft immer gleich ab und die erzeugten Dateien werden direkt zusammen mit dem jeweiligen Workflow-Run gespeichert.
Darüber hinaus können wir Versionsnummern automatisch aus unserem Pipeline-Run ableiten.
Vor allem reduzieren wir aber Unterschiede zwischen lokalen Entwicklungsumgebungen. Der Release-Build hängt damit nicht mehr davon ab, welche SDK-Version oder welche Konfiguration gerade auf einem Entwicklerrechner vorhanden ist.
Bei einer .NET MAUI Anwendung eignet sich Android außerdem sehr gut als Einstieg in die Build-Automatisierung. Im Vergleich zum Signing einer iOS-Anwendung ist der benötigte Workflow relativ überschaubar.
Voraussetzungen
Bevor wir mit unserer GitHub Action beginnen, benötigen wir zunächst einige Dinge:
- eine .NET MAUI App mit
net10.0-android - ein GitHub Repository mit unserer Anwendung
- eine über
global.jsonfestgelegte .NET SDK-Version - einen Android Keystore
- GitHub Secrets für den Keystore und die benötigten Passwörter
In meinem Beispiel-Repository befindet sich das .NET MAUI Projekt unter folgendem Pfad:
src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj
Schritt 1: .NET 10 für Android verwenden
Zunächst sollte unser .NET MAUI Projekt Android explizit mit .NET 10 als Target Framework verwenden.
In der .csproj-Datei finden wir beispielsweise:
<TargetFrameworks>net10.0-android</TargetFrameworks>
Sollte eure Anwendung noch net9.0-android verwenden, müsst ihr entweder zunächst auf .NET 10 aktualisieren oder den späteren Workflow entsprechend anpassen.
Wichtig ist vor allem, dass das Target Framework der Anwendung und das Framework innerhalb des Workflows übereinstimmen.
Schritt 2: SDK-Version mit global.json festlegen
Unsere CI-Pipeline sollte nicht davon abhängig sein, welche .NET SDK-Version gerade zufällig auf dem GitHub Runner installiert oder als Standard konfiguriert ist.
Daher erstellen wir im Root-Verzeichnis unseres Repositories eine global.json.
{
"sdk": {
"version": "10.0.200",
"rollForward": "latestFeature"
}
}
Damit erreichen wir zwei Dinge:
- lokale Builds und CI-Builds verwenden dieselbe SDK-Version
- ein SDK-Update erfolgt bewusst und nicht automatisch durch ein aktualisiertes Runner-Image
GitHub Actions kann diese Datei später direkt über actions/setup-dotnet verwenden.
Schritt 3: Android Keystore erstellen
Damit wir eine signierte Android-Anwendung erzeugen können, benötigen wir einen Keystore.
Falls ihr bereits einen Keystore für eure Anwendung besitzt, solltet ihr natürlich diesen verwenden.
Für eine neue Anwendung können wir über keytool einen neuen Keystore erzeugen.
keytool -genkeypair \
-v \
-storetype PKCS12 \
-keystore myapp.keystore \
-alias myapp \
-keyalg RSA \
-keysize 2048 \
-validity 10000
Die einzelnen Parameter haben dabei folgende Bedeutung:
-genkeypairerzeugt ein neues Schlüsselpaar-vaktiviert eine ausführlichere Ausgabe-storetype PKCS12verwendet PKCS#12 als Keystore-Format-keystore myapp.keystorelegt den Namen unseres Keystores fest-alias myappdefiniert den Alias des Schlüssels-keyalg RSAverwendet RSA-keysize 2048erzeugt einen Schlüssel mit 2048 Bit-validity 10000setzt die Gültigkeit des Zertifikats auf 10.000 Tage
Die 10.000 Tage entsprechen etwas mehr als 27 Jahren.
Das ist bei Android durchaus sinnvoll, da das Signing-Zertifikat langfristig gültig bleiben sollte. Schließlich benötigen wir dasselbe Zertifikat auch für zukünftige Updates unserer Anwendung.
Falls ihr den Keystore beispielsweise innerhalb eines Skripts ohne Benutzereingabe erzeugen möchtet, könnt ihr auch alle notwendigen Informationen direkt angeben:
keytool -genkeypair \
-v \
-storetype PKCS12 \
-keystore myapp.keystore \
-alias myapp \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass "YOUR_KEYSTORE_PASSWORD" \
-keypass "YOUR_KEY_PASSWORD" \
-dname "CN=Name, OU=Mobile, O=Company, L=City, S=State, C=US"
Schritt 4: Keystore nach Base64 konvertieren
GitHub Secrets speichern Text und keine Binärdateien.
Daher können wir unsere .keystore-Datei nicht einfach direkt als Secret hinterlegen. Stattdessen konvertieren wir die Datei zunächst in einen Base64-String.
Unter PowerShell können wir dafür folgende Befehle verwenden:
$base64 =
[Convert]::ToBase64String([IO.File]::ReadAllBytes(".\myapp.keystore"))
$base64 | Set-Content -Path .\myapp.keystore.base64.txt -NoNewline
Get-Content .\myapp.keystore.base64.txt
Unter Linux bzw. Bash funktioniert es entsprechend mit:
base64 -w 0 myapp.keystore > myapp.keystore.base64.txt
cat myapp.keystore.base64.txt
Damit erhalten wir eine Textrepräsentation unseres Keystores, die wir anschließend als GitHub Secret hinterlegen können.
Schritt 5: GitHub Secrets anlegen
Nun öffnen wir unser GitHub Repository und wechseln unter Settings in den Bereich für Secrets and variables.
Für unseren Android Build benötigen wir vier Secrets:
ANDROID_KEYSTOREANDROID_KEY_ALIASANDROID_KEY_PASSWORDANDROID_KEYSTORE_PASSWORD
ANDROID_KEYSTORE enthält dabei den vollständigen Base64-String, den wir im vorherigen Schritt erzeugt haben.
ANDROID_KEY_ALIAS entspricht dem Alias innerhalb unseres Keystores, in unserem Beispiel also myapp.
Die beiden anderen Secrets enthalten das Passwort für den Key beziehungsweise den gesamten Keystore.
Damit haben wir alle Informationen vorbereitet, die unsere GitHub Action später zum Signieren der Anwendung benötigt.
Schritt 6: GitHub Action erstellen
Jetzt können wir unsere eigentliche GitHub Action erstellen.
Dafür legen wir folgende Datei an:
.github/workflows/android-build.yml
Der vollständige Workflow sieht folgendermaßen aus:
name: Build .NET MAUI Android App (.aab, .apk)
on:
workflow_dispatch:
jobs:
build-android:
runs-on: windows-latest
env:
PROJECT_PATH: src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Install MAUI Workload
run: dotnet workload install maui
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" | Out-File -Encoding ascii -FilePath myapp.keystore.b64
certutil -decode myapp.keystore.b64 myapp.keystore
- name: Set version variables
run: |
$buildNumber = "${{ github.run_number }}"
echo "VERSION_DISPLAY=1.0.$buildNumber" >> $env:GITHUB_ENV
echo "VERSION_CODE=$((10000 + $buildNumber))" >> $env:GITHUB_ENV
- name: Restore NuGet packages
run: dotnet restore "$env:PROJECT_PATH"
- name: Build (Release)
run: |
dotnet publish "$env:PROJECT_PATH" -f net10.0-android -c Release `
-p:ApplicationVersion="$env:VERSION_CODE" `
-p:ApplicationDisplayVersion="$env:VERSION_DISPLAY" `
-p:AndroidKeyStore=true `
-p:AndroidSigningKeyAlias="${{ secrets.ANDROID_KEY_ALIAS }}" `
-p:AndroidSigningKeyPass="${{ secrets.ANDROID_KEY_PASSWORD }}" `
-p:AndroidSigningStorePass="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" `
-p:AndroidSigningStore=myapp.keystore `
-p:AndroidPackageFormats="aab;apk"
- name: Copy signed .aab and .apk to output
run: |
$destination = "$(pwd)\output"
New-Item -ItemType Directory -Path $destination -Force | Out-Null
$projectDir = Split-Path "$env:PROJECT_PATH" -Parent
$publishDir = Join-Path $projectDir "bin\Release\net10.0-android\publish"
$files = Get-ChildItem -Path $publishDir -Recurse -Include *.aab,*.apk -File
foreach ($file in $files) {
Copy-Item $file.FullName -Destination $destination -Force
}
- name: Upload final AAB and APK only
uses: actions/upload-artifact@v4
with:
name: android-build
path: output
Schritt 7: Was macht unser Workflow?
Schauen wir uns noch einmal kurz an, was innerhalb unserer GitHub Action passiert.
- Repository auschecken
- .NET SDK installieren
- .NET MAUI Workload installieren
- Keystore wiederherstellen
- Versionsnummer erzeugen
- Anwendung bauen und signieren
- Artifacts sammeln und veröffentlichen
Schritt 8: Workflow ausführen
Aktuell verwenden wir in unserem Workflow ausschließlich:
on:
workflow_dispatch:
Dadurch wird der Build nicht automatisch bei jedem Commit gestartet.
Nachdem wir die Workflow-Datei in unser Repository gepusht haben, können wir in GitHub in den Bereich Actions wechseln und den Workflow dort manuell starten.
Das eignet sich sehr gut, um die Pipeline zunächst zu testen.
Sobald alles funktioniert, können wir den Trigger natürlich erweitern und den Workflow beispielsweise bei einem Push, einem Tag oder bei der Erstellung eines Releases ausführen.
Nach einem erfolgreichen Run sollten wir ein Artifact mit dem Namen android-build sehen.
Dieses enthält unsere finalen, signierten .aab- und .apk-Dateien.
Typische Probleme
Auch wenn unser Workflow insgesamt recht einfach aufgebaut ist, gibt es einige typische Fehlerquellen.
Falscher Projektpfad
Der Wert von PROJECT_PATH muss auf die richtige .csproj-Datei zeigen.
env:
PROJECT_PATH: src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj
Stimmt dieser Pfad nicht, werden bereits dotnet restore oder spätestens dotnet publish fehlschlagen.
Falsche Secrets
Keystore-Passwort, Key-Passwort und Alias müssen exakt zu unserem Keystore passen.
Stimmt einer dieser Werte nicht, schlägt das Signing während dotnet publish fehl.
Fehlender MAUI Workload
Auch das MAUI Workload darf innerhalb eines frischen GitHub Runners nicht vergessen werden.
dotnet workload install maui
Andernfalls kann unsere .NET MAUI App nicht korrekt veröffentlicht werden.
Unterschiedliche SDK-Versionen
Ohne eine global.json kann sich das Verhalten unseres Builds ändern, sobald GitHub das verwendete Runner-Image aktualisiert.
Daher empfehle ich, die verwendete .NET SDK-Version immer explizit festzulegen.
Es wird nur eine .aab erzeugt
In manchen Konfigurationen erzeugt dotnet publish im Release-Modus standardmäßig nur ein Android App Bundle.
Wenn ihr sowohl .aab als auch .apk im selben Build benötigt, setzt beim Publish zusätzlich:
-p:AndroidPackageFormats="aab;apk"
AAB oder APK?
Warum erzeugen wir eigentlich beide Formate?
Beide Dateien haben unterschiedliche Anwendungsfälle.
AAB
Das Android App Bundle (.aab) ist das bevorzugte Format für die Veröffentlichung einer Anwendung über den Google Play Store.
Google verwendet das Bundle anschließend, um optimierte APKs für die verschiedenen Geräte der Benutzer zu erzeugen.
APK
Eine Android Package-Datei (.apk) können wir dagegen direkt auf einem Android-Gerät installieren.
Sie eignet sich daher beispielsweise sehr gut für:
- interne Tests
- direkte Installation auf Testgeräten
- schnelle Überprüfung eines Builds
- Verteilung außerhalb des Google Play Stores
Wenn wir beide Formate innerhalb derselben Pipeline erzeugen, erhalten wir mit einem einzigen Build sowohl das benötigte Format für den Store als auch eine direkt installierbare Version für unsere Tests.
Fazit
Das automatisierte Bauen einer .NET MAUI Android App muss nicht kompliziert sein.
Sobald wir den Umgang mit dem Keystore, die GitHub Secrets, die verwendete .NET SDK-Version und unseren dotnet publish-Befehl einmal sauber definiert haben, erhalten wir einen reproduzierbaren Build-Prozess.
Und genau das ist für mich der eigentliche Vorteil einer solchen Pipeline.
Es geht nicht nur darum, einen manuellen Schritt zu automatisieren. Wir stellen vielmehr sicher, dass jedes Release unserer Android-Anwendung unter denselben Bedingungen und auf dieselbe Art erstellt wird.
Ein vollständiges Beispiel mit einer einfachen .NET MAUI Anwendung und den entsprechenden GitHub Actions findet ihr in meinem Repository